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,11 +1,32 @@
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
import { clamp, clamp01 } from '../utils/math';
import { createAudioPanNode } from './audio-pan-node';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
import type {
ActivePianoVoice,
LoadedPianoSample,
PianoNote,
} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
type PianoLoadState = 'idle' | 'loading' | 'loaded';
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
synthGainScale: 0.34,
synthMaxDurationSeconds: 1.8,
synthOscillatorType: 'triangle' as OscillatorType,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
};
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private sampleLoadPromise: Promise<void> | null = null;
@ -34,7 +55,9 @@ export class PianoSampler {
}
this.loadState = 'loading';
this.sampleLoadPromise = loadPianoSamples(context)
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
forceReload: true,
})
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
@ -74,16 +97,26 @@ export class PianoSampler {
const sample = this.findNearestSample(midi);
if (!sample) {
this.playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend,
lowpassHz,
});
return;
}
const scheduledStart = Math.max(
context.currentTime + this.config.piano.scheduleAheadSeconds,
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.config.piano.minGain,
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
@ -91,14 +124,14 @@ export class PianoSampler {
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
const panNode = createAudioPanNode(context, pan, scheduledStart);
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
@ -111,45 +144,46 @@ export class PianoSampler {
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
filter.type = this.config.piano.filterType;
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = this.config.piano.filterQ;
gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart);
filter.Q.value = pianoSamplerTuning.filterQ;
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel),
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
this.config.piano.minFadeSeconds,
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
panNode.output.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
@ -158,7 +192,7 @@ export class PianoSampler {
source.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
panNode.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
@ -181,6 +215,22 @@ export class PianoSampler {
this.activeVoices = [];
}
public fadeAll(fadeSeconds: number): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
this.trimActiveVoices(now);
this.activeVoices.forEach((voice) => {
this.fadeVoice(voice, now, fadeDurationSeconds);
});
}
public reset(): void {
this.loadState = 'idle';
this.sampleLoadPromise = null;
@ -198,18 +248,114 @@ export class PianoSampler {
);
}
private playSynthFallback({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, delayInput } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + this.config.piano.sustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createOscillator();
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panNode = createAudioPanNode(context, pan, scheduledStart);
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
}
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
source.connect(filter);
filter.connect(gain);
gain.connect(panNode.input);
panNode.output.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panNode.output.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panNode.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
{ once: true }
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + this.config.piano.voiceStealStopSeconds;
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
this.config.piano.minGain,
pianoSamplerTuning.minGain,
now,
this.config.piano.voiceStealFadeSeconds
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {
@ -219,7 +365,31 @@ export class PianoSampler {
}
}
private fadeVoice(
voice: ActivePianoVoice,
now: number,
fadeDurationSeconds: number
): void {
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
Math.max(0.001, fadeDurationSeconds / 4)
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
} catch {
// The voice may already have ended; either way it is fading out of the mix.
}
}
private setSamples(samples: Array<LoadedPianoSample>): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}
const getMidiFrequency = (midi: number): number =>
440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE);