Add lazy piano transcription and optional leased server worker
This commit is contained in:
72
frontend/piano-engine.mjs
Normal file
72
frontend/piano-engine.mjs
Normal 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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user