This commit is contained in:
Andras Schmelczer 2026-05-21 07:43:10 +01:00
commit 6bc125be1c
104 changed files with 3084 additions and 2410 deletions

View file

@ -1,4 +1,4 @@
import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types';
export interface GardenAudioChord {

View file

@ -38,6 +38,7 @@ export class GardenAudio {
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0;
public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
@ -56,6 +57,14 @@ export class GardenAudio {
return;
}
if (
this.lifecycle === 'started' &&
this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running'
) {
return;
}
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
@ -108,25 +117,49 @@ export class GardenAudio {
return;
}
const startRequestId = ++this.startRequestId;
void this.piano
.load(context)
.then(() => {
if (!this.canCompleteStart(context, startRequestId)) {
return;
}
this.activateStart(vibe, context, startupRampSeconds, true);
})
.catch((error) => {
if (this.canCompleteStart(context, startRequestId)) {
this.activateStart(vibe, context, startupRampSeconds, false);
}
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples.',
severity: Severity.WARNING,
});
});
}
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
return (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted &&
this.startRequestId === startRequestId
);
}
private activateStart(
vibe: VibePreset,
context: AudioContext,
startupRampSeconds: number,
cuePiano: boolean
): void {
this.lifecycle = 'started';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe));
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context);
if (pianoLoad) {
void pianoLoad
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
severity: Severity.WARNING,
});
});
if (cuePiano) {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
}

View file

@ -46,13 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
type GardenAudioStyleIndex = 0 | 1 | 2;
interface TouchDownRequest {
vibe: VibePreset;
now: number;
strength: number;
maniaAmount?: number;
}
interface PitchCandidate {
midi: number;
preference: number;
@ -152,7 +145,12 @@ export class GenerativePianoEngine {
now,
strength,
maniaAmount = 0,
}: TouchDownRequest): void {
}: {
vibe: VibePreset;
now: number;
strength: number;
maniaAmount?: number;
}): void {
const normalizedStrength = clamp01(strength);
const normalizedManiaAmount = clamp01(maniaAmount);
const styleIndex = this.getStyleIndex(now);

View file

@ -21,9 +21,6 @@ const pianoSamplerTuning = {
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,
@ -39,9 +36,9 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
if (this.loadState !== 'idle') {
return null;
public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
@ -80,82 +77,32 @@ export class PianoSampler {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
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 = 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;
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 = this.computeNoteGain(
noteVelocity,
pianoSamplerTuning.synthGainScale
);
const releaseAt =
scheduledStart +
clamp(
durationSeconds + profileSustainSeconds * 0.5,
pianoSamplerTuning.minDurationSeconds,
pianoSamplerTuning.synthMaxDurationSeconds
);
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;
const source = context.createOscillator();
const source = context.createBufferSource();
source.type = pianoSamplerTuning.synthOscillatorType;
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
@ -171,6 +118,17 @@ export class PianoSampler {
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,
@ -312,6 +270,3 @@ export class PianoSampler {
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);

View file

@ -1,7 +1,38 @@
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';
interface PianoSampleDefinition {
midi: number;
path: string;
url: string;
}
@ -10,48 +41,39 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
const sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.m4a', 21],
['C1v12.m4a', 24],
['Dsharp1v12.m4a', 27],
['Fsharp1v12.m4a', 30],
['A1v12.m4a', 33],
['C2v12.m4a', 36],
['Dsharp2v12.m4a', 39],
['Fsharp2v12.m4a', 42],
['A2v12.m4a', 45],
['C3v12.m4a', 48],
['Dsharp3v12.m4a', 51],
['Fsharp3v12.m4a', 54],
['A3v12.m4a', 57],
['C4v12.m4a', 60],
['Dsharp4v12.m4a', 63],
['Fsharp4v12.m4a', 66],
['A4v12.m4a', 69],
['C5v12.m4a', 72],
['Dsharp5v12.m4a', 75],
['Fsharp5v12.m4a', 78],
['A5v12.m4a', 81],
['C6v12.m4a', 84],
['Dsharp6v12.m4a', 87],
['Fsharp6v12.m4a', 90],
['A6v12.m4a', 93],
['C7v12.m4a', 96],
['Dsharp7v12.m4a', 99],
['Fsharp7v12.m4a', 102],
['A7v12.m4a', 105],
['C8v12.m4a', 108],
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
];
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
midi,
url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
@ -111,11 +133,11 @@ export const loadPianoSamples = (
}
).then(
(samples) => {
loadedPianoSamples = samples
.filter((sample): sample is LoadedPianoSample => sample !== null)
.sort((a, b) => a.midi - b.midi);
if (loadedPianoSamples.length === 0) {
throw new Error('Unable to load any piano samples.');
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
throw new Error(
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
);
}
return [...loadedPianoSamples];
},
@ -138,7 +160,7 @@ const loadPianoSample = async (
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
throw new Error(`Unable to load piano sample ${sample.path}`);
}
const audioData = await response.arrayBuffer();
@ -148,17 +170,13 @@ const loadPianoSample = async (
const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>,
loadSample: (
sample: PianoSampleDefinition
) => Promise<LoadedPianoSample | null>
): Promise<Array<LoadedPianoSample | null>> => {
const results: Array<LoadedPianoSample | null> = [];
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
): Promise<Array<LoadedPianoSample>> => {
const results: Array<LoadedPianoSample> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
const batchResults = await Promise.all(
batch.map((sample) => loadSample(sample).catch(() => null))
);
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
results.push(...batchResults);
}

BIN
src/audio/samples/A0v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C8v12.m4a Normal file

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

@ -0,0 +1,7 @@
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 recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/