313 lines
8.7 KiB
TypeScript
313 lines
8.7 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 { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
|
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
|
|
|
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
|
|
|
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
|
|
|
interface ActivePianoVoice {
|
|
gain: GainNode;
|
|
source: AudioScheduledSourceNode;
|
|
stopAt: number;
|
|
}
|
|
|
|
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 samples: Array<LoadedPianoSample> = [];
|
|
private activeVoices: Array<ActivePianoVoice> = [];
|
|
|
|
public constructor(
|
|
private readonly config: GardenAudioConfig,
|
|
private readonly graph: GardenAudioGraph
|
|
) {}
|
|
|
|
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
|
if (this.loadState !== 'idle') {
|
|
return null;
|
|
}
|
|
|
|
const loadedSamples = getLoadedPianoSamples();
|
|
if (loadedSamples) {
|
|
this.setSamples(loadedSamples);
|
|
this.loadState = 'loaded';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
this.loadState = 'loading';
|
|
return loadPianoSamples(context)
|
|
.then((samples) => {
|
|
this.setSamples(samples);
|
|
this.loadState = 'loaded';
|
|
})
|
|
.catch((error) => {
|
|
this.loadState = 'idle';
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
public play({
|
|
midi,
|
|
velocity,
|
|
startTime,
|
|
durationSeconds,
|
|
pan,
|
|
role,
|
|
delaySend = 0,
|
|
lowpassHz = this.config.piano.lowpassHz,
|
|
}: PianoNote): void {
|
|
const { context } = 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 sample = this.findNearestSample(midi);
|
|
|
|
if (sample) {
|
|
const noteGainValue = Math.max(
|
|
pianoSamplerTuning.minGain,
|
|
this.config.piano.gain * noteVelocity
|
|
);
|
|
const sustainSeconds =
|
|
this.config.piano.sustainSeconds *
|
|
(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;
|
|
const source = context.createBufferSource();
|
|
|
|
source.buffer = sample.buffer;
|
|
source.playbackRate.setValueAtTime(
|
|
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
|
scheduledStart
|
|
);
|
|
|
|
this.scheduleVoice({
|
|
source,
|
|
scheduledStart,
|
|
stopAt,
|
|
pan,
|
|
lowpassHz,
|
|
delaySend,
|
|
eventBus,
|
|
configureGainEnvelope: (gain) => {
|
|
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
|
|
);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
source.type = pianoSamplerTuning.synthOscillatorType;
|
|
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
|
|
|
this.scheduleVoice({
|
|
source,
|
|
scheduledStart,
|
|
stopAt,
|
|
pan,
|
|
lowpassHz,
|
|
delaySend,
|
|
eventBus,
|
|
configureGainEnvelope: (gain) => {
|
|
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
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
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.loadState = 'idle';
|
|
this.samples = [];
|
|
this.activeVoices = [];
|
|
}
|
|
|
|
private scheduleVoice({
|
|
source,
|
|
scheduledStart,
|
|
stopAt,
|
|
pan,
|
|
lowpassHz,
|
|
delaySend,
|
|
eventBus,
|
|
configureGainEnvelope,
|
|
}: {
|
|
source: AudioScheduledSourceNode;
|
|
scheduledStart: number;
|
|
stopAt: number;
|
|
pan: number;
|
|
lowpassHz: number;
|
|
delaySend: number;
|
|
eventBus: GainNode;
|
|
configureGainEnvelope: (gain: GainNode) => void;
|
|
}): void {
|
|
const { context, delayInput } = this.graph;
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
const filter = context.createBiquadFilter();
|
|
const gain = context.createGain();
|
|
const panner = context.createStereoPanner();
|
|
let sendGain: GainNode | null = null;
|
|
|
|
this.trimActiveVoices(scheduledStart);
|
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
|
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, 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);
|
|
configureGainEnvelope(gain);
|
|
|
|
source.connect(filter);
|
|
filter.connect(gain);
|
|
gain.connect(panner);
|
|
panner.connect(eventBus);
|
|
|
|
if (delayInput && delaySend > 0) {
|
|
sendGain = context.createGain();
|
|
sendGain.gain.value = delaySend;
|
|
panner.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();
|
|
panner.disconnect();
|
|
sendGain?.disconnect();
|
|
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
|
|
private findNearestSample(midi: number): LoadedPianoSample | null {
|
|
if (this.samples.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return this.samples.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 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.stopAt = stopAt;
|
|
voice.source.stop(stopAt);
|
|
}
|
|
|
|
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);
|