SegerEnd's picture
Basic Pitch without TensorFlow: C/WebAssembly runtime and demo
de6631a verified
Raw History Blame Contribute Delete
4.16 kB
// notes.js - Basic Pitch's outputToNotesPoly.
const NOTES = 88, LOWEST_MIDI = 21;
const SAMPLE_RATE = 22050, HOP = 256, FPS = Math.floor(SAMPLE_RATE / HOP);
// frame -> seconds, with Basic Pitch's correction per 2 s window
const WINDOW_FRAMES = 2 * FPS, WINDOW_OFFSET = HOP / SAMPLE_RATE * (WINDOW_FRAMES - (2 * SAMPLE_RATE - HOP) / HOP) + 0.0018;
const frameTime = (frame) => frame * HOP / SAMPLE_RATE - WINDOW_OFFSET * Math.floor(frame / WINDOW_FRAMES);
const keyOf = (hz) => Math.trunc(12 * (Math.log2(hz) - Math.log2(440)) + 69 - LOWEST_MIDI);
export function notesFromOutput({ frames, onsets, rows }, onsetThreshold, frameThreshold, minFrames, minFrequency, maxFrequency, boostOnsets, energyTolerance = 11) {
const at = (row, key) => row * NOTES + key;
frames = frames.slice(0, rows * NOTES);
onsets = onsets.slice(0, rows * NOTES);
// frequency limits (clamped: Basic Pitch wipes almost every key for a minimum under 27.5 Hz)
if (maxFrequency) {
const from = Math.min(Math.max(keyOf(maxFrequency), 0), NOTES);
for (let r = 0; r < rows; r++) { frames.fill(0, at(r, from), at(r + 1, 0)); onsets.fill(0, at(r, from), at(r + 1, 0)); }
}
if (minFrequency) {
const to = Math.min(Math.max(keyOf(minFrequency), 0), NOTES);
for (let r = 0; r < rows; r++) { frames.fill(0, at(r, 0), at(r, to)); onsets.fill(0, at(r, 0), at(r, to)); }
}
let onset = onsets;
if (boostOnsets) {
let onsetMax = 0, riseMax = 0;
const rise = new Float32Array(rows * NOTES);
for (const v of onsets) onsetMax = Math.max(onsetMax, v);
for (let r = 2; r < rows; r++)
for (let k = 0; k < NOTES; k++) {
const v = frames[at(r, k)], d = Math.max(Math.min(v - frames[at(r - 1, k)], v - frames[at(r - 2, k)]), 0);
rise[at(r, k)] = d;
riseMax = Math.max(riseMax, d);
}
onset = onsets.map((v, i) => Math.max(v, riseMax ? onsetMax * rise[i] / riseMax : 0));
}
const starts = [];
for (let r = 0; r < rows; r++)
for (let k = 0; k < NOTES; k++) {
const v = onset[at(r, k)];
if (v > onsetThreshold && (r === 0 || v > onset[at(r - 1, k)]) && (r === rows - 1 || v > onset[at(r + 1, k)])) starts.push([r, k]);
}
starts.reverse();
const left = frames.slice(), notes = []; // energy no note has taken yet
const take = (r, k) => { left[at(r, k)] = 0; if (k < NOTES - 1) left[at(r, k + 1)] = 0; if (k > 0) left[at(r, k - 1)] = 0; };
const add = (start, end, k) => {
let sum = 0;
for (let r = start; r < end; r++) sum += frames[at(r, k)];
notes.push({ start, end, key: k, amplitude: sum / (end - start) });
};
for (const [start, k] of starts) {
if (start >= rows - 1) continue;
let r = start + 1, quiet = 0;
while (r < rows - 1 && quiet < energyTolerance) { quiet = left[at(r, k)] < frameThreshold ? quiet + 1 : 0; r++; }
const end = r - quiet;
if (end - start <= minFrames) continue;
for (let i = start; i < end; i++) take(i, k);
add(start, end, k);
}
// melodia trick, loudest first (sorted once, same order as Basic Pitch's repeated scans)
const peaks = [];
for (let i = 0; i < rows * NOTES; i++) if (left[i] > frameThreshold) peaks.push(i);
peaks.sort((a, b) => left[b] - left[a] || Math.floor(a / NOTES) - Math.floor(b / NOTES) || b - a);
const level = Float32Array.from(peaks, (i) => left[i]);
peaks.forEach((peak, p) => {
if (left[peak] !== level[p]) return; // already taken
const mid = Math.floor(peak / NOTES), k = peak % NOTES;
left[peak] = 0;
let r = mid + 1, quiet = 0;
while (r < rows - 1 && quiet < energyTolerance) { quiet = left[at(r, k)] < frameThreshold ? quiet + 1 : 0; take(r, k); r++; }
const end = r - 1 - quiet;
r = mid - 1; quiet = 0;
while (r > 0 && quiet < energyTolerance) { quiet = left[at(r, k)] < frameThreshold ? quiet + 1 : 0; take(r, k); r--; }
const start = r + 1 + quiet;
if (end - start > minFrames) add(start, end, k);
});
return notes.map((n) => ({
pitchMidi: n.key + LOWEST_MIDI,
startTimeSeconds: frameTime(n.start),
durationSeconds: frameTime(n.end) - frameTime(n.start),
amplitude: n.amplitude,
}));
}