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