fleeting-garden/src/audio/piano-samples.ts
2026-05-16 15:05:35 +01:00

145 lines
4 KiB
TypeScript

import type { LoadedPianoSample } from './garden-audio-types';
export interface PianoSampleDefinition {
midi: number;
url: string;
}
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
sample?: PianoSampleDefinition;
}
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
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],
];
export 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;
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
const OfflineAudioContextConstructor =
globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext;
if (!OfflineAudioContextConstructor) {
return Promise.reject(
new Error('OfflineAudioContext is required to preload piano samples.')
);
}
const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) {
onProgress?.({
loadedCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
let loadedCount = 0;
const totalCount = pianoSampleDefinitions.length;
onProgress?.({ loadedCount, totalCount });
pianoSampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const loadedSample = await loadPianoSample(decodeContext, sample);
loadedCount += 1;
onProgress?.({ loadedCount, totalCount, sample });
return loadedSample;
})
).then(
(samples) => {
loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi);
return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
throw error;
}
);
return pianoSampleLoadPromise;
};
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
export const resetPianoSampleCacheForTest = (): void => {
loadedPianoSamples = null;
pianoSampleLoadPromise = null;
};
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeAudioData(decodeContext, audioData);
return { midi: sample.midi, buffer };
};
const decodeAudioData = (
decodeContext: BaseAudioContext,
audioData: ArrayBuffer
): Promise<AudioBuffer> =>
new Promise((resolve, reject) => {
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
decodePromise?.then(resolve, reject);
});