web: onset detector and key estimator with synthetic-signal tests
Spectral-flux onset detector (max-filtered reference, median threshold, refractory) and peak-based chroma with Krumhansl profile key estimate. Own FFT; no external dependencies. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
609330b22d
commit
da902619de
3 changed files with 299 additions and 0 deletions
12
web/package.json
Normal file
12
web/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "perlines-web",
|
||||
"version": "2.0.0",
|
||||
"description": "Perlines v2: zero-install browser audio visualizer (WebAudio + WebGL2)",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test test/dsp.test.mjs",
|
||||
"serve": "node tools/serve.mjs"
|
||||
}
|
||||
}
|
||||
180
web/src/dsp.js
Normal file
180
web/src/dsp.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// 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: 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.
|
||||
*
|
||||
* push(mags, t) -> null | { time, strength } (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.35; // 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.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;
|
||||
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;
|
||||
for (let k = this.minBin; k < n; k++) {
|
||||
const ref = Math.max(p[k - 1] ?? 0, p[k], p[k + 1] ?? 0);
|
||||
const d = cur[k] - ref;
|
||||
if (d > 0) flux += d;
|
||||
}
|
||||
flux /= Math.sqrt(n);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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' : '') };
|
||||
}
|
||||
}
|
||||
107
web/test/dsp.test.mjs
Normal file
107
web/test/dsp.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// node --test web/test (node >= 20). Synthetic-signal checks of the onset detector and key estimate.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { magnitudeSpectrum, OnsetDetector, KeyEstimator } from '../src/dsp.js';
|
||||
|
||||
const SR = 44100, FFT = 2048;
|
||||
|
||||
function rng(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296) * 2 - 1; }
|
||||
|
||||
function clickTrack(times, seconds, noiseAmp = 0.003) {
|
||||
const x = new Float32Array(Math.ceil(seconds * SR)), r = rng(7);
|
||||
for (let i = 0; i < x.length; i++) x[i] = r() * noiseAmp;
|
||||
for (const t of times) {
|
||||
const s = Math.round(t * SR);
|
||||
for (let i = 0; i < 900 && s + i < x.length; i++) x[s + i] += r() * 0.8 * Math.exp(-i / 180);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
function detect(x, hopSec, opts) {
|
||||
const det = new OnsetDetector(opts), hop = Math.round(hopSec * SR), found = [];
|
||||
const mags = new Float32Array(FFT >> 1);
|
||||
for (let off = 0; off + FFT <= x.length; off += hop) {
|
||||
magnitudeSpectrum(x, off, FFT, mags);
|
||||
// the analyser window ends at "now", so a frame's time is its window end, like AnalyserNode
|
||||
const o = det.push(mags, (off + FFT) / SR);
|
||||
if (o) found.push(o.time);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function score(truth, found, tol) {
|
||||
// an onset in the window ending at time T is visible from T-FFT/SR .. T; allow that window plus tol
|
||||
const used = new Set(); let hit = 0;
|
||||
for (const t of truth) {
|
||||
const i = found.findIndex((f, j) => !used.has(j) && f >= t - tol && f <= t + FFT / SR + tol);
|
||||
if (i >= 0) { used.add(i); hit++; }
|
||||
}
|
||||
return { recall: hit / truth.length, falsePositives: found.length - used.size };
|
||||
}
|
||||
|
||||
const TRUTH = [0.5, 1.0, 1.4, 2.1, 2.6, 3.3, 3.8, 4.6, 5.0, 5.7];
|
||||
|
||||
for (const hop of [512 / SR, 1 / 60]) {
|
||||
test(`clicks found at hop ${(hop * 1000).toFixed(1)} ms`, () => {
|
||||
const x = clickTrack(TRUTH, 6.5);
|
||||
const found = detect(x, hop);
|
||||
const s = score(TRUTH, found, 0.03);
|
||||
console.log(` hop ${(hop * 1000).toFixed(1)}ms: recall ${s.recall}, false positives ${s.falsePositives}, found ${found.length}/${TRUTH.length}`);
|
||||
assert.ok(s.recall >= 0.9, `recall ${s.recall}`);
|
||||
assert.ok(s.falsePositives <= 1, `false positives ${s.falsePositives}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('silence and steady noise give no onsets', () => {
|
||||
assert.equal(detect(new Float32Array(SR * 4), 1 / 60).length, 0);
|
||||
const r = rng(3), x = new Float32Array(SR * 4).map(() => r() * 0.1);
|
||||
assert.equal(detect(x, 1 / 60).length, 0);
|
||||
});
|
||||
|
||||
test('a sustained tone does not retrigger', () => {
|
||||
const x = new Float32Array(SR * 4).map((_, i) => 0.3 * Math.sin(2 * Math.PI * 440 * i / SR));
|
||||
assert.ok(detect(x, 1 / 60).length <= 1); // the very first rise may count once
|
||||
});
|
||||
|
||||
test('refractory period merges a doublet 20 ms apart', () => {
|
||||
const x = clickTrack([0.5, 1.5, 2.5, 3.0, 3.02], 4.5);
|
||||
const found = detect(x, 512 / SR).filter(t => t > 2.9 && t < 3.4);
|
||||
assert.equal(found.length, 1);
|
||||
});
|
||||
|
||||
function chord(midis, seconds) {
|
||||
const x = new Float32Array(Math.ceil(seconds * SR));
|
||||
for (const m of midis) {
|
||||
const f = 440 * 2 ** ((m - 69) / 12);
|
||||
for (let h = 1; h <= 4; h++) for (let i = 0; i < x.length; i++) x[i] += Math.sin(2 * Math.PI * f * h * i / SR) * 0.1 / h;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
function keyOf(x) {
|
||||
const k = new KeyEstimator(SR, FFT >> 1), mags = new Float32Array(FFT >> 1), hop = 735;
|
||||
for (let off = 0; off + FFT <= x.length; off += hop) { magnitudeSpectrum(x, off, FFT, mags); k.push(mags, hop / SR); }
|
||||
return k.key;
|
||||
}
|
||||
|
||||
test('key: C major scale-ish progression reads C major', () => {
|
||||
// C-F-G-C triads, then lingering C major
|
||||
const parts = [[60, 64, 67], [65, 69, 72], [67, 71, 74], [60, 64, 67], [60, 64, 67]];
|
||||
const x = new Float32Array(SR * 15);
|
||||
parts.forEach((p, i) => x.set(chord(p, 3), i * 3 * SR));
|
||||
const k = keyOf(x);
|
||||
assert.equal(k.name, 'C');
|
||||
assert.ok(k.confidence > 0.2, `confidence ${k.confidence}`);
|
||||
});
|
||||
|
||||
test('key: A minor progression reads A minor, and key tracks a transposition', () => {
|
||||
// i - iv - i - V - i - i: the E major chord's G# is the leading tone that separates A minor from C major
|
||||
const am = [[57, 60, 64], [62, 65, 69], [57, 60, 64], [64, 68, 71], [57, 60, 64], [57, 60, 64]];
|
||||
const x = new Float32Array(SR * 18);
|
||||
am.forEach((p, i) => x.set(chord(p, 3), i * 3 * SR));
|
||||
assert.equal(keyOf(x).name, 'Am');
|
||||
const up = am.map(p => p.map(m => m + 2)); // B minor
|
||||
const y = new Float32Array(SR * 18);
|
||||
up.forEach((p, i) => y.set(chord(p, 3), i * 3 * SR));
|
||||
assert.equal(keyOf(y).name, 'Bm');
|
||||
});
|
||||
Loading…
Reference in a new issue