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 | null = null; private samples: Array = []; private activeVoices: Array = []; public constructor( private readonly config: GardenAudioConfig, private readonly graph: GardenAudioGraph ) {} public load(context: BaseAudioContext): Promise { if (this.loadState === 'loaded') { return Promise.resolve(); } const loadedSamples = getLoadedPianoSamples(); if (loadedSamples) { this.setSamples(loadedSamples); this.loadState = 'loaded'; return Promise.resolve(); } if (this.sampleLoadPromise) { return this.sampleLoadPromise; } this.loadState = 'loading'; this.sampleLoadPromise = loadPianoSamples(context, undefined, { forceReload: true, }) .then((samples) => { this.setSamples(samples); this.loadState = 'loaded'; }) .catch((error) => { this.loadState = 'idle'; this.sampleLoadPromise = null; throw error; }); return this.sampleLoadPromise; } public loadIfIdle(context: BaseAudioContext): Promise | null { if (this.loadState !== 'idle') { return null; } return this.load(context); } public play({ 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 sample = this.findNearestSample(midi); if (!sample) { this.playSynthFallback({ midi, velocity, startTime, durationSeconds, pan, role, delaySend, lowpassHz, }); 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 ); 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 releaseSeconds = this.config.piano.releaseSeconds; const stopAt = releaseAt + releaseSeconds; const source = context.createBufferSource(); 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.buffer = sample.buffer; source.playbackRate.setValueAtTime( Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), 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( 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, 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 } ); } 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 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; this.samples = []; this.activeVoices = []; } 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 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 + pianoSamplerTuning.voiceStealStopSeconds; voice.gain.gain.cancelScheduledValues(now); voice.gain.gain.setTargetAtTime( pianoSamplerTuning.minGain, now, pianoSamplerTuning.voiceStealFadeSeconds ); voice.stopAt = stopAt; try { voice.source.stop(stopAt); } catch { // The voice may already have ended; either way it is no longer part of the mix. } } 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): 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);