Improve piano

This commit is contained in:
Andras Schmelczer 2026-05-27 19:26:45 +01:00
parent ff924676c0
commit 8d3ccd6639
40 changed files with 712 additions and 236 deletions

View file

@ -1,6 +1,7 @@
import type { PianoNoteRole } from './garden-audio-types';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const DEFAULT_AUDIO_VOLUME = 0.65;
export const MAX_AUDIO_VOLUME = 1.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
@ -58,17 +59,33 @@ export const createGardenAudioConfig = () => ({
timeRampSeconds: 0.12,
},
piano: {
maxVoices: 24,
gain: 0.48,
maxVoices: 48,
gain: 0.78,
sustainSeconds: 0.42,
sustainLevel: 0.26,
releaseSeconds: 0.34,
lowpassHz: 7000,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
releaseSeconds: 0.62,
lowpassHz: 9500,
gainAttackSeconds: 0.003,
lowpassMaxHz: 16000,
lowpassMinHz: 900,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
releaseSampleGain: 0.035,
releaseSampleVelocityBase: 0.45,
releaseSampleVelocityRange: 0.55,
roomSend: 0.18,
velocityLayerCurve: 0.72,
velocityLayerMax: 0.26,
velocityLayerMin: 0.035,
voiceDecayEstimateSeconds: 1.9,
},
room: {
decaySeconds: 1.65,
highPassHz: 120,
lowPassHz: 8200,
preDelaySeconds: 0.018,
sendGain: 1,
wetGain: 0.11,
},
rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,

View file

@ -28,11 +28,11 @@ const graphTuning = {
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
thresholdDb: -22,
kneeDb: 12,
ratio: 4.5,
attackSeconds: 0.006,
releaseSeconds: 0.18,
thresholdDb: -17,
kneeDb: 18,
ratio: 2.2,
attackSeconds: 0.014,
releaseSeconds: 0.28,
},
} as const;
const delayFilterTuning = {
@ -45,6 +45,7 @@ export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
public roomInput: GainNode | null = null;
public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
@ -112,6 +113,7 @@ export class GardenAudioGraph {
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, outputBus);
this.createRoom(context, outputBus);
this.createBuses(context, outputBus);
return context;
@ -262,6 +264,33 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput;
}
private createRoom(context: AudioContext, outputBus: GainNode): void {
const roomInput = context.createGain();
const preDelay = context.createDelay(0.08);
const convolver = context.createConvolver();
const highPass = context.createBiquadFilter();
const lowPass = context.createBiquadFilter();
const roomOutput = context.createGain();
roomInput.gain.value = this.config.room.sendGain;
preDelay.delayTime.value = this.config.room.preDelaySeconds;
convolver.buffer = this.createRoomImpulse(context);
highPass.type = 'highpass';
highPass.frequency.value = this.config.room.highPassHz;
lowPass.type = 'lowpass';
lowPass.frequency.value = this.config.room.lowPassHz;
roomOutput.gain.value = this.config.room.wetGain;
roomInput.connect(preDelay);
preDelay.connect(convolver);
convolver.connect(highPass);
highPass.connect(lowPass);
lowPass.connect(roomOutput);
roomOutput.connect(outputBus);
this.roomInput = roomInput;
}
private createBuses(context: AudioContext, outputBus: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
@ -332,10 +361,34 @@ export class GardenAudioGraph {
return buffer;
}
private createRoomImpulse(context: AudioContext): AudioBuffer {
const sampleCount = Math.max(
1,
Math.floor(context.sampleRate * this.config.room.decaySeconds)
);
const impulse = context.createBuffer(2, sampleCount, context.sampleRate);
for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
const data = impulse.getChannelData(channel);
for (let index = 0; index < sampleCount; index += 1) {
const position = index / sampleCount;
const decay = Math.pow(1 - position, 2.35);
const earlyReflection =
index % Math.max(1, Math.floor(context.sampleRate * 0.011)) === 0
? 0.18 * (1 - position)
: 0;
data[index] = (Math.random() * 2 - 1 + earlyReflection) * decay;
}
}
return impulse;
}
private clearNodes(): void {
this.context = null;
this.eventBus = null;
this.delayInput = null;
this.roomInput = null;
this.noiseBus = null;
this.noiseBuffer = null;
this.masterGain = null;

View file

@ -14,11 +14,22 @@ export interface GardenAudioStroke {
elapsedSeconds: number;
}
export interface LoadedPianoSample {
export interface LoadedPianoStrikeSample {
midi: number;
velocityLayer: number;
buffer: AudioBuffer;
}
export interface LoadedPianoReleaseSample {
midi: number;
buffer: AudioBuffer;
}
export interface LoadedPianoSamples {
releases: Array<LoadedPianoReleaseSample>;
strikes: Array<LoadedPianoStrikeSample>;
}
export interface PianoNote {
midi: number;
velocity: number;

View file

@ -1,7 +1,8 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import { clamp } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
MAX_AUDIO_VOLUME,
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
@ -49,7 +50,7 @@ export class GardenAudio {
private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
@ -228,7 +229,7 @@ export class GardenAudio {
}
public setMasterVolume(masterVolume: number): void {
this.masterVolume = clamp01(masterVolume);
this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
@ -396,7 +397,7 @@ export class GardenAudio {
return;
}
const distanceActivity = clamp01(activity);
const distanceActivity = clamp(activity, 0, 1);
if (distanceActivity <= 0) {
return;
}

View file

@ -2,32 +2,56 @@ 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 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;
source: AudioScheduledSourceNode;
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.7,
filterQ: 0.45,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
releaseTimeConstantCount: 5,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
releaseSampleAttackSeconds: 0.006,
releaseSampleDecaySeconds: 0.18,
releaseTimeConstantCount: 6,
tailStopExtraSeconds: 0.08,
voiceStealFadeSeconds: 0.045,
voiceStealStopSeconds: 0.09,
} as const;
export class PianoSampler {
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
private releaseSamples: Array<LoadedPianoReleaseSample> = [];
private strikeSamples: Array<LoadedPianoStrikeSample> = [];
private velocityLayers: Array<number> = [];
public constructor(
private readonly config: GardenAudioConfig,
@ -35,7 +59,7 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.samples.length > 0) {
if (this.strikeSamples.length > 0) {
return Promise.resolve();
}
@ -67,8 +91,9 @@ export class PianoSampler {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
const noteVelocity = clamp01(velocity);
const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
if (selectedSamples.length === 0) {
return;
}
@ -76,7 +101,6 @@ export class PianoSampler {
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
@ -88,45 +112,36 @@ export class PianoSampler {
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
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({
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
);
},
lowpassHz,
noteGainValue,
pan,
releaseAt,
releaseSample,
scheduledStart,
stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
strikeSources,
sustainAt,
sustainSeconds,
});
}
@ -146,30 +161,40 @@ export class PianoSampler {
}
public reset(): void {
this.samples = [];
this.releaseSamples = [];
this.strikeSamples = [];
this.velocityLayers = [];
this.activeVoices = [];
}
private scheduleVoice({
source,
strikeSources,
releaseSample,
scheduledStart,
sustainAt,
sustainSeconds,
releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
noteGainValue,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
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 } = this.graph;
const { context, delayInput, roomInput } = this.graph;
if (!context) {
return;
}
@ -177,15 +202,18 @@ export class PianoSampler {
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
let sendGain: GainNode | null = null;
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) {
const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
this.stealQuietestVoice(scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
@ -195,48 +223,352 @@ export class PianoSampler {
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
this.configureGainEnvelope({
gain,
noteGainValue,
releaseAt,
scheduledStart,
sustainAt,
sustainSeconds,
});
source.connect(filter);
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) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
delaySendGain = context.createGain();
delaySendGain.gain.value = delaySend;
panner.connect(delaySendGain);
delaySendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
if (roomInput && this.config.piano.roomSend > 0) {
roomSendGain = context.createGain();
roomSendGain.gain.value = this.config.piano.roomSend;
panner.connect(roomSendGain);
roomSendGain.connect(roomInput);
}
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
{ once: true }
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 findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
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 this.samples.reduce((nearest, sample) =>
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
);
}
@ -245,6 +577,33 @@ export class PianoSampler {
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;
@ -254,11 +613,23 @@ export class PianoSampler {
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.sources.forEach((source) => {
try {
source.stop(stopAt);
} catch {
// The source may already have ended naturally.
}
});
voice.stopAt = stopAt;
voice.source.stop(stopAt);
}
private setSamples(samples: Array<LoadedPianoSample>): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
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);
}
}

View file

@ -1,40 +1,26 @@
import type { LoadedPianoSample } from './garden-audio-types';
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
import type {
LoadedPianoReleaseSample,
LoadedPianoSamples,
LoadedPianoStrikeSample,
} from './garden-audio-types';
interface PianoSampleDefinition {
note: string;
interface PianoStrikeSampleDefinition {
kind: 'strike';
midi: number;
path: string;
url: string;
velocityLayer: number;
}
interface PianoReleaseSampleDefinition {
kind: 'release';
midi: number;
path: string;
url: string;
}
type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
@ -42,54 +28,28 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, note: 'A0' },
{ url: c1SampleUrl, note: 'C1' },
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
{ url: a1SampleUrl, note: 'A1' },
{ url: c2SampleUrl, note: 'C2' },
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
{ url: a2SampleUrl, note: 'A2' },
{ url: c3SampleUrl, note: 'C3' },
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
{ url: a3SampleUrl, note: 'A3' },
{ url: c4SampleUrl, note: 'C4' },
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
{ url: a4SampleUrl, note: 'A4' },
{ url: c5SampleUrl, note: 'C5' },
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
{ url: a5SampleUrl, note: 'A5' },
{ url: c6SampleUrl, note: 'C6' },
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
{ url: a6SampleUrl, note: 'A6' },
{ url: c7SampleUrl, note: 'C7' },
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
{ url: a7SampleUrl, note: 'A7' },
{ url: c8SampleUrl, note: 'C8' },
];
const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
eager: true,
import: 'default',
query: '?url&no-inline',
}) as Record<string, string>;
const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
let loadedPianoSamples: LoadedPianoSamples | null = null;
let pianoSampleLoadPromise: Promise<LoadedPianoSamples> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = {
concurrency: 4,
concurrency: 6,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
): Promise<LoadedPianoSamples> => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
@ -106,18 +66,19 @@ export const preloadPianoSamples = (
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
): Promise<LoadedPianoSamples> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) {
emitPianoSampleProgress({
failedCount: 0,
loadedCount: loadedPianoSamples.length,
settledCount: loadedPianoSamples.length,
loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
settledCount:
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
return Promise.resolve([...loadedPianoSamples]);
return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples));
}
if (pianoSampleLoadPromise) {
@ -151,13 +112,15 @@ export const loadPianoSamples = (
)
.then(
(samples) => {
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
loadedPianoSamples = sortLoadedPianoSamples(samples);
const loadedCount =
loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
if (loadedCount !== pianoSampleDefinitions.length) {
throw new Error(
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
`Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.`
);
}
return [...loadedPianoSamples];
return cloneLoadedPianoSamples(loadedPianoSamples);
},
(error: unknown) => {
pianoSampleLoadPromise = null;
@ -170,29 +133,38 @@ export const loadPianoSamples = (
return pianoSampleLoadPromise;
};
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null;
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => {
): Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample> => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
throw new Error(`Unable to load piano sample ${sample.path}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: getMidiForPianoSample(sample), buffer };
if (sample.kind === 'strike') {
return {
buffer,
midi: sample.midi,
velocityLayer: sample.velocityLayer,
};
}
return { buffer, midi: sample.midi };
};
const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>,
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
): Promise<Array<LoadedPianoSample>> => {
const results: Array<LoadedPianoSample> = [];
loadSample: (
sample: PianoSampleDefinition
) => Promise<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
): Promise<Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample>> => {
const results: Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
@ -247,13 +219,50 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
`./samples/${sample.note}v12.m4a`;
function getPianoSampleDefinitions(
modules: Record<string, string>
): Array<PianoSampleDefinition> {
return Object.entries(modules)
.map(([path, url]) => getPianoSampleDefinition(path, url))
.sort((a, b) => a.midi - b.midi || getSampleSortValue(a) - getSampleSortValue(b));
}
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition {
const filename = path.split('/').pop() ?? path;
const strikeMatch = /^(?<note>[A-G](?:sharp)?\d+)v(?<velocityLayer>\d+)\.m4a$/.exec(
filename
);
if (strikeMatch?.groups) {
return {
kind: 'strike',
midi: getMidiForPianoSampleNote(strikeMatch.groups.note),
path,
url,
velocityLayer: Number(strikeMatch.groups.velocityLayer),
};
}
const releaseMatch = /^rel(?<releaseIndex>\d+)\.m4a$/.exec(filename);
if (releaseMatch?.groups) {
return {
kind: 'release',
midi: getMidiForReleaseSample(Number(releaseMatch.groups.releaseIndex)),
path,
url,
};
}
throw new Error(`Invalid piano sample filename ${path}`);
}
function getSampleSortValue(sample: PianoSampleDefinition): number {
return sample.kind === 'strike' ? sample.velocityLayer : Number.MAX_SAFE_INTEGER;
}
function getMidiForPianoSampleNote(note: string): number {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(note);
if (!match?.groups) {
throw new Error(`Invalid piano sample note ${sample.note}`);
throw new Error(`Invalid piano sample note ${note}`);
}
const semitoneByName: Record<string, number> = {
@ -268,4 +277,25 @@ const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
};
}
function getMidiForReleaseSample(releaseIndex: number): number {
const pianoLowestMidi = 21;
return pianoLowestMidi + releaseIndex - 1;
}
const sortLoadedPianoSamples = (
samples: Array<LoadedPianoStrikeSample | LoadedPianoReleaseSample>
): LoadedPianoSamples => ({
releases: samples
.filter((sample): sample is LoadedPianoReleaseSample => !('velocityLayer' in sample))
.sort((a, b) => a.midi - b.midi),
strikes: samples
.filter((sample): sample is LoadedPianoStrikeSample => 'velocityLayer' in sample)
.sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer),
});
const cloneLoadedPianoSamples = (samples: LoadedPianoSamples): LoadedPianoSamples => ({
releases: [...samples.releases],
strikes: [...samples.strikes],
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,15 +2,25 @@ Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
under CC BY 3.0.
Source package: @audio-samples/piano-velocity12
Source packages:
- @audio-samples/piano-velocity4
- @audio-samples/piano-velocity8
- @audio-samples/piano-velocity12
- @audio-samples/piano-velocity16
- @audio-samples/piano-release
Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
The app derives MIDI values from those note names in `piano-samples.ts`.
Checked-in subset: velocity layers `v4`, `v8`, `v12`, and `v16` at the
available Salamander strike anchors, plus all 88 release samples. The strike
anchors are A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
The app derives strike MIDI values and velocity layers from filenames in
`piano-samples.ts`; release sample `rel1` maps to A0 and `rel88` maps to C8.
Repro notes: start from the matching `v12` OGG files in the source package and
transcode each selected sample to AAC/M4A without renaming the note/velocity
stem. The expected output filenames are `<note>v12.m4a`, for example
`C4v12.m4a`.
Repro notes: start from the matching OGG files in the source packages and
transcode each selected sample to AAC/M4A at 192 kbps without renaming the
note/velocity or release stem. Replace `#` with `sharp` in filenames for URL
compatibility. Expected output filenames include `<note>v<layer>.m4a`, for
example `C4v16.m4a`, and `rel<index>.m4a`, for example `rel40.m4a`.

View file

@ -1,18 +1,17 @@
import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
import { DEFAULT_AUDIO_VOLUME, MAX_AUDIO_VOLUME } from '../audio/garden-audio-config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math';
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
const AUDIO_VOLUME_MIN = 0;
const AUDIO_VOLUME_MAX = 1;
const AUDIO_VOLUME_MAX = MAX_AUDIO_VOLUME;
const AUDIO_VOLUME_STEP = 0.01;
const clampAudioVolume = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, safeValue));
};
const readInitialAudioVolume = (): number => {
@ -83,6 +82,7 @@ export class AudioControl {
this.audioVolume = clampAudioVolume(this.audioVolume);
const isEffectivelyMuted = this.isMuted;
const volumePercent = Math.round(this.audioVolume * 100);
const volumeProgressPercent = Math.round((this.audioVolume / AUDIO_VOLUME_MAX) * 100);
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
@ -102,7 +102,10 @@ export class AudioControl {
this.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
this.volumeControl.style.setProperty(
'--volume-progress',
`${volumeProgressPercent}%`
);
const game = this.options.getGame();
game?.setAudioVolume(this.audioVolume);

View file

@ -52,7 +52,7 @@
> .splash-description {
margin: 0;
max-width: 28ch;
max-width: 70ch;
color: rgb(255 255 255 / 80%);
font-size: 15px;
font-weight: 400;

View file

@ -133,9 +133,8 @@ html > body > aside.control-dock > .info-page {
line-height: 1.18;
}
.info-page__lede,
.info-page__notes,
.info-page__meta {
.info-page__main,
.info-page__notes {
max-width: 56ch;
overflow-wrap: break-word;
color: rgb(25 35 32);
@ -143,8 +142,7 @@ html > body > aside.control-dock > .info-page {
line-height: 1.56;
}
.info-page__lede,
.info-page__meta {
.info-page__main {
margin-bottom: 0;
hyphens: auto;
}
@ -166,19 +164,6 @@ html > body > aside.control-dock > .info-page {
}
}
.info-page__meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: baseline;
margin-top: 0.2rem;
padding-top: 0.75rem;
border-top: 1px solid rgb(43 66 57 / 16%);
color: rgb(67 82 77);
font-size: 0.8rem;
line-height: 1.45;
}
a {
color: rgb(0 83 105);
font-weight: 700;
@ -214,15 +199,10 @@ html > body > aside.control-dock > .info-page {
padding: 14px;
}
.info-page__lede,
.info-page__main,
.info-page__notes {
font-size: 0.95rem;
line-height: 1.52;
}
.info-page__meta {
font-size: 0.875rem;
line-height: 1.45;
}
}
}