Add generative garden audio

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:57 +01:00
parent f8294934fd
commit c2efb33683
45 changed files with 3730 additions and 0 deletions

View file

@ -0,0 +1,127 @@
import type { PianoNoteRole } from './garden-audio-types';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
export interface GardenAudioVibeSettings {
idleIntensity: number;
bpm: number;
rampUpIntensity: number;
rampUpTime: number;
noteLength: number;
notePitchOffset: number;
brightness: number;
scale?: Array<number>;
progression?: Array<GardenAudioChord>;
}
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
rootMidi: number;
scale: Array<number>;
progression: Array<GardenAudioChord>;
}
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
brightness: 1,
};
export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeBeats: 0.5,
timeMinSeconds: 0.18,
timeMaxSeconds: 0.72,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,
activityFeedbackWeight: 0.08,
feedbackMax: 0.32,
feedbackMin: 0.04,
outputActivityWeight: 0.5,
outputBase: 0.65,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.26,
releaseSeconds: 0.34,
lowpassHz: 7000,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
},
rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
bpm: defaultGardenAudioVibeSettings.bpm,
stepsPerBeat: 4,
stepsPerBar: 16,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
pan: 0,
pianoActivity: 0,
},
energy: {
decaySeconds: 0.9,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
pianoBusGains: {
pad: 0.86,
support: 0.94,
texture: 0.88,
gesture: 1,
brush: 0.9,
stinger: 0.92,
} satisfies Record<PianoNoteRole, number>,
pianoBusActivityDucking: {
pad: 0.42,
support: 0.18,
texture: -0.06,
gesture: 0,
brush: -0.08,
stinger: 0,
} satisfies Record<PianoNoteRole, number>,
noiseBusGain: 0.72,
},
input: {
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
activitySoftCeiling: 0.96,
activityAttackSeconds: 0.055,
activityReleaseSeconds: 0.2,
minAudibleDistance: 0.0025,
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
},
});
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;

View file

@ -0,0 +1,66 @@
import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly config: GardenAudioConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.lastEnergyUpdateAt = now;
}
public endGesture(): void {
this.isGestureActive = false;
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
}
}
public silence(): void {
this.targetEnergy = 0;
this.energy = 0;
}
public update(now: number, profile: GardenAudioVibeProfile): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
}
const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.config.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = this.config.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = profile.rampUpTime;
}
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
this.isGestureActive = false;
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
}
}

View file

@ -0,0 +1,75 @@
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
interface GardenAudioGestureFrame {
activity: number;
maniaAmount: number;
}
export class GardenAudioGestureState {
private activity = 0;
private maniaAmount = 0;
private isManic = false;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public recordStroke({
metrics,
}: {
metrics: GardenAudioStrokeMetrics;
}): GardenAudioGestureFrame {
const targetActivity = this.getTargetActivity(metrics);
const activityTimeConstant =
targetActivity > this.activity
? this.inputConfig.activityAttackSeconds
: this.inputConfig.activityReleaseSeconds;
this.activity = approach(
this.activity,
targetActivity,
metrics.elapsedSeconds,
activityTimeConstant
);
if (this.activity >= this.inputConfig.manicActivityThreshold) {
this.isManic = true;
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
this.isManic = false;
}
const maniaTarget = this.isManic
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
: 0;
this.maniaAmount = approach(
this.maniaAmount,
maniaTarget,
metrics.elapsedSeconds,
this.inputConfig.maniaSmoothingSeconds
);
return {
activity: this.activity,
maniaAmount: this.maniaAmount,
};
}
public reset(): void {
this.activity = 0;
this.maniaAmount = 0;
this.isManic = false;
}
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
const speedRange =
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}
}

View file

