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)`); });