Add WIP sound generation

This commit is contained in:
Andras Schmelczer 2026-05-10 15:26:44 +01:00
commit 34ac200437
10 changed files with 1542 additions and 0 deletions

162
src/audio/piano-sampler.ts Normal file
View file

@ -0,0 +1,162 @@
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 { pianoSampleDefinitions } from './piano-samples';
export class PianoSampler {
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly graph: GardenAudioGraph
) {}
public async load(context: AudioContext): Promise<void> {
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
}
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
})
)
.then((samples) => {
this.samples = samples.sort((a, b) => a.midi - b.midi);
})
.catch(() => {
this.samples = [];
});
return this.sampleLoadPromise;
}
public play({
midi,
velocity,
startTime,
durationSeconds,
pan,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
if (!context || !eventBus || this.samples.length === 0) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
return;
}
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
const sustainSeconds =
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
const sustainAt = scheduledStart + Math.max(0.08, 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();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
oldest?.source.stop(scheduledStart + 0.05);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / 12),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
filter.Q.value = 0.7;
gain.gain.setValueAtTime(0.0001, scheduledStart);
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
gain.gain.setTargetAtTime(
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(0.04, sustainSeconds * 0.45)
);
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && this.config.delay.enabled && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + 0.05);
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 }
);
}
public fadeActive(now: number): void {
this.activeVoices.forEach((voice) => {
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
try {
voice.source.stop(now + 0.28);
} catch {
// The source may already have a stop time scheduled.
}
});
}
public reset(): void {
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 trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
}