import { clamp01 } from '../utils/clamp'; import { ErrorHandler, Severity } from '../utils/error-handler'; import { VibePreset } from '../vibes'; import { 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, normalizeColorIndex } from './garden-audio-music'; import type { GardenAudioColorIndex, GardenAudioSnapshot, GardenAudioStartOptions, GardenAudioStroke, GardenAudioTouchDown, } 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, GardenAudioTouchDown, } from './garden-audio-types'; 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: string | null = null; private hasStarted = false; private isDestroyed = false; private isMuted = false; private isGestureActive = false; private selectedColorIndex: GardenAudioColorIndex = 0; private hasQueuedPianoLoad = false; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; public constructor(private readonly config: GardenAudioConfig) { this.graph = new GardenAudioGraph(config); this.piano = new PianoSampler(config, this.graph); this.noise = new NoiseBurstPlayer(config, 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 { if (this.isDestroyed || this.isMuted) { return; } const context = this.graph.ensureContext(options.userGesture === true); if (!context) { return; } const startupRampSeconds = options.userGesture === true ? this.config.muteRampSeconds : this.config.fadeInSeconds; const needsResume = context.state !== 'running' && context.state !== 'closed'; let resumePromise: Promise | null = null; if (needsResume) { if (options.userGesture !== true) { return; } resumePromise = context.resume(); } if (options.userGesture === true) { this.graph.unlock(); } if (resumePromise) { void resumePromise .then(() => { if (this.graph.context === context && !this.isDestroyed && !this.isMuted) { this.graph.unlock(); this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); } }) .catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not resume audio playback.', severity: Severity.WARNING, }); }); } this.hasStarted = true; this.applyVibe(vibe); this.pianoEngine.prime(context.currentTime); this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); if (!this.hasQueuedPianoLoad) { this.hasQueuedPianoLoad = true; void this.piano .load(context) .then(() => { if (this.graph.context === context && !this.isDestroyed) { this.pianoEngine.cue(context.currentTime); } }) .catch(() => undefined); } } 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.isDestroyed && didChangeVibe ) { this.playVibeChangeStinger(vibe); } } public setMuted(isMuted: boolean): void { this.isMuted = isMuted; this.graph.setMasterGain( isMuted ? this.config.muteGain : this.config.masterVolume, isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds ); } public beginGesture(): void { const context = this.graph.context; if (!context) { return; } this.isGestureActive = true; this.gestureState.beginGesture(); this.energy.beginGesture(context.currentTime); this.pianoEngine.beginGesture(); } public endGesture(): void { this.gestureState.endGesture(); this.isGestureActive = false; this.energy.endGesture(); this.pianoEngine.endGesture(); } public touchDown(touch: GardenAudioTouchDown): void { if (this.isDestroyed || this.isMuted) { return; } const context = this.graph.context; if (!context || !this.isGestureActive) { return; } this.selectedColorIndex = normalizeColorIndex(touch.colorIndex); this.gestureState.recordTouchDown(); } public update(snapshot: GardenAudioSnapshot): void { const context = this.graph.context; if (!this.hasStarted || !context || this.isMuted) { return; } this.applyVibe(snapshot.vibe); this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex); this.energy.update(context.currentTime); if (snapshot.isErasing) { this.energy.silence(); } this.pianoEngine.renderLookahead({ vibe: snapshot.vibe, now: context.currentTime, activity: snapshot.isErasing ? 0 : this.energy.getLevel(), selectedColorIndex: this.selectedColorIndex, }); this.updateDelay(snapshot); } public stroke(stroke: GardenAudioStroke): void { if (this.isDestroyed || this.isMuted) { return; } this.start(stroke.vibe); const context = this.graph.context; if (!context) { return; } if (!this.isGestureActive) { return; } const metrics = getStrokeMetrics(stroke, this.config.input); const now = context.currentTime; this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex); 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, selectedColorIndex: this.selectedColorIndex, maniaAmount: frame.maniaAmount, }); } public async destroy(): Promise { this.isDestroyed = true; await this.graph.close(); this.piano.reset(); this.energy.reset(); this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; this.hasStarted = false; this.isGestureActive = false; this.selectedColorIndex = 0; this.hasQueuedPianoLoad = false; 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 < this.config.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: 0, }); } } private updateDelay(snapshot: GardenAudioSnapshot): void { const context = this.graph.context; if (!context) { return; } const profile = getVibeProfile(this.config, 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(this.config, vibe)); this.pianoEngine.cue(this.graph.context.currentTime); } }