328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { clamp } from '../utils/math';
|
|
import type { GardenAudioConfig } 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,
|
|
delayMaxSeconds: 2,
|
|
eventBusGain: 1,
|
|
noiseMax: 1,
|
|
noiseMin: -1,
|
|
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
|
outputFilterType: 'highpass' as BiquadFilterType,
|
|
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;
|
|
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 lastPianoBusActivity = 0;
|
|
private pianoBusGainScale = 1;
|
|
private pianoBusGainScaleAutomationUntil = 0;
|
|
private pianoBusGainScaleTimeConstantSeconds = 0;
|
|
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
|
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
|
|
masterGain.gain.value = 0;
|
|
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);
|
|
|
|
this.context = context;
|
|
this.masterGain = masterGain;
|
|
this.noiseBuffer = this.createNoiseBuffer(context);
|
|
this.createDelay(context, masterGain);
|
|
this.createBuses(context, masterGain);
|
|
|
|
return context;
|
|
}
|
|
|
|
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
|
if (!this.context || !this.masterGain) {
|
|
return;
|
|
}
|
|
|
|
this.masterGain.gain.setTargetAtTime(
|
|
targetGain,
|
|
this.context.currentTime,
|
|
timeConstantSeconds
|
|
);
|
|
}
|
|
|
|
public applyDelayProfile(bpm: number): void {
|
|
if (!this.context || !this.delayNode) {
|
|
return;
|
|
}
|
|
|
|
this.delayNode.delayTime.setTargetAtTime(
|
|
this.getDelayTimeSecondsForBpm(bpm),
|
|
this.context.currentTime,
|
|
this.config.delay.timeRampSeconds
|
|
);
|
|
}
|
|
|
|
public updateDelay(activity: number, bpm: 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.getDelayTimeSecondsForBpm(bpm),
|
|
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 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) {
|
|
return;
|
|
}
|
|
|
|
if (this.masterGain && context.state !== 'closed') {
|
|
this.masterGain.gain.setTargetAtTime(
|
|
graphTuning.closeGain,
|
|
context.currentTime,
|
|
graphTuning.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(graphTuning.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.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
|
delayFeedback.gain.value = this.config.delay.feedback;
|
|
delayOutput.gain.value = this.config.delay.wetGain;
|
|
feedbackHighPass.type = 'highpass';
|
|
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
|
|
feedbackLowPass.type = 'lowpass';
|
|
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
|
|
returnLowPass.type = 'lowpass';
|
|
returnLowPass.frequency.value = delayFilterTuning.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 = graphTuning.eventBusGain;
|
|
eventBus.connect(masterGain);
|
|
this.eventBus = eventBus;
|
|
this.pianoBuses.clear();
|
|
|
|
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).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,
|
|
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) * this.pianoBusGainScale),
|
|
now,
|
|
effectiveTimeConstantSeconds
|
|
);
|
|
});
|
|
}
|
|
|
|
private getDelayTimeSecondsForBpm(bpm: number): number {
|
|
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
|
return clamp(
|
|
(60 / safeBpm) * this.config.delay.timeBeats,
|
|
this.config.delay.timeMinSeconds,
|
|
this.config.delay.timeMaxSeconds
|
|
);
|
|
}
|
|
|
|
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
|
const buffer = context.createBuffer(
|
|
1,
|
|
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
|
context.sampleRate
|
|
);
|
|
const data = buffer.getChannelData(0);
|
|
|
|
for (let index = 0; index < data.length; index++) {
|
|
data[index] =
|
|
graphTuning.noiseMin +
|
|
Math.random() * (graphTuning.noiseMax - graphTuning.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.lastPianoBusActivity = 0;
|
|
this.pianoBusGainScale = 1;
|
|
this.pianoBusGainScaleAutomationUntil = 0;
|
|
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
|
this.pianoBuses.clear();
|
|
}
|
|
}
|