Whole-track analysis into a frame-indexed FeatureTrack. Nothing reads a live AnalyserNode: realtime preview maps currentTime to a frame index, export counts frames, both read the same rows. - fft.js: radix-2 with precomputed tables, allocated once per track - analyze.js: STFT at hop 1/60s with CENTRED windows (a window that starts at the timestamp reports energy arriving up to 23ms later, which reads as visuals lagging the music). Energy features normalised against the track's own percentiles; absolute stats kept in summary for the look generator. - tempo.js: autocorrelation + grid F-measure, beat grid, downbeats - segment.js: self-similarity novelty, boundaries snapped to the bar grid - FeatureTrack: assembles everything, plus the lookahead fields. buildSlope rises through the bars leading into a higher-energy section, so a build can ramp into the drop rather than react after it lands. - clicktrack.js: mixes clicks onto the detected grid for validation by ear Three real bugs found and fixed by the tests: - 174 BPM read as 87. Mean-energy-per-beat scores a half-tempo grid identically to the true one; only an F-measure penalises the missed onsets via recall. - 90 BPM read as 180. Offbeat hi-hats make a double-tempo grid score perfectly on both precision and recall, so the grid is now interpreted metrically afterwards: a systematic strong/weak alternation means the real beat is every other grid point. - Beat grid drifted ~30ms over 30s from integer-frame offsets. Onset peaks are now parabolically interpolated and the grid least-squares fitted. Gates: 11/11 node tests against synthetic ground truth (tempo within 2% across 90-174 BPM, beat alignment under half a frame, segmentation within 2s of a known boundary, graceful on silence, 6-minute analysis in 1.0s); 5/5 browser checks including audio-driven vs fixed-step frame parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
2.8 KiB
JavaScript
80 lines
2.8 KiB
JavaScript
// Iterative radix-2 Cooley-Tukey FFT with precomputed twiddles and bit-reversal.
|
|
//
|
|
// Allocated once per size and reused across all ~21k frames of a track — the
|
|
// analysis pass is the one place in this project where JS throughput actually
|
|
// matters, and per-frame allocation is what would sink it.
|
|
|
|
export class FFT {
|
|
constructor(size) {
|
|
if ((size & (size - 1)) !== 0) throw new Error(`FFT size must be a power of two, got ${size}`);
|
|
this.size = size;
|
|
this.half = size >> 1;
|
|
|
|
this.re = new Float32Array(size);
|
|
this.im = new Float32Array(size);
|
|
this.magnitude = new Float32Array(this.half);
|
|
|
|
// Bit-reversal permutation table.
|
|
this.rev = new Uint32Array(size);
|
|
const bits = Math.log2(size);
|
|
for (let i = 0; i < size; i++) {
|
|
let r = 0;
|
|
for (let b = 0; b < bits; b++) if (i & (1 << b)) r |= 1 << (bits - 1 - b);
|
|
this.rev[i] = r;
|
|
}
|
|
|
|
// Twiddle factors, flattened per stage.
|
|
this.cos = new Float32Array(this.half);
|
|
this.sin = new Float32Array(this.half);
|
|
for (let i = 0; i < this.half; i++) {
|
|
this.cos[i] = Math.cos((-2 * Math.PI * i) / size);
|
|
this.sin[i] = Math.sin((-2 * Math.PI * i) / size);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward transform of a real windowed signal. Writes into `this.magnitude`
|
|
* (first size/2 bins) and returns it. The buffer is reused: copy it if you
|
|
* need to keep it.
|
|
*/
|
|
forward(input) {
|
|
const { size, re, im, rev, cos, sin } = this;
|
|
|
|
for (let i = 0; i < size; i++) {
|
|
re[i] = input[rev[i]];
|
|
im[i] = 0;
|
|
}
|
|
|
|
for (let len = 2; len <= size; len <<= 1) {
|
|
const halfLen = len >> 1;
|
|
const step = size / len;
|
|
for (let i = 0; i < size; i += len) {
|
|
for (let j = 0, k = 0; j < halfLen; j++, k += step) {
|
|
const c = cos[k], s = sin[k];
|
|
const a = i + j, b = a + halfLen;
|
|
const tre = re[b] * c - im[b] * s;
|
|
const tim = re[b] * s + im[b] * c;
|
|
re[b] = re[a] - tre;
|
|
im[b] = im[a] - tim;
|
|
re[a] += tre;
|
|
im[a] += tim;
|
|
}
|
|
}
|
|
}
|
|
|
|
const mag = this.magnitude;
|
|
const scale = 2 / size;
|
|
for (let i = 0; i < this.half; i++) {
|
|
mag[i] = Math.sqrt(re[i] * re[i] + im[i] * im[i]) * scale;
|
|
}
|
|
return mag;
|
|
}
|
|
}
|
|
|
|
/** Periodic Hann window. Periodic (not symmetric) is correct for STFT analysis. */
|
|
export function hannWindow(size) {
|
|
const w = new Float32Array(size);
|
|
for (let i = 0; i < size; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / size));
|
|
return w;
|
|
}
|