Spaces:
Running
Running
File size: 7,870 Bytes
de6631a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | // basicpitch.js - Spotify's Basic Pitch in the browser: audio in, notes and MIDI out.
//
// const output = await transcribe(samples); // mono Float32Array at SAMPLE_RATE
// const notes = findNotes(output); // [{ pitchMidi, startTimeSeconds, durationSeconds, amplitude }]
// const midi = toMidi(notes); // the bytes of a .mid file
import { notesFromOutput } from "./notes.js";
export const SAMPLE_RATE = 22050;
const NOTES = 88, FPS = Math.floor(SAMPLE_RATE / 256);
const WINDOW = 43844, FRAMES = 172, EDGE = 15, ROWS = FRAMES - 2 * EDGE; // as in bp.c
const OVERLAP = 2 * EDGE * 256, STEP = WINDOW - OVERLAP;
let pool = null, jobs = 0;
// Starts the workers (transcribe does too). The first call sets the number of threads.
export function preload({ threads = Math.min(8, navigator.hardwareConcurrency || 4) } = {}) {
if (pool) return pool.ready;
const workers = Array.from({ length: threads }, () => new Worker(import.meta.url, { type: "module", name: "basicpitch" }));
const handlers = new Map(); // job id -> its result handler
const ready = loadFiles().then((files) => Promise.all(workers.map((worker) => new Promise((resolve, reject) => {
worker.onmessage = ({ data }) => {
if (data.ready) resolve();
else if (data.job === undefined) reject(new Error(data.error));
else handlers.get(data.job)?.(worker, data);
};
worker.onerror = (e) => {
e.preventDefault();
const error = e.message || "Basic Pitch failed";
reject(new Error(error));
for (const handle of handlers.values()) handle(worker, { error });
};
worker.postMessage(files);
}))));
pool = { workers, handlers, ready };
ready.catch(() => { for (const worker of workers) worker.terminate(); pool = null; });
return ready;
}
async function loadFiles() {
const simd = WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11]));
const get = (name) => fetch(new URL(name, import.meta.url)).then((r) => {
if (!r.ok) throw new Error(`can't load ${name} (${r.status})`);
return r.arrayBuffer();
});
const [wasm, plan, weights] = await Promise.all([get(simd ? "bp.wasm" : "bp-plain.wasm"), get("plan.bin"), get("weights.bin")]);
return { module: await WebAssembly.compile(wasm), plan, weights };
}
// The model's output: { frames, onsets, rows }, frames[row * 88 + key] (key 0 is MIDI note 21).
export async function transcribe(samples, { threads, onProgress, signal } = {}) {
await preload(threads ? { threads } : undefined);
const job = ++jobs, rows = Math.floor(samples.length * FPS / SAMPLE_RATE), count = Math.ceil((samples.length + OVERLAP / 2) / STEP);
const frames = new Float32Array(rows * NOTES), onsets = new Float32Array(rows * NOTES);
let next = 0, done = 0;
try {
await new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
const send = (worker) => {
if (next >= count || signal?.aborted) return;
const index = next++, start = index * STEP - OVERLAP / 2, window = new Float32Array(WINDOW);
window.set(samples.subarray(Math.max(0, start), Math.min(samples.length, start + WINDOW)), Math.max(0, -start));
worker.postMessage({ job, index, window }, [window.buffer]);
};
pool.handlers.set(job, (worker, data) => {
if (data.error) return reject(new Error(data.error));
const at = data.index * ROWS, n = Math.min(ROWS, rows - at) * NOTES;
if (n > 0) { frames.set(data.frames.subarray(0, n), at * NOTES); onsets.set(data.onsets.subarray(0, n), at * NOTES); }
done++; // not inside onProgress?.(), which skips its argument without a callback
onProgress?.(done / count);
if (done === count) resolve();
else send(worker);
});
for (const worker of pool.workers) { send(worker); send(worker); }
});
} finally {
pool.handlers.delete(job);
}
return { frames, onsets, rows };
}
// The settings and defaults are basicpitch.spotify.com's (ms, Hz, 0 for no limit).
export function findNotes(output, { onsetThreshold = 0.5, frameThreshold = 0.3, minNoteLength = 128, minFrequency = 0, maxFrequency = 3000, boostOnsets = true } = {}) {
return notesFromOutput(output, onsetThreshold, frameThreshold, Math.round(minNoteLength / 1000 * FPS), minFrequency, maxFrequency, boostOnsets);
}
// The notes as the MIDI file holds them (ticks, seconds, velocity), for playback.
export function midiNotes(notes, { tempo = 120 } = {}) {
const ticks = 8 * tempo; // a second, at 480 a beat
return notes.map((n) => {
const on = Math.round(n.startTimeSeconds * ticks), off = Math.max(on + 1, Math.round((n.startTimeSeconds + n.durationSeconds) * ticks));
return { pitch: n.pitchMidi, on, off, start: on / ticks, end: off / ticks, velocity: Math.min(127, Math.max(1, Math.round(n.amplitude * 127))) };
});
}
// The tempo only sets the beat grid, the notes keep their times.
export function toMidi(notes, { tempo = 120 } = {}) {
const events = [];
for (const n of midiNotes(notes, { tempo })) events.push([n.on, 1, n.pitch, n.velocity], [n.off, 0, n.pitch, 0]);
events.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // offs before ons
const us = Math.floor(60e6 / tempo);
const track = [0x00, 0xff, 0x51, 0x03, (us >> 16) & 255, (us >> 8) & 255, us & 255];
let last = 0;
for (const [tick, on, pitch, velocity] of events) {
track.push(...varint(tick - last), on ? 0x90 : 0x80, pitch, velocity);
last = tick;
}
track.push(0x00, 0xff, 0x2f, 0x00);
const n = track.length;
return new Uint8Array([
0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 0, 0, 1, 0x01, 0xe0, // MThd: format 0, 1 track, 480 ticks a beat
0x4d, 0x54, 0x72, 0x6b, n >>> 24, (n >> 16) & 255, (n >> 8) & 255, n & 255, ...track,
]);
}
function varint(value) {
const bytes = [value & 0x7f];
while ((value >>= 7)) bytes.unshift((value & 0x7f) | 0x80);
return bytes;
}
// In a worker
let model = null;
async function setUp({ module, plan, weights }) {
const bp = (await WebAssembly.instantiate(module, { env: { emscripten_notify_memory_growth() {} } })).exports;
bp._initialize?.();
const copy = (bytes) => { const p = bp.malloc(bytes.byteLength); new Uint8Array(bp.memory.buffer, p, bytes.byteLength).set(new Uint8Array(bytes)); return p; };
const handle = bp.bp_load(copy(plan), plan.byteLength / 4, copy(weights));
if (!handle) throw new Error("plan.bin is not a Basic Pitch plan");
model = { bp, handle, input: bp.malloc(WINDOW * 4), frames: bp.malloc(FRAMES * NOTES * 4), onsets: bp.malloc(FRAMES * NOTES * 4) };
// warm up, so the browser compiles the optimised code before real work
for (let i = 0; i < 2; i++) bp.bp_run(handle, model.input, model.frames, model.onsets, 0);
}
function run({ job, index, window }) {
const { bp, handle, input, frames, onsets } = model;
new Float32Array(bp.memory.buffer, input, WINDOW).set(window);
const err = bp.bp_run(handle, input, frames, onsets, 0);
if (err) throw new Error(`Basic Pitch error ${err}`);
const rows = (p) => new Float32Array(bp.memory.buffer, p + EDGE * NOTES * 4, ROWS * NOTES).slice();
const f = rows(frames), o = rows(onsets);
self.postMessage({ job, index, frames: f, onsets: o }, [f.buffer, o.buffer]);
}
if (typeof WorkerGlobalScope !== "undefined" && self.name === "basicpitch")
self.onmessage = ({ data }) => {
if (data.module) setUp(data).then(() => self.postMessage({ ready: true }), (error) => self.postMessage({ error: error.message }));
else try { run(data); } catch (error) { self.postMessage({ job: data.job, error: error.message }); }
};
|