Perlines/web/tools/onset-bench.mjs
Aqua 908e8dcb3b web: band-split dB-flux onset detector, synthetic drum bench
Replaces the log1p-compressed whole-spectrum flux with four-band flux in dB,
a two-frame max-filtered reference, a per-band median+8 MAD threshold with a
per-band minimum rise, and one refractory period. Level-independent.

tools/onset-bench.mjs synthesises kick/snare/hat patterns with known onset
times (3 patterns x 3 tempos x 3 levels x 3 beds = 81 signals), scores hit
rate, false positives and median timing at 30 fps and 512-sample hops.
tools/onset-legacy.mjs freezes the old detector for the before numbers.

Synthetic only. Nothing was listened to; no real music, no real GPU.
Corpus adjustments made after the first baseline run: coincident kick+snare
count as one onset; voices end in a 60 ms fade because truncation clicks read
as onsets in the near-silent gaps; hats raised relative to the snare tail.
Baseline was rerun after each change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:34:14 +00:00

169 lines
9.1 KiB
JavaScript

// Onset detector bench: synthesises drum patterns with known onset times, runs a detector over them at the
// page's 30 fps feature rate and at full rate (512-sample hop), and scores hit rate, false positives and timing.
// Deterministic (fixed LCG seeds), no network, no dependencies. node web/tools/onset-bench.mjs [variant ...]
// SYNTHETIC ONLY: nothing here has been listened to, and a score here says nothing about real music.
import { magnitudeSpectrum, OnsetDetector } from '../src/dsp.js';
import { LegacyOnsetDetector } from './onset-legacy.mjs';
export const SR = 48000, FFT = 2048;
export const RATES = { fps30: Math.round(SR / 30), full: 512 }; // hop in samples
const LEAD = 1.0, DURATION = 14, SCORE_FROM = 0.6; // seconds
const MATCH_EARLY = 0.02, MATCH_LATE = 0.09; // a frame at time T (window end) matches an onset at t if t-0.02 <= T <= t+0.09
function rng(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296) * 2 - 1; }
// 16 steps per bar. kick/snare/hat step lists.
export const PATTERNS = {
rock: { kick: [0, 8, 10], snare: [4, 12], hat: [0, 2, 4, 6, 8, 10, 12, 14] },
four: { kick: [0, 4, 8, 12], snare: [4, 12], hat: [2, 6, 10, 14] },
busy: { kick: [0, 3, 6, 10, 13], snare: [4, 12], hat: Array.from({ length: 16 }, (_, i) => i) },
};
export const BEDS = { none: { bass: 0, noise: 0 }, bed: { bass: 0.2, noise: 0.02 }, loud: { bass: 0.4, noise: 0.08 } };
export const LEVELS = { '0dB': 1, '-20dB': 0.1, '-40dB': 0.01 };
export const TEMPOS = [90, 120, 150];
// every voice ends with a 60 ms linear fade so a truncated tail cannot click and read as an onset
const taper = (i, n) => Math.min(1, (n - i) / (0.06 * SR));
function addKick(x, s, r) {
let ph = 0; const n = Math.round(0.5 * SR);
for (let i = 0; i < n && s + i < x.length; i++) {
const t = i / SR; ph += 2 * Math.PI * (45 + 90 * Math.exp(-t / 0.03)) / SR;
x[s + i] += taper(i, n) * 0.9 * Math.exp(-t / 0.12) * Math.sin(ph);
}
}
function addSnare(x, s, r) {
const n = Math.round(0.4 * SR); let p = 0;
for (let i = 0; i < n && s + i < x.length; i++) {
const t = i / SR, w = r(), hp = w - p; p = w;
x[s + i] += taper(i, n) * (0.3 * Math.exp(-t / 0.06) * Math.sin(2 * Math.PI * 185 * t) + 0.5 * Math.exp(-t / 0.09) * hp * 0.7);
}
}
function addHat(x, s, r, amp) {
const n = Math.round(0.15 * SR); let p1 = 0, p2 = 0;
for (let i = 0; i < n && s + i < x.length; i++) {
const t = i / SR, w = r(), a = w - p1, b = a - p2; p1 = w; p2 = a;
x[s + i] += taper(i, n) * amp * Math.exp(-t / 0.025) * b * 0.5;
}
}
/** -> { x: Float32Array, truth: [{ time, inst }] } */
export function synthesize({ pattern, bpm, level, bed, seed = 1 }) {
const x = new Float32Array(DURATION * SR), r = rng(seed), P = PATTERNS[pattern], B = BEDS[bed];
const step = 60 / bpm / 4, truth = [];
const place = (inst, k, fn, ...a) => { const t = LEAD + k * step; truth.push({ time: t, inst }); fn(x, Math.round(t * SR), r, ...a); };
for (let bar = 0, k0 = 0; LEAD + k0 * step < DURATION - 0.4; bar++, k0 += 16) {
for (const s of P.kick) if (LEAD + (k0 + s) * step < DURATION - 0.4) place('kick', k0 + s, addKick);
for (const s of P.snare) if (LEAD + (k0 + s) * step < DURATION - 0.4) place('snare', k0 + s, addSnare);
for (const s of P.hat) if (LEAD + (k0 + s) * step < DURATION - 0.4) place('hat', k0 + s, addHat, s % 4 === 0 ? 0.7 : 0.4);
}
// beds: a bass drone that glides between notes (no onsets) and pink-ish noise, faded in over 0.3 s
const notes = [55, 55, 65.4, 49], rb = rng(seed + 100);
let ph = 0, f = 55, pink = 0;
for (let i = 0; i < x.length; i++) {
const t = i / SR, target = notes[Math.floor(t / 2) % notes.length];
f += (target - f) * (1 - Math.exp(-1 / (0.3 * SR)));
ph += 2 * Math.PI * f / SR;
const fade = Math.min(1, t / 0.3), trem = 0.85 + 0.15 * Math.sin(2 * Math.PI * 0.25 * t);
pink = 0.98 * pink + 0.1 * rb();
x[i] += fade * (B.bass * trem * (Math.sin(ph) + 0.4 * Math.sin(2 * ph)) + B.noise * pink * 3);
}
const rf = rng(seed + 200);
for (let i = 0; i < x.length; i++) x[i] = x[i] * level + rf() * 1e-5; // dither floor, ~ -100 dBFS
return { x, truth };
}
/** Magnitude frames the way AnalyserNode hands them over: window ends at 'time', values round-trip through dB clamped at -120. */
export function spectra(x, hop) {
const frames = [], mags = new Float32Array(FFT >> 1);
for (let off = 0; off + FFT <= x.length; off += hop) {
magnitudeSpectrum(x, off, FFT, mags);
const m = new Float32Array(mags.length);
for (let k = 0; k < m.length; k++) m[k] = Math.pow(10, Math.max(20 * Math.log10(mags[k] + 1e-12), -120) / 20);
frames.push({ time: (off + FFT) / SR, mags: m });
}
return frames;
}
/** Coincident hits (a kick and a snare on one step) are one onset: a detector emits one event per moment.
* Greedy one-to-one match in time order; each onset takes the earliest unused detection in its window.
* Per-instrument rows count an instrument as hit when the event it sounds in was hit. */
export function score(truth, found) {
const events = [];
for (const t of truth.slice().sort((p, q) => p.time - q.time)) {
const e = events[events.length - 1];
if (e && t.time - e.time < 0.005) e.insts.push(t.inst); else events.push({ time: t.time, insts: [t.inst] });
}
const used = new Set(), errs = [], per = {};
let n = 0;
for (const e of events) {
if (e.time < SCORE_FROM) continue;
n++;
const i = found.findIndex((f, j) => !used.has(j) && f >= e.time - MATCH_EARLY && f <= e.time + MATCH_LATE);
if (i >= 0) { used.add(i); errs.push(found[i] - e.time); }
for (const inst of e.insts) { const p = per[inst] ??= { n: 0, hit: 0 }; p.n++; if (i >= 0) p.hit++; }
}
return { n, hit: errs.length, fp: found.filter((f, j) => !used.has(j) && f >= SCORE_FROM).length, errs, per };
}
export function detect(make, frames) {
const det = make(), out = [];
for (const fr of frames) { const o = det.push(fr.mags, fr.time); if (o) out.push(o.time); }
return out;
}
export function corpus() {
const cases = [];
for (const pattern of Object.keys(PATTERNS)) for (const bpm of TEMPOS) for (const [lv, level] of Object.entries(LEVELS)) for (const bed of Object.keys(BEDS))
cases.push({ pattern, bpm, lv, level, bed, seed: cases.length + 1 });
return cases;
}
// detector as Analysis builds it: bins up to 8 kHz
const maxBin = Math.round(8000 / (SR / 2 / (FFT / 2))); // what the legacy Analysis passed
export const VARIANTS = {
legacy: () => new LegacyOnsetDetector({ maxBin }),
current: () => new OnsetDetector({ binHz: SR / FFT }),
};
const median = a => { if (!a.length) return NaN; const s = a.slice().sort((p, q) => p - q); return s[s.length >> 1]; };
/** run variants over the corpus -> { [variant]: { [rate]: { n, hit, fp, minutes, medianMs, byInst, byBed, byLevel } } } */
export function run(variantNames = Object.keys(VARIANTS), cases = corpus()) {
const acc = {};
for (const v of variantNames) { acc[v] = {}; for (const r of Object.keys(RATES)) acc[v][r] = { n: 0, hit: 0, fp: 0, seconds: 0, errs: [], inst: {}, bed: {}, lv: {} }; }
for (const c of cases) {
const { x, truth } = synthesize(c);
for (const [rate, hop] of Object.entries(RATES)) {
const frames = spectra(x, hop);
for (const v of variantNames) {
const s = score(truth, detect(VARIANTS[v], frames)), a = acc[v][rate];
a.n += s.n; a.hit += s.hit; a.fp += s.fp; a.seconds += DURATION - SCORE_FROM; a.errs.push(...s.errs);
for (const [k, p] of Object.entries(s.per)) { const q = a.inst[k] ??= { n: 0, hit: 0 }; q.n += p.n; q.hit += p.hit; }
for (const key of ['bed', 'lv']) { const q = a[key][c[key]] ??= { n: 0, hit: 0, fp: 0 }; q.n += s.n; q.hit += s.hit; q.fp += s.fp; }
}
}
}
const out = {};
for (const v of variantNames) { out[v] = {}; for (const [r, a] of Object.entries(acc[v])) out[v][r] = { ...a, hitRate: a.hit / a.n, fpPerMin: a.fp / (a.seconds / 60), medianMs: median(a.errs) * 1000 }; }
return out;
}
const pct = (h, n) => (100 * h / n).toFixed(1).padStart(5) + '%';
export function report(res) {
const lines = [];
for (const [v, rates] of Object.entries(res)) for (const [r, a] of Object.entries(rates)) {
const i = Object.entries(a.inst).map(([k, p]) => `${k} ${pct(p.hit, p.n)}`).join(' ');
const b = Object.entries(a.bed).map(([k, p]) => `${k} ${pct(p.hit, p.n)}/${p.fp}fp`).join(' ');
const l = Object.entries(a.lv).map(([k, p]) => `${k} ${pct(p.hit, p.n)}/${p.fp}fp`).join(' ');
lines.push(`${v.padEnd(9)} ${r.padEnd(5)} hit ${pct(a.hit, a.n)} (${a.hit}/${a.n}) fp ${a.fp} (${a.fpPerMin.toFixed(1)}/min) median err ${a.medianMs.toFixed(1)} ms`);
lines.push(` by instrument: ${i}`, ` by bed: ${b}`, ` by level: ${l}`);
}
return lines.join('\n');
}
if (import.meta.url === `file://${process.argv[1]}`) {
const names = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(VARIANTS);
const cs = corpus();
console.log(`corpus: ${cs.length} signals x ${DURATION} s, ${Object.keys(RATES).map(r => `${r}=hop ${RATES[r]}`).join(', ')}, SR ${SR}, FFT ${FFT}; match window -${MATCH_EARLY * 1000}..+${MATCH_LATE * 1000} ms of window end`);
console.log(report(run(names, cs)));
}