This commit is contained in:
Andras Schmelczer 2026-05-19 21:03:53 +01:00
commit 7c70f15e49
65 changed files with 1122 additions and 1906 deletions

View file

@ -1,9 +1,13 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type NavigatorWithAudioSession = Navigator & {
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@ -13,12 +17,6 @@ const graphTuning = {
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
noiseBufferChannels: 1,
noiseBufferDurationSeconds: 1,
unlockTickFrequencyHz: 440,
unlockTickGain: 0.0001,
unlockTickSeconds: 0.025,
unlockTickType: 'sine' as OscillatorType,
compressor: {
thresholdDb: -18,
kneeDb: 18,
@ -44,8 +42,10 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
private mediaStreamElement: HTMLAudioElement | null = null;
private lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
@ -64,20 +64,20 @@ export class GardenAudioGraph {
return null;
}
let context: AudioContext;
try {
context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
} catch {
context = new AudioContextConstructor();
// Tells iOS to treat this as media playback, so the hardware ringer/mute
// switch does not silence Web Audio output. No-op on browsers without the
// Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
const mediaStreamDestination = isIosLike()
? context.createMediaStreamDestination()
: null;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
@ -90,15 +90,10 @@ export class GardenAudioGraph {
masterGain.connect(highPass);
highPass.connect(compressor);
if (mediaStreamDestination) {
compressor.connect(mediaStreamDestination);
} else {
compressor.connect(context.destination);
}
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
@ -106,35 +101,6 @@ export class GardenAudioGraph {
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 = graphTuning.unlockTickType;
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + graphTuning.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
source.disconnect();
gain.disconnect();
},
{ once: true }
);
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -147,38 +113,6 @@ export class GardenAudioGraph {
);
}
public startMediaElementOutput(): void {
if (!this.mediaStreamDestination) {
return;
}
const mediaElement = this.ensureMediaStreamElement();
const playPromise = mediaElement.play();
void playPromise?.catch(() => undefined);
}
private ensureMediaStreamElement(): HTMLAudioElement {
if (this.mediaStreamElement) {
return this.mediaStreamElement;
}
const mediaElement = document.createElement('audio');
mediaElement.autoplay = true;
mediaElement.volume = 1;
mediaElement.setAttribute('playsinline', '');
mediaElement.setAttribute('aria-hidden', 'true');
mediaElement.style.position = 'fixed';
mediaElement.style.width = '1px';
mediaElement.style.height = '1px';
mediaElement.style.opacity = '0';
mediaElement.style.pointerEvents = 'none';
mediaElement.style.left = '-9999px';
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
document.body.append(mediaElement);
this.mediaStreamElement = mediaElement;
return mediaElement;
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
@ -228,6 +162,20 @@ export class GardenAudioGraph {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
if (!this.context) {
this.pianoBusGainScale = clamp(targetScale, 0, 1);
return;
}
const now = this.context.currentTime;
this.pianoBusGainScale = clamp(targetScale, 0, 1);
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
}
public async close(): Promise<void> {
const context = this.context;
if (!context) {
@ -304,27 +252,33 @@ export class GardenAudioGraph {
this.noiseBus.connect(eventBus);
}
private updatePianoBusGains(activity: number, now: number): void {
private updatePianoBusGains(
activity: number,
now: number,
timeConstantSeconds?: number
): void {
const effectiveTimeConstantSeconds =
timeConstantSeconds ??
(now < this.pianoBusGainScaleAutomationUntil
? this.pianoBusGainScaleTimeConstantSeconds
: this.config.updateRampSeconds);
this.lastPianoBusActivity = activity;
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)),
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
this.config.updateRampSeconds
effectiveTimeConstantSeconds
);
});
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1,
Math.floor(
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
1,
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
@ -348,16 +302,10 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.mediaStreamDestination = null;
if (this.mediaStreamElement) {
this.mediaStreamElement.pause();
this.mediaStreamElement.srcObject = null;
this.mediaStreamElement.remove();
this.mediaStreamElement = null;
}
this.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}
const appPositiveInteger = (value: number): number =>
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));