import { ErrorHandler, Severity } from '../utils/error-handler'; import { clamp01 } from '../utils/math'; import type { VibeId, VibePreset } from '../vibes'; import type { GardenAudioConfig } 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, GardenAudioStartOptions, GardenAudioStroke, } from './garden-audio-types'; import { GenerativePianoEngine } from './generative-piano'; import { NoiseBurstPlayer } from './noise-burst-player'; import { PianoSampler } from './piano-sampler'; export type { GardenAudioSnapshot, GardenAudioStartOptions, GardenAudioStroke, } from './garden-audio-types'; type AudioLifecycle = 'idle' | 'started' | 'destroyed'; const muteGain = 0.0001; const muteRampSeconds = 0.02; const brushUpPianoFinishSeconds = 1.2; const brushUpPianoFadeSeconds = 1.1; 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 lifecycle: AudioLifecycle = 'idle'; private isMuted = false; private isGestureActive = false; private isPianoStoppedAfterGesture = false; private fadePianoAfter: number | null = null; private masterVolume: number; private stopPianoAfter: number | null = null; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; 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: GardenAudioStartOptions = {}): void { const isUserGesture = options.userGesture === true; if (this.lifecycle === 'destroyed') { 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'; let resumePromise: Promise | null = null; if (isUserGesture) { this.graph.startMediaElementOutput(); this.graph.unlock(); } if (needsResume) { if (!isUserGesture) { return; } resumePromise = context.resume(); } if (isUserGesture) { this.graph.unlock(); } if (resumePromise) { void resumePromise .then(() => { if (this.graph.context === context && this.lifecycle !== 'destroyed') { this.graph.unlock(); this.completeStart(vibe, { context, startupRampSeconds, }); } }) .catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not resume audio playback.', severity: Severity.WARNING, }); }); return; } this.completeStart(vibe, { context, startupRampSeconds, }); } private completeStart( vibe: VibePreset, { context, startupRampSeconds, }: { context: AudioContext; startupRampSeconds: number; } ): void { if (this.graph.context !== context || this.lifecycle === 'destroyed') { return; } if (this.isMuted) { this.graph.setMasterGain(muteGain, muteRampSeconds); return; } this.lifecycle = 'started'; this.applyVibe(vibe); this.pianoEngine.prime(context.currentTime); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); const pianoLoad = this.piano.loadIfIdle(context); if (pianoLoad) { void pianoLoad .then(() => { if (this.graph.context === context && this.lifecycle !== 'destroyed') { this.pianoEngine.cue(context.currentTime); } }) .catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not load piano samples. Using synthesized audio.', severity: Severity.WARNING, }); }); } } public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { const previousVibeId = this.currentVibeId; this.start(vibe, options); const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; if (didChangeVibe) { this.piano.stopAll(); } 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 ? muteGain : this.masterVolume, isMuted ? muteRampSeconds : this.config.fadeInSeconds ); } 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.isPianoStoppedAfterGesture = false; this.fadePianoAfter = null; this.stopPianoAfter = null; this.gestureState.beginGesture(); this.energy.beginGesture(context.currentTime); this.pianoEngine.beginGesture(); } public endGesture(): void { this.gestureState.endGesture(); this.isGestureActive = false; const context = this.graph.context; this.isPianoStoppedAfterGesture = true; this.fadePianoAfter = context ? context.currentTime + brushUpPianoFinishSeconds : null; this.stopPianoAfter = null; 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); this.energy.update(context.currentTime); if (snapshot.isErasing) { this.energy.silence(); } if (!this.isGestureActive && this.isPianoStoppedAfterGesture) { if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) { this.piano.fadeAll(brushUpPianoFadeSeconds); this.fadePianoAfter = null; this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds; } if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) { this.piano.stopAll(); this.pianoEngine.reset(); this.stopPianoAfter = null; } this.updateDelay(snapshot); return; } this.pianoEngine.renderLookahead({ vibe: snapshot.vibe, now: context.currentTime, activity: snapshot.isErasing ? this.config.eraser.pianoActivity : this.energy.getLevel(), }); this.updateDelay(snapshot); } public stroke(stroke: GardenAudioStroke): void { if (this.lifecycle === 'destroyed' || this.isMuted) { return; } this.start(stroke.vibe); 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.energy.recordEraserStroke(); this.playEraser(strokeEnergy, now); return; } this.energy.recordStroke(strokeEnergy, now); this.pianoEngine.recordStroke({ vibe: stroke.vibe, now, activity: strokeEnergy, maniaAmount: frame.maniaAmount, }); } public async destroy(): Promise { this.lifecycle = 'destroyed'; await this.graph.close(); this.piano.reset(); this.energy.reset(); this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; this.isGestureActive = false; this.isPianoStoppedAfterGesture = false; this.fadePianoAfter = null; this.stopPianoAfter = null; 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 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): void { const context = this.graph.context; if (!context) { return; } const profile = getVibeProfile(snapshot.vibe); const activity = snapshot.isErasing ? this.config.delay.erasingActivity : this.energy.getLevel(); this.graph.updateDelay(profile, activity); } private applyVibe(vibe: VibePreset): void { if (!this.graph.context || this.currentVibeId === vibe.id) { return; } this.currentVibeId = vibe.id; this.graph.applyDelayProfile(getVibeProfile(vibe)); this.pianoEngine.cue(this.graph.context.currentTime); } }