@ -0,0 +1,347 @@
import { clamp } from '../utils/math';
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
type NavigatorWithAudioSession = Navigator & {
audioSession?: {
type:
| 'auto'
| 'playback'
| 'ambient'
| 'transient'
| 'transient-solo'
| 'play-and-record';
};
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: SILENT_AUDIO_GAIN,
closeRampSeconds: 0.015,
delayMaxSeconds: 2,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.1,
attackSeconds: 0.018,
releaseSeconds: 0.18,
},
} as const;
const delayFilterTuning = {
feedbackHighPassHz: 180,
feedbackLowPassHz: 5200,
returnLowPassHz: 6200,
};
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
private masterGain: GainNode | null = null;
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private previousAudioSessionType: AudioSessionType | null = null;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
return this.context;
}
if (!canCreate) {
return null;
}
const AudioContextConstructor = globalThis.AudioContext;
if (!AudioContextConstructor) {
return null;
}
// Tells iOS to treat this as media playback, so the hardware ringer/mute
// switch does not silence Web Audio output. No-op on browsers without the
// Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
this.previousAudioSessionType ??= audioSession.type;
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
compressor.threshold.value = graphTuning.compressor.thresholdDb;
compressor.knee.value = graphTuning.compressor.kneeDb;
compressor.ratio.value = graphTuning.compressor.ratio;
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
return context;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
}
this.masterGain.gain.setTargetAtTime(
targetGain,
this.context.currentTime,
timeConstantSeconds
);
}
public applyDelayProfile(bpm: number): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.getDelayTimeSecondsForBpm(bpm),
this.context.currentTime,
this.config.delay.timeRampSeconds
);
}
public updateDelay(activity: number, bpm: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
const now = this.context.currentTime;
const normalizedActivity = clamp(activity, 0, 1);
this.delayNode.delayTime.setTargetAtTime(
this.getDelayTimeSecondsForBpm(bpm),
now,
this.config.delay.timeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
normalizedActivity * this.config.delay.activityFeedbackWeight,
this.config.delay.feedbackMin,
this.config.delay.feedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
(this.config.delay.outputBase +
normalizedActivity * this.config.delay.outputActivityWeight) *
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
now,
this.config.updateRampSeconds
);
this.updatePianoBusGains(normalizedActivity, now);
}
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
if (!this.context) {
this.pianoBusGainScale = clamp(targetScale, 0, 1);
return;
}
const now = this.context.currentTime;
this.pianoBusGainScale = clamp(targetScale, 0, 1);
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
}
public async close(): Promise<void> {
const context = this.context;
if (!context) {
return;
}
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
graphTuning.closeGain,
context.currentTime,
graphTuning.closeRampSeconds
);
}
this.clearNodes();
if (context.state !== 'closed') {
await context.close().catch(() => undefined);
}
this.restoreAudioSessionType();
}
private restoreAudioSessionType(): void {
const previousType = this.previousAudioSessionType;
this.previousAudioSessionType = null;
if (previousType === null) {
return;
}
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = previousType;
}
}
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
const feedbackHighPass = context.createBiquadFilter();
const feedbackLowPass = context.createBiquadFilter();
const returnLowPass = context.createBiquadFilter();
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass';
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
feedbackLowPass.type = 'lowpass';
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
returnLowPass.type = 'lowpass';
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
delayInput.connect(delayNode);
delayNode.connect(feedbackHighPass);
feedbackHighPass.connect(feedbackLowPass);
feedbackLowPass.connect(delayFeedback);
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
delayOutput.connect(masterGain);
this.delayInput = delayInput;
this.delayNode = delayNode;
this.delayFeedback = delayFeedback;
this.delayOutput = delayOutput;
}
private createBuses(context: AudioContext, masterGain: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(masterGain);
this.eventBus = eventBus;
this.pianoBuses.clear();
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
(role) => {
const bus = context.createGain();
bus.gain.value = this.config.graph.pianoBusGains[role];
bus.connect(eventBus);
this.pianoBuses.set(role, bus);
}
);
this.noiseBus = context.createGain();
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
this.noiseBus.connect(eventBus);
}
private updatePianoBusGains(
activity: number,
now: number,
timeConstantSeconds?: number
): void {
const effectiveTimeConstantSeconds =
timeConstantSeconds ??
(now < this.pianoBusGainScaleAutomationUntil
? this.pianoBusGainScaleTimeConstantSeconds
: this.config.updateRampSeconds);
this.lastPianoBusActivity = activity;
this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime(
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
effectiveTimeConstantSeconds
);
});
}
private getDelayTimeSecondsForBpm(bpm: number): number {
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
return clamp(
(60 / safeBpm) * this.config.delay.timeBeats,
this.config.delay.timeMinSeconds,
this.config.delay.timeMaxSeconds
);
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
1,
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
for (let index = 0; index < data.length; index++) {
data[index] =
graphTuning.noiseMin +
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
}
return buffer;
}
private clearNodes(): void {
this.context = null;
this.eventBus = null;
this.delayInput = null;
this.noiseBus = null;
this.noiseBuffer = null;
this.masterGain = null;
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}

