Perlines/web/src/analysis.js
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

88 lines
4.4 KiB
JavaScript

// Turns a frame of dB spectrum into the shared state every scene reads: log-spaced smoothed bands,
// band levels, onset events, chroma, key and the tint colour derived from the key.
import { dbToLinear, OnsetDetector, KeyEstimator } from './dsp.js';
export const NB = 128; // log-spaced display bands
export const HIST = 128; // history rows (spectrum texture height)
function hslToRgb(h, s, l) {
const f = n => { const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l); return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
return [f(0), f(8), f(4)];
}
export class Analysis {
constructor(sampleRate, binCount) {
this.sampleRate = sampleRate; this.binCount = binCount;
this.lin = new Float32Array(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
this.bass = 0; this.mid = 0; this.treble = 0; this.level = 0;
this.onset = { count: 0, strength: 0, time: -1 }; // count increments per onset; scenes diff it
this.flux = 0;
this.key = this.keyEst.key;
this.hue = 0.6; this.tint = hslToRgb(0.6, 0.7, 0.55);
this.time = 0; this.dt = 1 / 60;
// band -> bin edges, log spaced 40 Hz .. 16 kHz
const hz = sampleRate / 2 / binCount, lo = 40, hi = Math.min(16000, sampleRate / 2 - hz);
this.edges = Array.from({ length: NB + 1 }, (_, i) => Math.min(binCount - 1, Math.round(lo * Math.pow(hi / lo, i / NB) / hz)));
}
/** db: Float32Array in decibels (AnalyserNode.getFloatFrequencyData); t: seconds. */
update(db, t) {
const dt = Math.min(0.1, Math.max(1e-3, t - this.time || 1 / 60));
this.time = t; this.dt = dt;
const lin = dbToLinear(db, this.lin);
const o = this.onsets.push(lin, t);
this.flux = this.onsets.flux;
if (o) { this.onset.count++; this.onset.strength = o.strength; this.onset.time = t; }
// bands: peak of dB inside each band, mapped -90..-25 dB to 0..1, fast attack / slow release
let b = 0, m = 0, tr = 0, nb = 0, nm = 0, nt = 0;
for (let i = 0; i < NB; i++) {
const a = this.edges[i], z = Math.max(a + 1, this.edges[i + 1]);
let pk = -160;
for (let k = a; k < z; k++) if (db[k] > pk) pk = db[k];
const v = Math.max(0, Math.min(1, (pk + 90) / 65));
const cur = this.bands[i];
this.bands[i] = v > cur ? cur + (v - cur) * 0.8 : cur + (v - cur) * (1 - Math.pow(0.02, dt));
this.row[i] = Math.round(this.bands[i] * 255);
if (i < NB * 0.2) { b += this.bands[i]; nb++; } else if (i < NB * 0.6) { m += this.bands[i]; nm++; } else { tr += this.bands[i]; nt++; }
}
this.bass = b / nb; this.mid = m / nm; this.treble = tr / nt;
this.level = (this.bass + this.mid + this.treble) / 3;
const key = this.keyEst.push(lin, dt);
this.key = key;
// tint: hue walks the circle of fifths so related keys sit near each other; confidence gates the move
const target = ((key.tonic * 7) % 12) / 12;
let d = target - this.hue; d -= Math.round(d);
this.hue = (this.hue + d * Math.min(1, dt * (0.15 + key.confidence * 0.8)) + 1) % 1;
const major = key.mode === 'major';
this.tint = hslToRgb(this.hue, major ? 0.8 : 0.6, major ? 0.58 : 0.5);
return this;
}
}
/** Synthetic spectrum for demo mode and benchmarks: a 120 bpm kick/hat pattern over an A-minor pad. */
export class DemoFeed {
constructor(sampleRate, binCount) { this.sr = sampleRate; this.n = binCount; this.db = new Float32Array(binCount); }
frame(t) {
const { db, n, sr } = this, hz = sr / 2 / n, beat = 0.5;
const ph = t % beat, kick = Math.exp(-ph * 9), hat = Math.exp(-((t + beat / 2) % beat) * 40) * ((Math.floor(t / (beat / 2)) % 2) ? 1 : 0.4);
const snare = Math.floor(t / beat) % 2 ? Math.exp(-ph * 14) : 0;
const chord = [220, 261.63, 329.63, 440, 523.25, 659.25];
for (let k = 0; k < n; k++) {
const f = k * hz + 1;
let v = -105 + 12 * Math.sin(k * 12.9898 + t * 3) * 0.3;
v += 78 * kick * Math.exp(-Math.pow(Math.log2(f / 55), 2) * 1.5);
v += 62 * snare * Math.exp(-Math.pow(Math.log2(f / 1800), 2) * 0.35);
v += 55 * hat * Math.exp(-Math.pow(Math.log2(f / 9000), 2) * 0.5);
for (const c of chord) v += 55 * Math.exp(-Math.pow((f - c) / (hz * 0.8 + c * 0.01), 2));
db[k] = v;
}
return db;
}
}