import type { GardenAudioEngineConfig } from '../config'; import { clamp } from '../utils/clamp'; import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; public delayInput: 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 hasUnlocked = false; public constructor( private readonly config: GardenAudioConfig, private readonly engineConfig: GardenAudioEngineConfig ) {} public ensureContext(canCreate: boolean): AudioContext | null { if (this.context) { return this.context; } if (!canCreate) { return null; } const context = new AudioContext({ latencyHint: 'interactive' }); const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); const compressor = context.createDynamicsCompressor(); masterGain.gain.value = 0; highPass.type = 'highpass'; highPass.frequency.value = this.config.highPassFrequencyHz; compressor.threshold.value = this.config.compressor.thresholdDb; compressor.knee.value = this.config.compressor.kneeDb; compressor.ratio.value = this.config.compressor.ratio; compressor.attack.value = this.config.compressor.attackSeconds; compressor.release.value = this.config.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 (Safari + Chrome iOS) only fully unlocks audio output once // a buffer source has been started inside a user-gesture handler. Calling // resume() alone leaves the context "running" but silent. public unlock(): void { if (!this.context || this.hasUnlocked) { return; } const buffer = this.context.createBuffer( 1, this.engineConfig.graph.unlockBufferLength, this.engineConfig.graph.unlockSampleRate ); const source = this.context.createBufferSource(); source.buffer = buffer; source.connect(this.context.destination); source.start(0); this.hasUnlocked = 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.engineConfig.graph.delayTimeRampSeconds ); } public updateDelay(profile: GardenAudioVibeProfile, activity: number): void { if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { return; } const now = this.context.currentTime; this.delayNode.delayTime.setTargetAtTime( this.config.delay.timeSeconds * profile.delayTimeMultiplier, now, this.engineConfig.graph.delayTimeRampSeconds ); this.delayFeedback.gain.setTargetAtTime( clamp( this.config.delay.feedback + activity * this.engineConfig.graph.delayActivityFeedbackWeight, this.engineConfig.graph.delayFeedbackMin, this.engineConfig.graph.delayFeedbackMax ), now, this.config.updateRampSeconds ); this.delayOutput.gain.setTargetAtTime( this.config.delay.wetGain * (this.engineConfig.graph.delayOutputBase + activity * this.engineConfig.graph.delayOutputActivityWeight), now, this.config.updateRampSeconds ); } public async close(): Promise { const context = this.context; if (!context) { return; } if (this.masterGain && context.state !== 'closed') { this.masterGain.gain.setTargetAtTime( this.engineConfig.graph.closeGain, context.currentTime, this.engineConfig.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(2); const delayFeedback = context.createGain(); const delayOutput = context.createGain(); delayNode.delayTime.value = this.config.delay.timeSeconds; delayFeedback.gain.value = this.config.delay.feedback; delayOutput.gain.value = this.config.delay.wetGain; delayInput.connect(delayNode); delayNode.connect(delayFeedback); delayFeedback.connect(delayNode); delayNode.connect(delayOutput); delayOutput.connect(masterGain); this.delayInput = delayInput; this.delayNode = delayNode; this.delayFeedback = delayFeedback; this.delayOutput = delayOutput; } private createBuses(context: AudioContext, masterGain: GainNode): void { this.eventBus = context.createGain(); this.eventBus.gain.value = this.engineConfig.graph.eventBusGain; this.eventBus.connect(masterGain); } private createNoiseBuffer(context: AudioContext): AudioBuffer { const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate); const data = buffer.getChannelData(0); for (let index = 0; index < data.length; index++) { data[index] = this.engineConfig.graph.noiseMin + Math.random() * (this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin); } return buffer; } private clearNodes(): void { this.context = null; this.eventBus = null; this.delayInput = null; this.noiseBuffer = null; this.masterGain = null; this.delayNode = null; this.delayFeedback = null; this.delayOutput = null; this.hasUnlocked = false; } }