View file

@ -0,0 +1,27 @@
import type { GardenAudioStroke } from './garden-audio-types';
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
}
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(
1,
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
);
const normalizedDistance = distancePixels / normalizationPixels;
return {
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};

View file

@ -0,0 +1,33 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const getProfileScale = (vibe: VibePreset): Array<number> => {
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
return [...scale];
};
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
(chord) => ({ ...chord })
);
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
return {
...vibe.audio,
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
scale: getProfileScale(vibe),
progression: getProfileProgression(vibe),
};
};

View file

@ -0,0 +1,48 @@
import type { VibePreset } from '../vibes';
export interface GardenAudioSnapshot {
vibe: VibePreset;
isErasing: boolean;
}
export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize: ArrayLike<number>;
isErasing: boolean;
elapsedSeconds: number;
}
export interface LoadedPianoSample {
midi: number;
buffer: AudioBuffer;
}
export interface PianoNote {
midi: number;
velocity: number;
startTime: number;
durationSeconds: number;
pan: number;
role?: PianoNoteRole;
delaySend?: number;
lowpassHz?: number;
sustainSeconds?: number;
}
export type PianoNoteRole =
| 'pad'
| 'support'
| 'texture'
| 'gesture'
| 'brush'
| 'stinger';
export interface NoiseBurst {
startTime: number;
durationSeconds: number;
gain: number;
filterHz: number;
pan: number;
}

448
src/audio/garden-audio.ts Normal file
View file

