Add lazy piano transcription and optional leased server worker

This commit is contained in:
Jonathan Sykes
2026-10-03 15:47:53 +08:00
parent d4354d220e
commit 0374e19396
20 changed files with 1107 additions and 1 deletions

72
frontend/piano-engine.mjs Normal file
View File

@@ -0,0 +1,72 @@
// This module, TensorFlow and the model are imported only after Transcribe.
let enginePromise, model;
const MODEL = 'https://cdn.jsdelivr.net/npm/@spotify/basic-pitch@1.0.1/model/model.json';
async function engine() {
if (!enginePromise)
enginePromise = (async () => {
const tf = await import('https://esm.sh/@tensorflow/tfjs@4.22.0');
await selectBackend(
tf,
!!navigator.gpu,
() => import('https://esm.sh/@tensorflow/tfjs-backend-webgpu@4.22.0'),
);
await tf.ready();
const pitch = await import(
'https://esm.sh/@spotify/basic-pitch@1.0.1?deps=@tensorflow/tfjs@4.22.0'
);
const graph = await tf.loadGraphModel(MODEL);
model = new pitch.BasicPitch(Promise.resolve(graph));
return { tf, pitch, backend: tf.getBackend() };
})().catch((error) => {
enginePromise = null;
throw error;
});
return enginePromise;
}
export async function selectBackend(tf, hasGPU, loadGPU) {
if (hasGPU) {
try {
await loadGPU();
if (await tf.setBackend('webgpu')) return 'webgpu';
} catch {}
}
for (const backend of ['webgl', 'cpu']) {
try {
if (await tf.setBackend(backend)) return backend;
} catch {}
}
throw new Error('No TensorFlow backend is available in this browser.');
}
export async function transcribe(buffer, progress) {
const decoded = await new OfflineAudioContext(1, 1, 22050).decodeAudioData(buffer);
if (decoded.duration > 900) throw new Error('Browser transcription is limited to 15 minutes.');
const context = new OfflineAudioContext(1, Math.ceil(decoded.duration * 22050), 22050),
source = context.createBufferSource();
source.buffer = decoded;
source.connect(context.destination);
source.start();
const audio = await context.startRendering();
progress('Loading Basic Pitch library and model…');
const { tf, pitch, backend } = await engine();
const frames = [],
onsets = [];
// Basic Pitch 1.0.1 retains intermediate tensors; release each run's scope.
// Model weights were loaded before this scope and stay available for reuse.
tf.engine().startScope();
try {
await model.evaluateModel(
audio.getChannelData(0),
(f, o) => {
frames.push(...f);
onsets.push(...o);
},
(p) => progress(`Transcribing on ${backend}: ${Math.round(p * 100)}%`),
);
} finally {
tf.engine().endScope();
}
return {
notes: pitch.noteFramesToTime(pitch.outputToNotesPoly(frames, onsets, 0.25, 0.25, 5)),
engine: `basic-pitch-1.0.1/${backend}`,
};
}