fleeting-garden/src/audio/piano-sampler.ts
2026-05-27 19:26:45 +01:00

635 lines
17 KiB
TypeScript

import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type {
LoadedPianoReleaseSample,
LoadedPianoSamples,
LoadedPianoStrikeSample,
PianoNote,
} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
peakGain: number;
releaseAt: number;
sources: Array<AudioBufferSourceNode>;
startedAt: number;
stopAt: number;
}
interface SelectedPianoStrikeSample {
gainScale: number;
sample: LoadedPianoStrikeSample;
}
interface ScheduledReleaseSample {
gainValue: number;
source: AudioBufferSourceNode;
startTime: number;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass',
filterQ: 0.45,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
releaseSampleAttackSeconds: 0.006,
releaseSampleDecaySeconds: 0.18,
releaseTimeConstantCount: 6,
tailStopExtraSeconds: 0.08,
voiceStealFadeSeconds: 0.045,
voiceStealStopSeconds: 0.09,
} as const;
export class PianoSampler {
private activeVoices: Array<ActivePianoVoice> = [];
private releaseSamples: Array<LoadedPianoReleaseSample> = [];
private strikeSamples: Array<LoadedPianoStrikeSample> = [];
private velocityLayers: Array<number> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly graph: GardenAudioGraph
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.strikeSamples.length > 0) {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
return Promise.resolve();
}
return loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
}
public play({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const noteVelocity = clamp01(velocity);
const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
if (selectedSamples.length === 0) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({
gainScale,
source: this.createSource(
context,
sample.buffer,
midi,
sample.midi,
scheduledStart
),
}));
const releaseSample = this.createReleaseSample({
context,
midi,
noteVelocity,
releaseAt,
});
this.scheduleVoice({
delaySend,
eventBus,
lowpassHz,
noteGainValue,
pan,
releaseAt,
releaseSample,
scheduledStart,
stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
strikeSources,
sustainAt,
sustainSeconds,
});
}
public stopAll(): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
this.activeVoices.forEach((voice) => {
this.stopVoice(voice, now);
});
this.activeVoices = [];
}
public reset(): void {
this.releaseSamples = [];
this.strikeSamples = [];
this.velocityLayers = [];
this.activeVoices = [];
}
private scheduleVoice({
strikeSources,
releaseSample,
scheduledStart,
sustainAt,
sustainSeconds,
releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
noteGainValue,
}: {
delaySend: number;
eventBus: GainNode;
lowpassHz: number;
noteGainValue: number;
pan: number;
releaseAt: number;
releaseSample: ScheduledReleaseSample | null;
scheduledStart: number;
stopAt: number;
strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>;
sustainAt: number;
sustainSeconds: number;
}): void {
const { context, delayInput, roomInput } = this.graph;
if (!context) {
return;
}
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
const sourceGains = strikeSources.map(({ gainScale }) => {
const sourceGain = context.createGain();
sourceGain.gain.value = gainScale;
return sourceGain;
});
let delaySendGain: GainNode | null = null;
let roomSendGain: GainNode | null = null;
let releaseGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
this.stealQuietestVoice(scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
this.configureGainEnvelope({
gain,
noteGainValue,
releaseAt,
scheduledStart,
sustainAt,
sustainSeconds,
});
strikeSources.forEach(({ source }, index) => {
source.connect(sourceGains[index]);
sourceGains[index].connect(filter);
});
filter.connect(gain);
gain.connect(panner);
if (releaseSample) {
releaseGain = context.createGain();
releaseSample.source.connect(releaseGain);
releaseGain.connect(panner);
this.configureReleaseEnvelope(releaseGain, releaseSample);
}
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
delaySendGain = context.createGain();
delaySendGain.gain.value = delaySend;
panner.connect(delaySendGain);
delaySendGain.connect(delayInput);
}
if (roomInput && this.config.piano.roomSend > 0) {
roomSendGain = context.createGain();
roomSendGain.gain.value = this.config.piano.roomSend;
panner.connect(roomSendGain);
roomSendGain.connect(roomInput);
}
const sources = [
...strikeSources.map(({ source }) => source),
...(releaseSample ? [releaseSample.source] : []),
];
strikeSources.forEach(({ source }) => {
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
});
if (releaseSample) {
releaseSample.source.start(releaseSample.startTime);
releaseSample.source.stop(releaseSample.stopAt);
}
const voice: ActivePianoVoice = {
gain,
peakGain: noteGainValue,
releaseAt,
sources,
startedAt: scheduledStart,
stopAt,
};
this.activeVoices.push(voice);
this.cleanupVoiceWhenSourcesEnd({
delaySendGain,
filter,
gain,
panner,
releaseGain,
roomSendGain,
sourceGains,
sources,
voice,
});
}
private configureGainEnvelope({
gain,
noteGainValue,
releaseAt,
scheduledStart,
sustainAt,
sustainSeconds,
}: {
gain: GainNode;
noteGainValue: number;
releaseAt: number;
scheduledStart: number;
sustainAt: number;
sustainSeconds: number;
}): void {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
}
private configureReleaseEnvelope(
releaseGain: GainNode,
releaseSample: ScheduledReleaseSample
): void {
releaseGain.gain.setValueAtTime(pianoSamplerTuning.minGain, releaseSample.startTime);
releaseGain.gain.exponentialRampToValueAtTime(
releaseSample.gainValue,
releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds
);
releaseGain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds,
pianoSamplerTuning.releaseSampleDecaySeconds
);
}
private createSource(
context: BaseAudioContext,
buffer: AudioBuffer,
midi: number,
sampleMidi: number,
scheduledStart: number
): AudioBufferSourceNode {
const source = context.createBufferSource();
source.buffer = buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
return source;
}
private createReleaseSample({
context,
midi,
noteVelocity,
releaseAt,
}: {
context: BaseAudioContext;
midi: number;
noteVelocity: number;
releaseAt: number;
}): ScheduledReleaseSample | null {
const sample = this.findNearestReleaseSample(midi);
if (!sample) {
return null;
}
const source = this.createSource(
context,
sample.buffer,
midi,
sample.midi,
releaseAt
);
const gainValue =
this.config.piano.releaseSampleGain *
(this.config.piano.releaseSampleVelocityBase +
noteVelocity * this.config.piano.releaseSampleVelocityRange);
return {
gainValue: Math.max(pianoSamplerTuning.minGain, gainValue),
source,
startTime: releaseAt,
stopAt:
releaseAt + sample.buffer.duration + pianoSamplerTuning.tailStopExtraSeconds,
};
}
private cleanupVoiceWhenSourcesEnd({
sources,
sourceGains,
filter,
gain,
releaseGain,
panner,
delaySendGain,
roomSendGain,
voice,
}: {
delaySendGain: GainNode | null;
filter: BiquadFilterNode;
gain: GainNode;
panner: StereoPannerNode;
releaseGain: GainNode | null;
roomSendGain: GainNode | null;
sourceGains: Array<GainNode>;
sources: Array<AudioBufferSourceNode>;
voice: ActivePianoVoice;
}): void {
let remainingSources = sources.length;
const cleanup = (): void => {
remainingSources -= 1;
if (remainingSources > 0) {
return;
}
sources.forEach((source) => {
source.disconnect();
});
sourceGains.forEach((sourceGain) => {
sourceGain.disconnect();
});
filter.disconnect();
gain.disconnect();
releaseGain?.disconnect();
panner.disconnect();
delaySendGain?.disconnect();
roomSendGain?.disconnect();
this.activeVoices = this.activeVoices.filter(
(activeVoice) => activeVoice !== voice
);
};
sources.forEach((source) => {
source.addEventListener('ended', cleanup, { once: true });
});
}
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
private selectStrikeSamples(
midi: number,
noteVelocity: number
): Array<SelectedPianoStrikeSample> {
if (this.strikeSamples.length === 0 || this.velocityLayers.length === 0) {
return [];
}
const targetLayer = this.getTargetVelocityLayer(noteVelocity);
const layerPair = this.getVelocityLayerPair(targetLayer);
if (!layerPair) {
return [];
}
const lowerSample = this.findNearestStrikeSample(midi, layerPair.lower);
const upperSample =
layerPair.upper === layerPair.lower
? null
: this.findNearestStrikeSample(midi, layerPair.upper);
if (!lowerSample && !upperSample) {
return [];
}
if (!upperSample || layerPair.blend <= 0) {
return lowerSample ? [{ gainScale: 1, sample: lowerSample }] : [];
}
if (!lowerSample || layerPair.blend >= 1) {
return [{ gainScale: 1, sample: upperSample }];
}
return [
{
gainScale: Math.sqrt(1 - layerPair.blend),
sample: lowerSample,
},
{
gainScale: Math.sqrt(layerPair.blend),
sample: upperSample,
},
];
}
private getTargetVelocityLayer(noteVelocity: number): number {
const firstLayer = this.velocityLayers[0];
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
const velocityRange = Math.max(
0.001,
this.config.piano.velocityLayerMax - this.config.piano.velocityLayerMin
);
const normalizedVelocity = clamp01(
(noteVelocity - this.config.piano.velocityLayerMin) / velocityRange
);
const curvedVelocity = Math.pow(
normalizedVelocity,
this.config.piano.velocityLayerCurve
);
return firstLayer + (lastLayer - firstLayer) * curvedVelocity;
}
private getVelocityLayerPair(
targetLayer: number
): { blend: number; lower: number; upper: number } | null {
const firstLayer = this.velocityLayers[0];
if (firstLayer === undefined) {
return null;
}
if (targetLayer <= firstLayer) {
return { blend: 0, lower: firstLayer, upper: firstLayer };
}
for (let index = 1; index < this.velocityLayers.length; index += 1) {
const upper = this.velocityLayers[index];
const lower = this.velocityLayers[index - 1];
if (targetLayer <= upper) {
return {
blend: (targetLayer - lower) / (upper - lower),
lower,
upper,
};
}
}
const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
return { blend: 0, lower: lastLayer, upper: lastLayer };
}
private findNearestStrikeSample(
midi: number,
velocityLayer: number
): LoadedPianoStrikeSample | null {
const layerSamples = this.strikeSamples.filter(
(sample) => sample.velocityLayer === velocityLayer
);
if (layerSamples.length === 0) {
return null;
}
return layerSamples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private findNearestReleaseSample(midi: number): LoadedPianoReleaseSample | null {
if (this.releaseSamples.length === 0) {
return null;
}
return this.releaseSamples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stealQuietestVoice(now: number): void {
const quietestVoice = this.activeVoices.reduce<ActivePianoVoice | null>(
(quietest, voice) =>
quietest === null ||
this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now)
? voice
: quietest,
null
);
if (!quietestVoice) {
return;
}
this.stopVoice(quietestVoice, now);
this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice);
}
private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number {
const ageSeconds = Math.max(0, now - voice.startedAt);
const releasedScale = now >= voice.releaseAt ? 0.28 : 1;
return (
voice.peakGain *
releasedScale *
Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds)
);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.sources.forEach((source) => {
try {
source.stop(stopAt);
} catch {
// The source may already have ended naturally.
}
});
voice.stopAt = stopAt;
}
private setSamples(samples: LoadedPianoSamples): void {
this.releaseSamples = samples.releases.slice().sort((a, b) => a.midi - b.midi);
this.strikeSamples = samples.strikes
.slice()
.sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer);
this.velocityLayers = [
...new Set(this.strikeSamples.map((sample) => sample.velocityLayer)),
].sort((a, b) => a - b);
}
}