@ -0,0 +1,448 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
} from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile } from './garden-audio-music';
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
type PianoReleasePhase =
| { kind: 'idle' }
| { kind: 'awaiting-fade' }
| { kind: 'scheduled-fade'; fadeAt: number }
| { kind: 'settling'; stopAt: number };
const muteRampSeconds = 0.02;
const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: VibeId | null = null;
private currentVibe: VibePreset | null = null;
private lifecycle: AudioLifecycle = 'idle';
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
private isMuted = false;
private isGestureActive = false;
private masterVolume: number;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0;
private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config);
this.gestureState = new GardenAudioGestureState(config.input);
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
return;
}
if (
this.lifecycle === 'started' &&
this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running' &&
this.hasLoadedPiano
) {
return;
}
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
const startupRampSeconds = isUserGesture
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
const startRequestId = ++this.startRequestId;
if (needsResume) {
if (!isUserGesture) {
return;
}
void context
.resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not resume audio playback.',
severity: Severity.WARNING,
});
});
return;
}
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
}
private completeStart(
vibe: VibePreset,
{
context,
startRequestId,
startupRampSeconds,
}: {
context: AudioContext;
startRequestId: number;
startupRampSeconds: number;
}
): void {
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
return;
}
if (this.isMuted) {
this.activateMutedStart(vibe, context);
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
return;
}
void this.piano
.load(context)
.then(() => {
if (!this.canCompleteStart(context, startRequestId)) {
return;
}
this.activateStart(vibe, context, startupRampSeconds, true);
})
.catch((error) => {
if (this.canCompleteStart(context, startRequestId)) {
this.activateStart(vibe, context, startupRampSeconds, false);
}
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples.',
severity: Severity.WARNING,
});
});
}
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
return (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted &&
this.startRequestId === startRequestId
);
}
private activateStart(
vibe: VibePreset,
context: AudioContext,
startupRampSeconds: number,
cuePiano: boolean
): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) {
this.hasLoadedPiano = true;
this.pianoEngine.cue(context.currentTime, profile);
}
}
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
this.hasLoadedPiano = false;
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
if (this.graph.context === context) {
this.pianoEngine.reset();
}
}
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
if (didChangeVibe) {
this.piano.stopAll();
this.hasLoadedPiano = false;
}
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
this.lifecycle !== 'destroyed' &&
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
if (this.isMuted === isMuted) {
return;
}
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
this.start(this.currentVibe);
}
}
public setMasterVolume(masterVolume: number): void {
this.masterVolume = clamp01(masterVolume);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
}
public beginGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.isGestureActive = true;
this.pianoReleasePhase = { kind: 'idle' };
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.reset();
this.isGestureActive = false;
this.pianoReleasePhase = { kind: 'awaiting-fade' };
this.energy.endGesture();
this.pianoEngine.endGesture();
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (this.lifecycle !== 'started' || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
const profile = getVibeProfile(snapshot.vibe);
this.energy.update(context.currentTime, profile);
if (snapshot.isErasing) {
this.energy.silence();
}
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot, profile);
return;
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing
? this.config.eraser.pianoActivity
: this.energy.getLevel(),
});
this.updateDelay(snapshot, profile);
}
public stroke(stroke: GardenAudioStroke): void {
if (this.lifecycle !== 'started' || this.isMuted) {
return;
}
const context = this.graph.context;
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.playEraser(strokeEnergy, now);
return;
}
const profile = getVibeProfile(stroke.vibe);
this.energy.recordStroke(strokeEnergy, profile);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
maniaAmount: frame.maniaAmount,
});
}
public async destroy(): Promise<void> {
this.lifecycle = 'destroyed';
await this.graph.close();
this.piano.reset();
this.hasLoadedPiano = false;
this.energy.reset();
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.currentVibe = null;
this.isGestureActive = false;
this.pianoReleasePhase = { kind: 'idle' };
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
return;
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}
this.lastVibeStingerAt = now;
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
const fadeAt = this.pianoEngine.release(vibe, now);
if (now < fadeAt) {
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
return;
}
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.pianoReleasePhase = {
kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
}
if (
this.pianoReleasePhase.kind === 'scheduled-fade' &&
now >= this.pianoReleasePhase.fadeAt
) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.pianoReleasePhase = {
kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
}
if (
this.pianoReleasePhase.kind === 'settling' &&
now >= this.pianoReleasePhase.stopAt
) {
this.piano.stopAll();
this.pianoEngine.reset();
this.hasLoadedPiano = false;
this.pianoReleasePhase = { kind: 'idle' };
}
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: this.config.eraser.pan,
});
}
}
private updateDelay(
snapshot: GardenAudioSnapshot,
profile: GardenAudioVibeProfile
): void {
const context = this.graph.context;
if (!context) {
return;
}
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(activity, profile.bpm);
}
private applyVibe(vibe: VibePreset): void {
if (!this.graph.context || this.currentVibeId === vibe.id) {
return;
}
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.pianoEngine.cue(this.graph.context.currentTime, profile);
this.hasLoadedPiano = true;
}
}

View file

