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>
195 lines
8.2 KiB
JavaScript
195 lines
8.2 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { FFT, hannWindow } from '../src/audio/fft.js';
|
|
import { analyzeBuffer, RAW_FEATURES } from '../src/audio/analyze.js';
|
|
import { detectTempo, buildBeatTracks } from '../src/audio/tempo.js';
|
|
import { segment } from '../src/audio/segment.js';
|
|
import { synthesizeBeat, synthesizeSectioned, synthesizeSilence } from '../src/audio/synth.js';
|
|
|
|
const FPS = 60;
|
|
|
|
test('FFT resolves a pure tone into the right bin', () => {
|
|
const size = 2048;
|
|
const sampleRate = 44100;
|
|
const fft = new FFT(size);
|
|
const window = hannWindow(size);
|
|
const freq = 1000;
|
|
|
|
const input = new Float32Array(size);
|
|
for (let i = 0; i < size; i++) {
|
|
input[i] = Math.sin((2 * Math.PI * freq * i) / sampleRate) * window[i];
|
|
}
|
|
const mag = fft.forward(input);
|
|
|
|
let peakBin = 0;
|
|
for (let i = 1; i < mag.length; i++) if (mag[i] > mag[peakBin]) peakBin = i;
|
|
const peakHz = (peakBin * sampleRate) / size;
|
|
assert.ok(Math.abs(peakHz - freq) < sampleRate / size,
|
|
`peak at ${peakHz.toFixed(1)}Hz, expected ~${freq}Hz`);
|
|
});
|
|
|
|
test('analysis produces finite, in-range features', () => {
|
|
const buffer = synthesizeBeat({ bpm: 128, duration: 20 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
|
|
assert.equal(analysis.frameCount, Math.ceil(buffer.duration * FPS));
|
|
|
|
for (const name of RAW_FEATURES) {
|
|
const arr = analysis.raw[name];
|
|
assert.ok(arr, `missing feature ${name}`);
|
|
for (let i = 0; i < arr.length; i++) {
|
|
assert.ok(Number.isFinite(arr[i]), `${name}[${i}] is not finite`);
|
|
assert.ok(arr[i] >= 0 && arr[i] <= 1, `${name}[${i}] = ${arr[i]} out of range`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('no feature is constant or pinned on real-ish material', () => {
|
|
// A feature stuck at 0 or 1 contributes nothing to the visuals and usually
|
|
// means a wrong band split — the thing the feature scope exists to catch.
|
|
const buffer = synthesizeBeat({ bpm: 124, duration: 30 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
|
|
for (const name of ['loudness', 'bandSub', 'bandLow', 'bandMid', 'flux', 'centroid']) {
|
|
const arr = analysis.raw[name];
|
|
let min = Infinity, max = -Infinity, sum = 0;
|
|
for (let i = 0; i < arr.length; i++) {
|
|
min = Math.min(min, arr[i]);
|
|
max = Math.max(max, arr[i]);
|
|
sum += arr[i];
|
|
}
|
|
const spread = max - min;
|
|
assert.ok(spread > 0.15, `${name} spread only ${spread.toFixed(3)} — feature is inert`);
|
|
const mean = sum / arr.length;
|
|
assert.ok(mean > 0.005 && mean < 0.995, `${name} mean ${mean.toFixed(3)} is pinned`);
|
|
}
|
|
});
|
|
|
|
test('tempo detection lands on the true BPM', () => {
|
|
for (const bpm of [90, 110, 124, 128, 140, 174]) {
|
|
const buffer = synthesizeBeat({ bpm, duration: 40 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
|
|
const error = Math.abs(tempo.bpm - bpm) / bpm;
|
|
assert.ok(error < 0.02,
|
|
`bpm ${tempo.bpm.toFixed(2)} vs true ${bpm} (${(error * 100).toFixed(2)}% off)`);
|
|
}
|
|
});
|
|
|
|
test('beat grid aligns with the true beat positions', () => {
|
|
const bpm = 128;
|
|
const buffer = synthesizeBeat({ bpm, duration: 40 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
|
|
const trueBeat = 60 / bpm;
|
|
let worst = 0;
|
|
for (const t of tempo.beats) {
|
|
if (t > 30) break;
|
|
const nearest = Math.round(t / trueBeat) * trueBeat;
|
|
worst = Math.max(worst, Math.abs(t - nearest));
|
|
}
|
|
// Within half a video frame: any more would be visible as the visuals
|
|
// sitting off the beat.
|
|
assert.ok(worst < 0.5 / FPS, `worst beat offset ${(worst * 1000).toFixed(1)}ms`);
|
|
});
|
|
|
|
test('downbeats land on the accented beat', () => {
|
|
const bpm = 128;
|
|
const buffer = synthesizeBeat({ bpm, duration: 40 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
|
|
const trueBar = (60 / bpm) * 4;
|
|
let aligned = 0;
|
|
for (const t of tempo.downbeats) {
|
|
const offset = Math.abs(t / trueBar - Math.round(t / trueBar));
|
|
if (offset < 0.05) aligned++;
|
|
}
|
|
assert.ok(aligned / tempo.downbeats.length > 0.9,
|
|
`only ${aligned}/${tempo.downbeats.length} downbeats on the bar line`);
|
|
});
|
|
|
|
test('beat tracks are bounded and phase-continuous', () => {
|
|
const buffer = synthesizeBeat({ bpm: 128, duration: 20 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
const tracks = buildBeatTracks(tempo, analysis.frameCount, FPS, analysis.raw.loudness);
|
|
|
|
for (const name of ['beat', 'beatPhase', 'barPhase', 'phrasePhase']) {
|
|
const arr = tracks[name];
|
|
for (let i = 0; i < arr.length; i++) {
|
|
assert.ok(Number.isFinite(arr[i]) && arr[i] >= 0 && arr[i] <= 1,
|
|
`${name}[${i}] = ${arr[i]}`);
|
|
}
|
|
}
|
|
// Phase must wrap exactly once per beat, never jump mid-beat.
|
|
let wraps = 0;
|
|
for (let i = 1; i < tracks.beatPhase.length; i++) {
|
|
if (tracks.beatPhase[i] < tracks.beatPhase[i - 1]) wraps++;
|
|
}
|
|
const expected = 20 / (60 / 128);
|
|
assert.ok(Math.abs(wraps - expected) <= 2, `${wraps} phase wraps, expected ~${expected.toFixed(0)}`);
|
|
});
|
|
|
|
test('segmentation finds a real structural boundary', () => {
|
|
const changeAt = 60;
|
|
const buffer = synthesizeSectioned({ bpm: 128, duration: 120, changeAt });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);
|
|
|
|
assert.ok(sections.length >= 2, `expected at least 2 sections, got ${sections.length}`);
|
|
|
|
const boundaries = sections.slice(1).map((s) => s.start);
|
|
const nearest = boundaries.reduce(
|
|
(best, b) => (Math.abs(b - changeAt) < Math.abs(best - changeAt) ? b : best),
|
|
Infinity,
|
|
);
|
|
assert.ok(Math.abs(nearest - changeAt) <= 2.0,
|
|
`nearest boundary ${nearest.toFixed(2)}s, true change at ${changeAt}s`);
|
|
});
|
|
|
|
test('sections tile the track with no gaps or overlaps', () => {
|
|
const buffer = synthesizeSectioned({ duration: 120, changeAt: 60 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);
|
|
|
|
assert.equal(sections[0].start, 0);
|
|
for (let i = 1; i < sections.length; i++) {
|
|
assert.equal(sections[i].start, sections[i - 1].end, `gap before section ${i}`);
|
|
}
|
|
assert.ok(Math.abs(sections[sections.length - 1].end - analysis.duration) < 0.05);
|
|
for (const s of sections) {
|
|
assert.ok(s.duration > 0, `section ${s.index} has non-positive duration`);
|
|
assert.ok(['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].includes(s.kind),
|
|
`bad kind ${s.kind}`);
|
|
}
|
|
});
|
|
|
|
test('silence degrades gracefully instead of producing NaN', () => {
|
|
const buffer = synthesizeSilence({ duration: 10 });
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
for (const name of RAW_FEATURES) {
|
|
for (const v of analysis.raw[name]) assert.ok(Number.isFinite(v), `${name} not finite on silence`);
|
|
}
|
|
const tempo = detectTempo(analysis.onsetEnvelope, FPS);
|
|
assert.ok(Number.isFinite(tempo.bpm), 'bpm not finite on silence');
|
|
const sections = segment(analysis.raw, analysis.frameCount, FPS, tempo);
|
|
assert.ok(sections.length >= 1, 'no sections produced for silence');
|
|
});
|
|
|
|
test('analysis of a six-minute track completes within budget', () => {
|
|
const buffer = synthesizeBeat({ bpm: 128, duration: 360, hats: true, pad: true });
|
|
const started = Date.now();
|
|
const analysis = analyzeBuffer(buffer, { fps: FPS });
|
|
detectTempo(analysis.onsetEnvelope, FPS);
|
|
const elapsed = (Date.now() - started) / 1000;
|
|
// The plan's budget is ~3s. Node is a fair proxy for browser JS here.
|
|
assert.ok(elapsed < 6, `analysis took ${elapsed.toFixed(2)}s`);
|
|
console.log(` (6-minute analysis: ${elapsed.toFixed(2)}s)`);
|
|
});
|