This commit is contained in:
Andras Schmelczer 2026-05-18 08:11:52 +01:00
commit 15e99380b5
84 changed files with 1659 additions and 4726 deletions

View file

@ -1,7 +1,38 @@
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
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';
const outputHighPassFrequencyHz = 45;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayMaxSeconds: 2,
eventBusGain: 1,
noiseMax: 1,
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,
ratio: 2.1,
attackSeconds: 0.018,
releaseSeconds: 0.18,
},
};
const delayFilterTuning = {
feedbackHighPassHz: 180,
feedbackLowPassHz: 5200,
returnLowPassHz: 6200,
};
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
@ -13,6 +44,8 @@ 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 readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
@ -34,7 +67,7 @@ export class GardenAudioGraph {
let context: AudioContext;
try {
context = new AudioContextConstructor({
latencyHint: this.config.graph.latencyHint,
latencyHint: graphTuning.latencyHint,
});
} catch {
context = new AudioContextConstructor();
@ -42,22 +75,30 @@ export class GardenAudioGraph {
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
const mediaStreamDestination = isIosLike()
? context.createMediaStreamDestination()
: null;
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;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
compressor.threshold.value = graphTuning.compressor.thresholdDb;
compressor.knee.value = graphTuning.compressor.kneeDb;
compressor.ratio.value = graphTuning.compressor.ratio;
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
if (mediaStreamDestination) {
compressor.connect(mediaStreamDestination);
} else {
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);
@ -77,17 +118,13 @@ export class GardenAudioGraph {
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.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 + this.config.graph.unlockTickSeconds);
source.stop(now + graphTuning.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
@ -110,6 +147,38 @@ 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;
@ -167,9 +236,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
this.config.graph.closeGain,
graphTuning.closeGain,
context.currentTime,
this.config.graph.closeRampSeconds
graphTuning.closeRampSeconds
);
}
@ -182,7 +251,7 @@ export class GardenAudioGraph {
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
const feedbackHighPass = context.createBiquadFilter();
@ -193,11 +262,11 @@ export class GardenAudioGraph {
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass';
feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz;
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
feedbackLowPass.type = 'lowpass';
feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz;
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
returnLowPass.type = 'lowpass';
returnLowPass.frequency.value = this.config.delay.returnLowPassHz;
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
delayInput.connect(delayNode);
delayNode.connect(feedbackHighPass);
@ -216,7 +285,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = this.config.graph.eventBusGain;
eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(masterGain);
this.eventBus = eventBus;
this.pianoBuses.clear();
@ -249,12 +318,11 @@ export class GardenAudioGraph {
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
appPositiveInteger(this.config.graph.noiseBufferChannels),
appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1,
Math.floor(
context.sampleRate *
Math.max(0.001, this.config.graph.noiseBufferDurationSeconds)
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
context.sampleRate
@ -263,8 +331,8 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
this.config.graph.noiseMin +
Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin);
graphTuning.noiseMin +
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
}
return buffer;
@ -280,6 +348,13 @@ 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.pianoBuses.clear();
}
}