@ -0,0 +1,443 @@
export interface GardenAudioRegister {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioStyleVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
}
interface GenerativePianoTuning {
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
vibeChangeStinger: {
velocities: [number, number, number];
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
noteDurationSeconds: number;
spacingSeconds: number;
};
releaseResolution: {
durationSeconds: number;
fadeAfterSeconds: number;
velocities: [number, number, number];
delaySend: number;
lowpassExpression: number;
strumSeconds: number;
};
highActivityExtra: {
barOffset: number;
expressionMultiplier: number;
};
padChord: {
velocities: [number, number, number];
expressionVelocityWeight: number;
delaySend: number;
lowpassExpressionWeight: number;
};
supportNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
lowpassExpressionWeight: number;
expressionThreshold: number;
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
};
textureNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
idleExpressionThreshold: number;
mediumExpressionThreshold: number;
intenseSpacing: number;
idlePhase: number;
};
gestureAccent: {
rotationStrengthMultiplier: number;
quantizeStepLookahead: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
};
touchNote: {
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
lowpassBaseExpression: number;
lowpassStrengthWeight: number;
};
brushPhrase: {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
frameManiaWeight: number;
};
brushStream: {
inferredManiaThreshold: number;
inferredManiaRange: number;
registerManiaShift: number;
chordToneEverySteps: number;
durationBaseSeconds: number;
durationIntensitySeconds: number;
durationManiaSeconds: number;
durationMinSeconds: number;
durationMaxSeconds: number;
delaySendBase: number;
delaySendIntensityWeight: number;
delaySendManiaWeight: number;
delaySendMin: number;
delaySendMax: number;
velocityBase: number;
velocityIntensityWeight: number;
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
intenseThreshold: number;
activeThreshold: number;
};
brushStreamEcho: {
maniaThreshold: number;
stepModulo: number;
stepRemainder: number;
intensityThreshold: number;
octaveSemitones: number;
maxMidi: number;
velocityBase: number;
velocityIntensityWeight: number;
durationMinSeconds: number;
durationScale: number;
panScale: number;
delaySendMin: number;
delaySendScale: number;
lowpassBaseExpression: number;
lowpassManiaWeight: number;
};
brushMotif: {
highThreshold: number;
mediumThreshold: number;
highOffset: number;
mediumOffset: number;
lowOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
midiMin: number;
midiMaxForMin: number;
minimumSpan: number;
midiMax: number;
};
candidateOctaveSearch: {
min: number;
max: number;
};
stereoWidth: {
idle: number;
active: number;
intense: number;
intenseThreshold: number;
};
stylePanOffsetScale: number;
lowpass: {
midiBase: number;
midiRange: number;
midiLiftHz: number;
expressionBase: number;
expressionWeight: number;
};
styleRotationBars: number;
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
idleTextureBarSpacing: number;
mediumTextureBarSpacing: number;
textureBeat: number;
highActivityExtraBeat: number;
highActivityExtraThreshold: number;
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreChordToneWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
}
export const generativePianoTuning: GenerativePianoTuning = {
stylePools: [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 78,
preferredMidi: 70,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
],
padRegisters: [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
],
vibeChangeStinger: {
velocities: [0.1, 0.085, 0.07],
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
noteDurationSeconds: 1.1,
spacingSeconds: 0.08,
},
releaseResolution: {
durationSeconds: 3.4,
fadeAfterSeconds: 2.4,
velocities: [0.064, 0.05, 0.038],
delaySend: 0.018,
lowpassExpression: 0.34,
strumSeconds: 0.055,
},
highActivityExtra: {
barOffset: 1,
expressionMultiplier: 0.9,
},
padChord: {
velocities: [0.046, 0.036, 0.029],
expressionVelocityWeight: 0.018,
delaySend: 0.008,
lowpassExpressionWeight: 0.24,
},
supportNote: {
velocityBase: 0.105,
velocityExpressionWeight: 0.07,
durationBaseSeconds: 1.35,
durationExpressionSeconds: 0.4,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
lowpassExpressionWeight: 0.7,
expressionThreshold: 0.55,
offsetsByStyle: [
[0, 2, 12],
[1, 2, 0, 12],
[2, 12, 3, 13],
],
},
textureNote: {
velocityBase: 0.09,
velocityExpressionWeight: 0.08,
durationBaseSeconds: 0.62,
durationExpressionSeconds: 0.24,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
idleExpressionThreshold: 0.35,
mediumExpressionThreshold: 0.7,
intenseSpacing: 1,
idlePhase: 1,
},
gestureAccent: {
rotationStrengthMultiplier: 3,
quantizeStepLookahead: 1,
velocityBase: 0.12,
velocityStrengthWeight: 0.09,
durationBaseSeconds: 0.48,
durationStrengthSeconds: 0.22,
delaySend: 0.012,
},
touchNote: {
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
durationStrengthSeconds: 0.18,
delaySend: 0.006,
lowpassBaseExpression: 0.55,
lowpassStrengthWeight: 0.35,
},
brushPhrase: {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
frameManiaWeight: 0.18,
},
brushStream: {
inferredManiaThreshold: 0.82,
inferredManiaRange: 0.18,
registerManiaShift: 0.3,
chordToneEverySteps: 4,
durationBaseSeconds: 0.48,
durationIntensitySeconds: 0.08,
durationManiaSeconds: 0.34,
durationMinSeconds: 0.14,
durationMaxSeconds: 0.62,
delaySendBase: 0.012,
delaySendIntensityWeight: 0.011,
delaySendManiaWeight: 0.006,
delaySendMin: 0.006,
delaySendMax: 0.032,
velocityBase: 0.1,
velocityIntensityWeight: 0.1,
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
intenseThreshold: 0.68,
activeThreshold: 0.34,
},
brushStreamEcho: {
maniaThreshold: 0.92,
stepModulo: 3,
stepRemainder: 1,
intensityThreshold: 0.95,
octaveSemitones: 12,
maxMidi: 84,
velocityBase: 0.035,
velocityIntensityWeight: 0.04,
durationMinSeconds: 0.11,
durationScale: 0.68,
panScale: -0.75,
delaySendMin: 0.006,
delaySendScale: 0.72,
lowpassBaseExpression: 0.62,
lowpassManiaWeight: 0.24,
},
brushMotif: {
highThreshold: 0.82,
mediumThreshold: 0.55,
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
},
registerBias: {
maniaShiftSemitones: 2,
midiMin: 36,
midiMaxForMin: 86,
minimumSpan: 4,
midiMax: 91,
},
candidateOctaveSearch: {
min: -3,
max: 3,
},
stereoWidth: {
idle: 0.46,
active: 0.9,
intense: 1.16,
intenseThreshold: 0.72,
},
stylePanOffsetScale: 0.35,
lowpass: {
midiBase: 48,
midiRange: 33,
midiLiftHz: 500,
expressionBase: 0.58,
expressionWeight: 0.32,
},
styleRotationBars: 2,
chordBars: 4,
supportBarSpacing: 2,
supportBarOffset: 1,
idleTextureBarSpacing: 2,
mediumTextureBarSpacing: 1,
textureBeat: 2,
highActivityExtraBeat: 3,
highActivityExtraThreshold: 0.45,
noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28,
noteScoreChordToneWeight: 0.75,
noteScoreRepeatPenalty: 3.2,
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 7,
brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5,
brushLayerMinIntensity: 0.12,
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.75,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.82,
};
export const styleVoices: [
GardenAudioStyleVoice,
GardenAudioStyleVoice,
GardenAudioStyleVoice,
] = [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import { clamp } from '../utils/math';
import type { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types';
const noiseBurstTuning = {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
filterType: 'bandpass',
} as const;
export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, noiseBus, noiseBuffer } = this.graph;
if (!context || !noiseBus || !noiseBuffer) {
return;
}
const scheduledStart = Math.max(
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
filter.type = noiseBurstTuning.filterType;
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = noiseBurstTuning.filterQ;
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(noiseBurstTuning.silentGain, gain),
scheduledStart + noiseBurstTuning.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(noiseBus);
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
const offsetSeconds =
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
source.start(scheduledStart, offsetSeconds);
source.stop(stopAt);
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
envelope.disconnect();
panner.disconnect();
},
{ once: true }
);
}
}

