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>
This commit is contained in:
Aqua 2026-11-06 12:34:14 +00:00
commit 908e8dcb3b
7 changed files with 346 additions and 49 deletions

View file

@ -16,7 +16,7 @@ Any static server works (`python3 -m http.server -d web`). `localhost` counts as
| Piece | File | What it does |
|---|---|---|
| Onset detector | `web/src/dsp.js` `OnsetDetector` | Log-compressed spectral flux against a max-filtered previous frame, moving-median adaptive threshold, local-peak pick, refractory period. Causal: confirms an onset one frame late. |
| Onset detector | `web/src/dsp.js` `OnsetDetector` | Four-band spectral flux in dB (40-150, 150-600, 600-2500, 2.5-12 kHz) against a per-bin max of the two previous frames (max-filtered across +-1 bin), per-band median + 8 MAD threshold with a per-band minimum rise, local-peak pick, one refractory period over all bands. Level-independent. Causal: confirms an onset one frame late. Synthetic-drum scores: `tools/onset-bench.mjs`. |
| Key estimate | `web/src/dsp.js` `KeyEstimator` | Peak-based chroma (parabolic-interpolated spectral peaks folded to 12 pitch classes, 4 s half-life) correlated against the 24 Krumhansl-Kessler key profiles. The key picks the scene tint: tonic walks the circle of fifths for hue, mode sets warmth. |
| Analysis state | `web/src/analysis.js` | One object all scenes read: 128 log bands, bass/mid/treble, onset counter, chroma, key, tint. Also `DemoFeed`. |
| Perlin noise | `web/src/noise.js` | Classic 3D Perlin and a 2D curl field (divergence-free flow) for the CPU-side paths. |
@ -32,10 +32,13 @@ Any static server works (`python3 -m http.server -d web`). `localhost` counts as
## Tests and measurements
```sh
cd web && npm test # node --test test/dsp.test.mjs (node >= 20, nothing to install)
cd web && npm test # node --test test/dsp.test.mjs test/onset.test.mjs (node >= 20, nothing to install)
cd web && npm run bench:onsets # full synthetic-drum corpus, before/after table, about 30 s
```
Covers: ten synthetic clicks found at two hop sizes (11.6 ms and 16.7 ms) with no false positives; silence, steady noise and a sustained tone give no onsets; a 20 ms doublet merges under the refractory period; the key estimate reads C major, A minor and (transposed) B minor from synthesized chord progressions. Fixtures are deliberately plain: the estimate is known to confuse relative major/minor when a clip leans on the dominant, and one test fixture was rewritten to put its tonic in more chords (the estimator was not tuned to the fixture).
Covers (dsp.test): ten synthetic clicks found at two hop sizes (11.6 ms and 16.7 ms) with no false positives; silence, steady noise and a sustained tone give no onsets; a 20 ms doublet merges under the refractory period; the key estimate reads C major, A minor and (transposed) B minor from synthesized chord progressions. Fixtures are deliberately plain: the estimate is known to confuse relative major/minor when a clip leans on the dominant, and one test fixture was rewritten to put its tonic in more chords (the estimator was not tuned to the fixture).
Covers (onset.test, synthetic drum corpus from `tools/onset-bench.mjs`: kick/snare/hat patterns at 90/120/150 bpm, three levels, bass+noise beds, 30 fps and 512-sample hops): hit rate, false positives and timing floors for the shipped detector, a margin over the frozen legacy detector, level independence, determinism. Synthetic only; the corpus was adjusted twice while building it (coincident kick+snare counted as one onset; voice tails faded so truncation clicks are not onsets), recorded in the commit message.
Frame time: the HUD shows rolling p50/p95 of requestAnimationFrame intervals against the 16.7 ms budget and CPU submit time. `?bench` runs the three scenes on the synthetic feed and prints JSON (`?w=1920&h=1080` fixes the drawing-buffer size, `&sync` reads a pixel back after each frame so the measured frame time is real cost, not capped by vsync, `&ms=` sets seconds per scene). `web/tools/headless.mjs` drives headless Chrome over CDP to collect it. Results of the last run are in `web/BENCH.md`.

View file

@ -6,7 +6,8 @@
"license": "MIT",
"private": true,
"scripts": {
"test": "node --test test/dsp.test.mjs",
"test": "node --test test/dsp.test.mjs test/onset.test.mjs",
"bench:onsets": "node tools/onset-bench.mjs",
"serve": "node tools/serve.mjs"
}
}

View file

