This commit is contained in:
Andras Schmelczer 2026-05-16 15:05:35 +01:00
commit 1fe5015056
55 changed files with 2036 additions and 685 deletions

View file

@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
const UNLOCK_TICK_SECONDS = 0.035;
const UNLOCK_TICK_FREQUENCY_HZ = 440;
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
@ -12,7 +15,6 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(
private readonly config: GardenAudioConfig,
@ -28,23 +30,26 @@ export class GardenAudioGraph {
return null;
}
const context = new AudioContext({ latencyHint: 'interactive' });
const AudioContextConstructor = globalThis.AudioContext;
if (!AudioContextConstructor) {
return null;
}
let context: AudioContext;
try {
context = new AudioContextConstructor({ latencyHint: 'interactive' });
} catch {
context = new AudioContextConstructor();
}
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);
highPass.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
@ -55,24 +60,37 @@ export class GardenAudioGraph {
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.
// 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 || this.hasUnlocked) {
if (!this.context) {
return;
}
const buffer = this.context.createBuffer(
1,
this.engineConfig.graph.unlockBufferLength,
this.engineConfig.graph.unlockSampleRate
const now = this.context.currentTime;
const source = this.context.createOscillator();
const gain = this.context.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now);
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now);
gain.gain.exponentialRampToValueAtTime(
this.engineConfig.piano.minGain,
now + UNLOCK_TICK_SECONDS
);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + UNLOCK_TICK_SECONDS);
source.addEventListener(
'ended',
() => {
source.disconnect();
gain.disconnect();
},
{ once: true }
);
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 {
@ -201,6 +219,5 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
}
}