264
src/audio/piano-sampler.ts Normal file
View file

@ -0,0 +1,264 @@
import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass',
filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
releaseTimeConstantCount: 5,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
} as const;
export class PianoSampler {
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly graph: GardenAudioGraph
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.samples.length > 0) {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
return Promise.resolve();
}
return loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
}
public play({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
});
}
public stopAll(): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
this.activeVoices.forEach((voice) => {
this.stopVoice(voice, now);
});
this.activeVoices = [];
}
public reset(): void {
this.samples = [];
this.activeVoices = [];
}
private scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
}): void {
const { context, delayInput } = this.graph;
if (!context) {
return;
}
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
{ once: true }
);
}
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
voice.source.stop(stopAt);
}
private setSamples(samples: Array<LoadedPianoSample>): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}

271
src/audio/piano-samples.ts Normal file
View file

@ -0,0 +1,271 @@
import type { LoadedPianoSample } from './garden-audio-types';
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
interface PianoSampleDefinition {
note: string;
url: string;
}
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
settledCount: number;
totalCount: number;
}
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, note: 'A0' },
{ url: c1SampleUrl, note: 'C1' },
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
{ url: a1SampleUrl, note: 'A1' },
{ url: c2SampleUrl, note: 'C2' },
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
{ url: a2SampleUrl, note: 'A2' },
{ url: c3SampleUrl, note: 'C3' },
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
{ url: a3SampleUrl, note: 'A3' },
{ url: c4SampleUrl, note: 'C4' },
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
{ url: a4SampleUrl, note: 'A4' },
{ url: c5SampleUrl, note: 'C5' },
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
{ url: a5SampleUrl, note: 'A5' },
{ url: c6SampleUrl, note: 'C6' },
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
{ url: a6SampleUrl, note: 'A6' },
{ url: c7SampleUrl, note: 'C7' },
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
{ url: a7SampleUrl, note: 'A7' },
{ url: c8SampleUrl, note: 'C8' },
];
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = {
concurrency: 4,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
return Promise.reject(
new Error('OfflineAudioContext is required to preload piano samples.')
);
}
// Decoding ignores these, but the constructor demands real numbers.
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) {
emitPianoSampleProgress({
failedCount: 0,
loadedCount: loadedPianoSamples.length,
settledCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise.finally(unsubscribeProgress);
}
let loadedCount = 0;
let failedCount = 0;
let settledCount = 0;
const totalCount = pianoSampleDefinitions.length;
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
pianoSampleLoadPromise = loadPianoSampleBatch(
pianoSampleDefinitions,
async (sample) => {
try {
const loadedSample = await withTimeout(
(signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
loadedCount += 1;
return loadedSample;
} catch (error) {
failedCount += 1;
throw error;
} finally {
settledCount += 1;
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
}
}
)
.then(
(samples) => {
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
throw new Error(
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
);
}
return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
pianoSampleProgressListeners.clear();
throw error;
}
)
.finally(unsubscribeProgress);
return pianoSampleLoadPromise;
};
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: getMidiForPianoSample(sample), buffer };
};
const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>,
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
): Promise<Array<LoadedPianoSample>> => {
const results: Array<LoadedPianoSample> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
results.push(...batchResults);
}
return results;
};
const withTimeout = <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T> =>
new Promise((resolve, reject) => {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort();
reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs);
operation(controller.signal).then(
(value) => {
globalThis.clearTimeout(timeout);
resolve(value);
},
(error: unknown) => {
globalThis.clearTimeout(timeout);
reject(error);
}
);
});
const subscribeToPianoSampleProgress = (
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
): (() => void) => {
if (!onProgress) {
return () => undefined;
}
pianoSampleProgressListeners.add(onProgress);
if (lastPianoSampleProgress) {
onProgress(lastPianoSampleProgress);
}
return () => {
pianoSampleProgressListeners.delete(onProgress);
};
};
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
lastPianoSampleProgress = progress;
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
`./samples/${sample.note}v12.m4a`;
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
if (!match?.groups) {
throw new Error(`Invalid piano sample note ${sample.note}`);
}
const semitoneByName: Record<string, number> = {
C: 0,
D: 2,
E: 4,
F: 5,
G: 7,
A: 9,
B: 11,
};
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
};

