import { clamp } from '../utils/clamp'; import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; import type { PianoNoteRole } from './garden-audio-types'; 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 readonly pianoBuses = new Map(); 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; } let context: AudioContext; try { context = new AudioContextConstructor({ latencyHint: this.config.graph.latencyHint, }); } catch { context = new AudioContextConstructor(); } const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); const compressor = context.createDynamicsCompressor(); masterGain.gain.value = 0; highPass.type = this.config.graph.outputFilterType; highPass.frequency.value = this.config.highPassFrequencyHz; compressor.threshold.value = this.config.graph.compressor.thresholdDb; compressor.knee.value = this.config.graph.compressor.kneeDb; compressor.ratio.value = this.config.graph.compressor.ratio; compressor.attack.value = this.config.graph.compressor.attackSeconds; compressor.release.value = this.config.graph.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; } // iOS WebKit can report "running" while the hardware output is still silent. // A very short, nearly inaudible oscillator in the gesture stack is more // reliable than a fully silent buffer on recent Safari versions. public unlock(): void { if (!this.context) { return; } const now = this.context.currentTime; const source = this.context.createOscillator(); const gain = this.context.createGain(); source.type = this.config.graph.unlockTickType; source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now); gain.gain.setValueAtTime(this.config.piano.minGain, now); gain.gain.exponentialRampToValueAtTime( this.config.piano.minGain, now + this.config.graph.unlockTickSeconds ); source.connect(gain); gain.connect(this.context.destination); source.start(now); source.stop(now + this.config.graph.unlockTickSeconds); source.addEventListener( 'ended', () => { source.disconnect(); gain.disconnect(); }, { once: true } ); } public setMasterGain(targetGain: number, timeConstantSeconds: number): void { if (!this.context || !this.masterGain) { return; } this.masterGain.gain.setTargetAtTime( targetGain, this.context.currentTime, timeConstantSeconds ); } public applyDelayProfile(profile: GardenAudioVibeProfile): void { if (!this.context || !this.delayNode) { return; } this.delayNode.delayTime.setTargetAtTime( this.config.delay.timeSeconds * profile.delayTimeMultiplier, this.context.currentTime, this.config.delay.timeRampSeconds ); } public updateDelay(profile: GardenAudioVibeProfile, activity: 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.config.delay.timeSeconds * profile.delayTimeMultiplier, 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 async close(): Promise { const context = this.context; if (!context) { return; } if (this.masterGain && context.state !== 'closed') { this.masterGain.gain.setTargetAtTime( this.config.graph.closeGain, context.currentTime, this.config.graph.closeRampSeconds ); } this.clearNodes(); if (context.state !== 'closed') { await context.close().catch(() => undefined); } } private createDelay(context: AudioContext, masterGain: GainNode): void { const delayInput = context.createGain(); const delayNode = context.createDelay(this.config.graph.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.config.delay.timeSeconds; delayFeedback.gain.value = this.config.delay.feedback; delayOutput.gain.value = this.config.delay.wetGain; feedbackHighPass.type = 'highpass'; feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz; feedbackLowPass.type = 'lowpass'; feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz; returnLowPass.type = 'lowpass'; returnLowPass.frequency.value = this.config.delay.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 = this.config.graph.eventBusGain; eventBus.connect(masterGain); this.eventBus = eventBus; this.pianoBuses.clear(); (Object.keys(this.config.graph.pianoBusGains) as Array).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): void { 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)), now, this.config.updateRampSeconds ); }); } private createNoiseBuffer(context: AudioContext): AudioBuffer { const buffer = context.createBuffer( appPositiveInteger(this.config.graph.noiseBufferChannels), Math.max( 1, Math.floor( context.sampleRate * Math.max(0.001, this.config.graph.noiseBufferDurationSeconds) ) ), context.sampleRate ); const data = buffer.getChannelData(0); for (let index = 0; index < data.length; index++) { data[index] = this.config.graph.noiseMin + Math.random() * (this.config.graph.noiseMax - this.config.graph.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.pianoBuses.clear(); } } const appPositiveInteger = (value: number): number => Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));