/** * Checks for the analysis half, run on synthetic audio so no microphone or * browser is involved: onset detection recovers known hit times, the * feature extractor separates a low thump from a high tick, and the * classifier labels held-out hits correctly. Run with `npm test`. */ import { detectOnsets } from '../src/analysis/onsets.ts'; import { extractHit } from '../src/analysis/features.ts'; import { classify, type TrainingSet } from '../src/analysis/classify.ts'; import { gainFromPeakDb } from '../src/render/types.ts'; const SR = 48000; let failures = 0; function check(cond: boolean, msg: string): void { console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`); if (!cond) failures++; } /** A decaying sine burst with a touch of noise; freq and decay set its character. */ function burst(x: Float32Array, t0: number, freq: number, decay: number, amp: number, seed: number): void { const s0 = Math.floor(t0 * SR); const n = Math.min(Math.floor(0.3 * SR), x.length - s0); let s = seed; for (let i = 0; i < n; i++) { s = (s * 1103515245 + 12345) & 0x7fffffff; const noise = (s / 0x3fffffff - 1) * 0.1; x[s0 + i] += amp * Math.exp(-i / (decay * SR)) * (Math.sin((2 * Math.PI * freq * i) / SR) + noise); } } // --- onset detection --------------------------------------------------------- { const times = [0.2, 0.55, 0.62, 1.1, 1.4]; const x = new Float32Array(Math.floor(2 * SR)); for (const [i, t] of times.entries()) burst(x, t, 200 + 300 * (i % 2), 0.03, 0.5, 7 + i); const found = detectOnsets(x, SR, 0.6); check(found.length === times.length, `onsets: found ${found.length} of ${times.length}`); if (found.length === times.length) { const worst = Math.max(...times.map((t, i) => Math.abs(found[i] - t))); check(worst < 0.012, `onsets: worst timing error ${(worst * 1000).toFixed(1)} ms < 12 ms`); } } // --- quiet hits at low vs high sensitivity ----------------------------------- { const x = new Float32Array(Math.floor(1.5 * SR)); burst(x, 0.2, 250, 0.03, 0.5, 3); burst(x, 0.7, 250, 0.03, 0.04, 4); // 22 dB quieter const insensitive = detectOnsets(x, SR, 0.1); const sensitive = detectOnsets(x, SR, 0.95); check(sensitive.length >= insensitive.length, `sensitivity: ${sensitive.length} hits at 0.95 >= ${insensitive.length} at 0.1`); check(sensitive.length === 2, `sensitivity 0.95 finds the quiet hit (${sensitive.length}/2)`); } // --- features + classifier ---------------------------------------------------- { // Two classes: a low, slow thump and a high, fast tick. const mk = (freq: number, decay: number, amp: number, seed: number) => { const x = new Float32Array(Math.floor(0.5 * SR)); burst(x, 0.05, freq, decay, amp, seed); return extractHit(x, SR, Math.floor(0.05 * SR), x.length); }; const thumps = [0, 1, 2, 3].map((i) => mk(85 + 6 * i, 0.06, 0.4 + 0.1 * i, 11 + i)); const ticks = [0, 1, 2, 3].map((i) => mk(3600 + 250 * i, 0.008, 0.3 + 0.1 * i, 31 + i)); const train: TrainingSet = { voiceIds: ['thump', 'thump', 'thump', 'tick', 'tick', 'tick'], vecs: [...thumps.slice(0, 3), ...ticks.slice(0, 3)].map((f) => f.vec), }; const a = classify(thumps[3].vec, train); const b = classify(ticks[3].vec, train); check(a?.voiceId === 'thump', `classify: held-out thump -> ${a?.voiceId} (conf ${a?.confidence.toFixed(2)})`); check(b?.voiceId === 'tick', `classify: held-out tick -> ${b?.voiceId} (conf ${b?.confidence.toFixed(2)})`); // Loudness must not leak into identity: a soft thump is still a thump. const soft = mk(90, 0.06, 0.02, 55); const c = classify(soft.vec, train); check(c?.voiceId === 'thump', `classify: 26 dB softer thump -> ${c?.voiceId}`); check(soft.peakDb < thumps[0].peakDb - 15, `loudness lives in peakDb (${soft.peakDb.toFixed(1)} vs ${thumps[0].peakDb.toFixed(1)} dB)`); } // --- gain mapping -------------------------------------------------------------- { check(Math.abs(gainFromPeakDb(-10, -10) - 1) < 1e-9, 'gain: the loudest hit maps to 1'); const g6 = gainFromPeakDb(-16, -10); check(g6 > 0.6 && g6 < 0.8, `gain: -6 dB maps to ${g6.toFixed(2)} (compressed, ordering kept)`); check(gainFromPeakDb(-90, -10) > 0.15, 'gain: very soft hits stay audible'); } console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed'); process.exit(failures ? 1 : 0);