@ -14,7 +14,7 @@ export class Analysis {
constructor(sampleRate, binCount) {
this.sampleRate = sampleRate; this.binCount = binCount;
this.lin = new Float32Array(binCount);
this.onsets = new OnsetDetector({ maxBin: Math.min(binCount, Math.round(8000 / (sampleRate / 2 / binCount))) });
this.onsets = new OnsetDetector({ binHz: sampleRate / 2 / binCount });
this.keyEst = new KeyEstimator(sampleRate, binCount);
this.bands = new Float32Array(NB);
this.row = new Uint8Array(NB); // newest row, as texture bytes

View file

@ -50,71 +50,81 @@ export function dbToLinear(db, out) {
}
/**
* Onset detector: log-compressed spectral flux against a max-filtered previous frame
* (the max filter ignores vibrato wobble), a moving-median adaptive threshold, local-peak picking
* and a refractory period. Causal: an onset on frame n-1 is confirmed when frame n arrives.
* Onset detector: band-split spectral flux in decibels. Each band has its own flux (mean positive dB rise
* against a max-filtered previous frame, which ignores vibrato wobble) and its own adaptive threshold
* (median + k * MAD over the last second), so a hat is judged against the hats and a kick against the
* kick, and the level of the track does not matter. Causal: a flux peak on frame n-1 is confirmed when
* frame n arrives. Bands are merged behind one refractory period.
*
* push(mags, t) -> null | { time, strength } (time = the onset frame's own timestamp, seconds)
* push(mags, t) -> null | { time, strength, band } (time = the onset frame's own timestamp, seconds)
*/
export class OnsetDetector {
constructor(opts = {}) {
this.compress = opts.compress ?? 200; // log(1 + compress * mag)
this.window = opts.window ?? 43; // median history, frames
this.delta = opts.delta ?? 0.08; // absolute floor above the median, in flux units
this.ratio = opts.ratio ?? 1.6; // and the flux must exceed ratio * median
this.refractory = opts.refractory ?? 0.06; // seconds
this.minBin = opts.minBin ?? 2;
this.binHz = opts.binHz ?? 48000 / 2048;
this.edgesHz = opts.edgesHz ?? [40, 150, 600, 2500, 12000];
this.maxBin = opts.maxBin ?? 0; // 0 = all bins
this.bands = opts.bands ?? 6; // log-spaced flux bands between minBin and maxBin
this.floorDb = opts.floorDb ?? -110; // magnitudes below this are silence
this.history = opts.history ?? 1.0; // seconds of flux per band for the threshold
this.k = opts.k ?? 8; // threshold = median + k * MAD (MAD scaled to sigma)
this.minRise = opts.minRise ?? [6, 3, 2, 2]; // and at least this many dB mean rise in the band (per band: the few bass bins are noisy)
this.refractory = opts.refractory ?? 0.05; // seconds, all bands
this.edges = null;
this.prev = null;
this.hist = []; // recent flux values
this.f1 = 0; this.f2 = 0; this.t1 = 0; // flux[n-1], flux[n-2], time[n-1]
this.lag = opts.lag ?? 2; // reference = per-bin max over this many previous frames (1 or 2)
this.prev = null; this.prev2 = null;
this.hist = null; // per band: { t[], f[] }
this.f1 = null; this.f2 = null; this.t1 = 0; // per band flux[n-1], flux[n-2], time[n-1]
this.last = -1e9;
this.flux = 0; // latest raw flux, for display
this.flux = 0; // largest band flux this frame, for display
}
push(mags, t) {
const n = this.maxBin || mags.length;
const n = Math.min(this.maxBin || mags.length, mags.length);
if (!this.edges) {
this.edges = Array.from({ length: this.bands + 1 }, (_, i) => Math.round(this.minBin * Math.pow(n / this.minBin, i / this.bands)));
for (let i = 1; i < this.edges.length; i++) this.edges[i] = Math.max(this.edges[i], this.edges[i - 1] + 1);
this.edges = this.edgesHz.map(h => Math.min(n, Math.max(1, Math.round(h / this.binHz))));
for (let i = 1; i < this.edges.length; i++) this.edges[i] = Math.min(n, Math.max(this.edges[i], this.edges[i - 1] + 1));
const nb = this.edges.length - 1;
this.hist = Array.from({ length: nb }, () => ({ t: [], f: [] }));
this.f1 = new Float64Array(nb); this.f2 = new Float64Array(nb);
}
const nb = this.edges.length - 1, lo = Math.pow(10, this.floorDb / 20);
const cur = new Float32Array(n);
for (let k = 0; k < n; k++) cur[k] = Math.log1p(this.compress * mags[k]);
let flux = 0;
for (let k = 0; k < n; k++) cur[k] = 20 * Math.log10(Math.max(mags[k], lo));
const flux = new Float64Array(nb);
if (this.prev) {
const p = this.prev;
// flux per log-spaced band, each divided by sqrt(its width): a kick (a few bass bins) then counts
// as much as a hat (hundreds of treble bins), which one whole-spectrum sum would bury
for (let b = 0; b < this.edges.length - 1; b++) {
let bf = 0;
for (let k = this.edges[b]; k < this.edges[b + 1]; k++) {
const ref = Math.max(p[k - 1] ?? 0, p[k], p[k + 1] ?? 0);
const p = this.prev, q = this.lag > 1 ? this.prev2 : null;
for (let b = 0; b < nb; b++) {
const a = this.edges[b], z = this.edges[b + 1];
let sum = 0;
for (let k = a; k < z; k++) {
let ref = Math.max(p[k - 1] ?? this.floorDb, p[k], p[k + 1] ?? this.floorDb);
if (q) ref = Math.max(ref, q[k - 1] ?? this.floorDb, q[k], q[k + 1] ?? this.floorDb);
const d = cur[k] - ref;
if (d > 0) bf += d;
if (d > 0) sum += d;
}
flux += bf / Math.sqrt(this.edges[b + 1] - this.edges[b]);
flux[b] = sum / (z - a);
}
flux /= this.edges.length - 1;
}
this.prev = cur;
this.flux = flux;
this.prev2 = this.prev; this.prev = cur;
this.flux = Math.max(...flux);
// candidate is the previous frame: f1 must be a local maximum over f2 and the new flux
let out = null;
const cand = this.f1;
if (this.hist.length >= 8 && cand > this.f2 && cand >= flux) {
const sorted = this.hist.slice().sort((a, b) => a - b);
const med = sorted[sorted.length >> 1];
if (cand > med * this.ratio + this.delta && this.t1 - this.last >= this.refractory) {
out = { time: this.t1, strength: Math.min(1, (cand - med) / (cand + 1e-6) * 1.5) };
this.last = this.t1;
// per band: the previous frame is a candidate when it is a local flux maximum over the threshold
let best = null;
for (let b = 0; b < nb; b++) {
const h = this.hist[b], cand = this.f1[b];
if (h.f.length >= 8 && cand > this.f2[b] && cand >= flux[b] && cand >= this.minRise[b]) {
const sorted = h.f.slice().sort((x, y) => x - y), med = sorted[sorted.length >> 1];
const dev = h.f.map(v => Math.abs(v - med)).sort((x, y) => x - y), mad = dev[dev.length >> 1] * 1.4826;
if (cand > med + this.k * mad && (!best || cand > best.flux)) best = { flux: cand, band: b };
}
h.t.push(t); h.f.push(flux[b]);
while (h.t.length > 2 && t - h.t[0] > this.history) { h.t.shift(); h.f.shift(); }
}
this.hist.push(flux);
if (this.hist.length > this.window) this.hist.shift();
this.f2 = this.f1; this.f1 = flux; this.t1 = t;
let out = null;
if (best && this.t1 - this.last >= this.refractory) {
out = { time: this.t1, strength: Math.min(1, best.flux / 15), band: best.band };
this.last = this.t1;
}
this.f2.set(this.f1); this.f1.set(flux); this.t1 = t;
return out;
}
}

49
web/test/onset.test.mjs Normal file
View file

@ -0,0 +1,49 @@
// node --test test/onset.test.mjs Synthetic drum corpus (tools/onset-bench.mjs): floors for the shipped detector.
// Synthetic only: these numbers say nothing about real music and nothing was listened to.
import test from 'node:test';
import assert from 'node:assert/strict';
import { run, corpus, synthesize, spectra, score, detect, RATES } from '../tools/onset-bench.mjs';
import { OnsetDetector } from '../src/dsp.js';
const subset = corpus().filter((_, i) => i % 3 === 0); // 27 of 81 signals, every pattern/tempo/level/bed combination class
test('drum corpus: hit rate, false positives and timing at 30 fps and full rate', () => {
const res = run(['legacy', 'current'], subset);
for (const v of ['legacy', 'current']) for (const [r, a] of Object.entries(res[v]))
console.log(` ${v.padEnd(8)} ${r.padEnd(5)} hit ${(100 * a.hitRate).toFixed(1)}% fp ${a.fp} (${a.fpPerMin.toFixed(1)}/min) median err ${a.medianMs.toFixed(1)} ms`);
for (const [r, a] of Object.entries(res.current)) {
assert.ok(a.hitRate >= 0.85, `${r} hit rate ${a.hitRate}`);
assert.ok(a.fpPerMin <= 2, `${r} false positives/min ${a.fpPerMin}`);
assert.ok(a.medianMs <= 40, `${r} median timing error ${a.medianMs} ms`);
assert.ok(a.hitRate > res.legacy[r].hitRate + 0.2, `${r} must beat the legacy detector by 20 points`);
}
});
test('hit rate does not depend on the level of the track', () => {
const res = run(['current'], subset);
for (const [r, a] of Object.entries(res.current)) {
const rates = Object.values(a.lv).map(p => p.hit / p.n);
assert.ok(Math.max(...rates) - Math.min(...rates) < 0.05, `${r} spread across 0/-20/-40 dB: ${rates}`);
}
});
test('corpus is deterministic', () => {
const c = corpus()[5], a = synthesize(c), b = synthesize(c);
assert.deepEqual(a.truth, b.truth);
assert.ok(a.x.every((v, i) => v === b.x[i]));
});
test('steady bass drone with a noise bed and no drums gives no onsets', () => {
const { x } = synthesize({ pattern: 'rock', bpm: 120, level: 1, bed: 'loud', seed: 9 });
const quiet = x.slice(0, 48000 * 1); // the lead-in before the first drum hit
for (const hop of Object.values(RATES)) {
const found = detect(() => new OnsetDetector({ binHz: 48000 / 2048 }), spectra(quiet, hop)).filter(t => t > 0.6);
assert.equal(found.length, 0, `hop ${hop}: ${found}`);
}
});
test('a single hit is reported once, within the match window', () => {
const { x, truth } = synthesize({ pattern: 'four', bpm: 60, level: 0.1, bed: 'none', seed: 4 });
const s = score(truth, detect(() => new OnsetDetector({ binHz: 48000 / 2048 }), spectra(x, 512)));
assert.ok(s.fp <= 1, `fp ${s.fp}`);
});

169
web/tools/onset-bench.mjs Normal file
View file

@ -0,0 +1,169 @@
// 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)));
}

View file

@ -0,0 +1,65 @@
// Frozen copy of the v2 onset detector as of commit f53a0d1, kept only so tools/onset-bench.mjs can reproduce the 'before' numbers.
// Not imported by the page. Delete when the baseline stops being interesting.
export class LegacyOnsetDetector {
constructor(opts = {}) {
this.compress = opts.compress ?? 200; // log(1 + compress * mag)
this.window = opts.window ?? 43; // median history, frames
this.delta = opts.delta ?? 0.08; // absolute floor above the median, in flux units
this.ratio = opts.ratio ?? 1.6; // and the flux must exceed ratio * median
this.refractory = opts.refractory ?? 0.06; // seconds
this.minBin = opts.minBin ?? 2;
this.maxBin = opts.maxBin ?? 0; // 0 = all bins
this.bands = opts.bands ?? 6; // log-spaced flux bands between minBin and maxBin
this.edges = null;
this.prev = null;
this.hist = []; // recent flux values
this.f1 = 0; this.f2 = 0; this.t1 = 0; // flux[n-1], flux[n-2], time[n-1]
this.last = -1e9;
this.flux = 0; // latest raw flux, for display
}
push(mags, t) {
const n = this.maxBin || mags.length;
if (!this.edges) {
this.edges = Array.from({ length: this.bands + 1 }, (_, i) => Math.round(this.minBin * Math.pow(n / this.minBin, i / this.bands)));
for (let i = 1; i < this.edges.length; i++) this.edges[i] = Math.max(this.edges[i], this.edges[i - 1] + 1);
}
const cur = new Float32Array(n);
for (let k = 0; k < n; k++) cur[k] = Math.log1p(this.compress * mags[k]);
let flux = 0;
if (this.prev) {
const p = this.prev;
// flux per log-spaced band, each divided by sqrt(its width): a kick (a few bass bins) then counts
// as much as a hat (hundreds of treble bins), which one whole-spectrum sum would bury
for (let b = 0; b < this.edges.length - 1; b++) {
let bf = 0;
for (let k = this.edges[b]; k < this.edges[b + 1]; k++) {
const ref = Math.max(p[k - 1] ?? 0, p[k], p[k + 1] ?? 0);
const d = cur[k] - ref;
if (d > 0) bf += d;
}
flux += bf / Math.sqrt(this.edges[b + 1] - this.edges[b]);
}
flux /= this.edges.length - 1;
}
this.prev = cur;
this.flux = flux;
// candidate is the previous frame: f1 must be a local maximum over f2 and the new flux
let out = null;
const cand = this.f1;
if (this.hist.length >= 8 && cand > this.f2 && cand >= flux) {
const sorted = this.hist.slice().sort((a, b) => a - b);
const med = sorted[sorted.length >> 1];
if (cand > med * this.ratio + this.delta && this.t1 - this.last >= this.refractory) {
out = { time: this.t1, strength: Math.min(1, (cand - med) / (cand + 1e-6) * 1.5) };
this.last = this.t1;
}
}
this.hist.push(flux);
if (this.hist.length > this.window) this.hist.shift();
this.f2 = this.f1; this.f1 = flux; this.t1 = t;
return out;
}
}