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 }); }
  };