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>
202 lines
9.3 KiB
JavaScript
202 lines
9.3 KiB
JavaScript
// Perlines v2 analysis: FFT, onset detector, chroma + key estimate.
|
|
// Pure ES module, no DOM, no dependencies: the browser and the node tests load the same file.
|
|
// Written from the published ideas (spectral flux with a max-filtered reference, adaptive
|
|
// median threshold, Krumhansl-Schmuckler key profiles), not from any GPL implementation.
|
|
|
|
/** In-place radix-2 complex FFT. re/im are Float32Array|Float64Array of power-of-two length. */
|
|
export function fft(re, im) {
|
|
const n = re.length;
|
|
for (let i = 1, j = 0; i < n; i++) {
|
|
let bit = n >> 1;
|
|
for (; j & bit; bit >>= 1) j ^= bit;
|
|
j ^= bit;
|
|
if (i < j) {
|
|
let t = re[i]; re[i] = re[j]; re[j] = t;
|
|
t = im[i]; im[i] = im[j]; im[j] = t;
|
|
}
|
|
}
|
|
for (let len = 2; len <= n; len <<= 1) {
|
|
const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang);
|
|
for (let i = 0; i < n; i += len) {
|
|
let cr = 1, ci = 0;
|
|
for (let k = 0; k < len >> 1; k++) {
|
|
const a = i + k, b = a + (len >> 1);
|
|
const xr = re[b] * cr - im[b] * ci, xi = re[b] * ci + im[b] * cr;
|
|
re[b] = re[a] - xr; im[b] = im[a] - xi;
|
|
re[a] += xr; im[a] += xi;
|
|
const t = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = t;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Hann-windowed magnitude spectrum (n/2 bins, scaled so a full-scale sine reads ~0.5). */
|
|
export function magnitudeSpectrum(samples, offset, n, out) {
|
|
const re = new Float64Array(n), im = new Float64Array(n);
|
|
for (let i = 0; i < n; i++) {
|
|
const w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (n - 1));
|
|
re[i] = (samples[offset + i] || 0) * w;
|
|
}
|
|
fft(re, im);
|
|
const m = out || new Float32Array(n >> 1);
|
|
for (let k = 0; k < n >> 1; k++) m[k] = Math.hypot(re[k], im[k]) * 2 / n;
|
|
return m;
|
|
}
|
|
|
|
/** Decibel array (as WebAudio getFloatFrequencyData gives) to linear magnitude, in place into out. */
|
|
export function dbToLinear(db, out) {
|
|
for (let i = 0; i < db.length; i++) out[i] = Math.pow(10, Math.max(db[i], -160) / 20);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 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, band } (time = the onset frame's own timestamp, seconds)
|
|
*/
|
|
export class OnsetDetector {
|
|
constructor(opts = {}) {
|
|
this.binHz = opts.binHz ?? 48000 / 2048;
|
|
this.edgesHz = opts.edgesHz ?? [40, 150, 600, 2500, 12000];
|
|
this.maxBin = opts.maxBin ?? 0; // 0 = all bins
|
|
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.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; // largest band flux this frame, for display
|
|
}
|
|
|
|
push(mags, t) {
|
|
const n = Math.min(this.maxBin || mags.length, mags.length);
|
|
if (!this.edges) {
|
|
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] = 20 * Math.log10(Math.max(mags[k], lo));
|
|
const flux = new Float64Array(nb);
|
|
if (this.prev) {
|
|
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) sum += d;
|
|
}
|
|
flux[b] = sum / (z - a);
|
|
}
|
|
}
|
|
this.prev2 = this.prev; this.prev = cur;
|
|
this.flux = Math.max(...flux);
|
|
|
|
// 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(); }
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
export { NOTE_NAMES };
|
|
// Krumhansl-Kessler probe-tone profiles (published psychology data).
|
|
const MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
|
|
const MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17];
|
|
|
|
function corr(a, b, rot) {
|
|
let ma = 0, mb = 0;
|
|
for (let i = 0; i < 12; i++) { ma += a[(i + rot) % 12]; mb += b[i]; }
|
|
ma /= 12; mb /= 12;
|
|
let num = 0, da = 0, db = 0;
|
|
for (let i = 0; i < 12; i++) {
|
|
const x = a[(i + rot) % 12] - ma, y = b[i] - mb;
|
|
num += x * y; da += x * x; db += y * y;
|
|
}
|
|
return da > 1e-12 && db > 1e-12 ? num / Math.sqrt(da * db) : 0;
|
|
}
|
|
|
|
/**
|
|
* Chroma accumulator + key estimate. Each frame folds spectral peaks between lowHz and highHz into
|
|
* 12 pitch classes, a slow exponential average (halfLife seconds) smooths them, and the key is the
|
|
* best of 24 rotations of the Krumhansl profiles by correlation.
|
|
*/
|
|
export class KeyEstimator {
|
|
constructor(sampleRate, binCount, opts = {}) {
|
|
this.halfLife = opts.halfLife ?? 4;
|
|
this.lowHz = opts.lowHz ?? 80;
|
|
this.highHz = opts.highHz ?? 2000;
|
|
this.chroma = new Float32Array(12);
|
|
this.key = { tonic: 0, mode: 'major', confidence: 0, name: 'C' };
|
|
this.hzPerBin = sampleRate / 2 / binCount;
|
|
this.lo = Math.max(2, Math.floor(this.lowHz / this.hzPerBin));
|
|
this.hi = Math.min(binCount - 2, Math.ceil(this.highHz / this.hzPerBin));
|
|
}
|
|
|
|
// Peak-based: bins are wider than a semitone below ~250 Hz, so each spectral peak is refined by
|
|
// parabolic interpolation and its energy goes to the pitch class of the refined frequency.
|
|
push(mags, dt) {
|
|
const frame = new Float32Array(12);
|
|
let top = 0;
|
|
for (let k = this.lo; k <= this.hi; k++) if (mags[k] > top) top = mags[k];
|
|
const floor = top * 0.05;
|
|
for (let k = this.lo; k <= this.hi; k++) {
|
|
const m = mags[k];
|
|
if (m < floor || m < 1e-5 || m < mags[k - 1] || m <= mags[k + 1]) continue;
|
|
const a = Math.log(mags[k - 1] + 1e-9), b = Math.log(m + 1e-9), c = Math.log(mags[k + 1] + 1e-9);
|
|
const d = 0.5 * (a - c) / (a - 2 * b + c);
|
|
const midi = 69 + 12 * Math.log2((k + d) * this.hzPerBin / 440);
|
|
const near = Math.round(midi);
|
|
frame[((near % 12) + 12) % 12] += Math.sqrt(m) * (1 - Math.min(0.5, Math.abs(midi - near))); // sqrt tames loud-bass dominance
|
|
}
|
|
const a = Math.pow(0.5, dt / this.halfLife);
|
|
for (let i = 0; i < 12; i++) this.chroma[i] = this.chroma[i] * a + frame[i] * (1 - a);
|
|
this.key = this.estimate();
|
|
return this.key;
|
|
}
|
|
|
|
estimate() {
|
|
let best = { tonic: 0, mode: 'major', score: -2 }, second = -2;
|
|
for (let r = 0; r < 12; r++) {
|
|
for (const [mode, prof] of [['major', MAJOR], ['minor', MINOR]]) {
|
|
const s = corr(this.chroma, prof, r);
|
|
if (s > best.score) { second = best.score; best = { tonic: r, mode, score: s }; }
|
|
else if (s > second) second = s;
|
|
}
|
|
}
|
|
const energy = this.chroma.reduce((x, y) => x + y, 0);
|
|
const confidence = energy < 1e-4 ? 0 : Math.max(0, Math.min(1, (best.score - second) * 4 + best.score * 0.3));
|
|
return { tonic: best.tonic, mode: best.mode, confidence, name: NOTE_NAMES[best.tonic] + (best.mode === 'minor' ? 'm' : '') };
|
|
}
|
|
}
|