BIN
src/audio/samples/A0v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C8v12.m4a Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,16 @@
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
under CC BY 3.0.
Source package: @audio-samples/piano-velocity12
Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
The app derives MIDI values from those note names in `piano-samples.ts`.
Repro notes: start from the matching `v12` OGG files in the source package and
transcode each selected sample to AAC/M4A without renaming the note/velocity
stem. The expected output filenames are `<note>v12.m4a`, for example
`C4v12.m4a`.

152
src/page/audio-control.ts Normal file
View file

@ -0,0 +1,152 @@
import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math';
const clampAudioVolume = (value: number): number => {
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
const safeValue = Number.isFinite(value) ? value : defaultVolume;
return Math.min(max, Math.max(min, clamp01(safeValue)));
};
const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
return storedVolume === null
? appConfig.toolbar.volume.default
: clampAudioVolume(Number(storedVolume));
};
const formatStoredAudioVolume = (volume: number): string =>
clampAudioVolume(volume).toFixed(2);
const STORED_MUTED_TRUE = '1';
const STORED_MUTED_FALSE = '0';
interface AudioControlOptions {
getGame: () => GameLoop | null;
hasStarted: () => boolean;
startButton: HTMLElement;
}
export class AudioControl {
private readonly soundButton = queryRequiredElement(
'[data-control="sound"]',
HTMLButtonElement
);
private readonly volumeControl = queryRequiredElement(
'.volume-control',
HTMLLabelElement
);
private readonly volumeSlider = queryRequiredElement(
'.volume-slider',
HTMLInputElement
);
private audioVolume = readInitialAudioVolume();
private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
this.soundButton.addEventListener('click', this.onToggleMute);
this.volumeSlider.addEventListener('input', this.onVolumeInput);
const passiveCaptureOptions = { capture: true, passive: true } as const;
const captureOptions = { capture: true } as const;
(
[
['touchstart', passiveCaptureOptions],
['pointerdown', passiveCaptureOptions],
['touchend', passiveCaptureOptions],
['pointerup', passiveCaptureOptions],
['click', captureOptions],
['keydown', captureOptions],
] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]>
).forEach(([event, opts]) => {
window.addEventListener(event, this.onUserGesture, opts);
});
this.render();
}
public get isMuted(): boolean {
return this.isMutedState || this.audioVolume <= 0;
}
public render(): void {
this.audioVolume = clampAudioVolume(this.audioVolume);
const isEffectivelyMuted = this.isMuted;
const volumePercent = Math.round(this.audioVolume * 100);
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
);
this.volumeControl.classList.toggle('muted', isEffectivelyMuted);
this.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
const game = this.options.getGame();
game?.setAudioVolume(this.audioVolume);
game?.setAudioMuted(isEffectivelyMuted);
}
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
this.audioVolume = appConfig.toolbar.volume.default;
}
this.isMutedState = !shouldUnmute;
this.persist();
this.render();
if (!this.isMutedState) {
this.options.getGame()?.startAudio(true);
}
};
private readonly onVolumeInput = () => {
this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value));
this.isMutedState = this.audioVolume <= 0;
this.persist();
this.render();
if (!this.isMutedState) {
this.options.getGame()?.startAudio(true);
}
};
private readonly onUserGesture = (event: Event) => {
if (
!this.options.hasStarted() ||
this.isMutedState ||
(event.target instanceof Node && this.options.startButton.contains(event.target)) ||
(event.target instanceof Node && this.soundButton.contains(event.target))
) {
return;
}
this.options.getGame()?.startAudio(true);
};
private persist(): void {
writeBrowserStorage(
appConfig.storage.audioMutedKey,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
appConfig.storage.audioVolumeKey,
formatStoredAudioVolume(this.audioVolume)
);
}
}