1/**
2 * Checks for the analysis half, run on synthetic audio so no microphone or
3 * browser is involved: onset detection recovers known hit times, the
4 * feature extractor separates a low thump from a high tick, and the
5 * classifier labels held-out hits correctly. Run with `npm test`.
6 */
7import { detectOnsets } from '../src/analysis/onsets.ts';
8import { extractHit } from '../src/analysis/features.ts';
9import { classify, type TrainingSet } from '../src/analysis/classify.ts';
10import { gainFromPeakDb } from '../src/render/types.ts';
12const SR = 48000;
13let failures = 0;
15function check(cond: boolean, msg: string): void {
16 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
17 if (!cond) failures++;
18}
20/** A decaying sine burst with a touch of noise; freq and decay set its character. */
21function burst(x: Float32Array, t0: number, freq: number, decay: number, amp: number, seed: number): void {
22 const s0 = Math.floor(t0 * SR);
23 const n = Math.min(Math.floor(0.3 * SR), x.length - s0);
24 let s = seed;
25 for (let i = 0; i < n; i++) {
26 s = (s * 1103515245 + 12345) & 0x7fffffff;
27 const noise = (s / 0x3fffffff - 1) * 0.1;
28 x[s0 + i] += amp * Math.exp(-i / (decay * SR)) * (Math.sin((2 * Math.PI * freq * i) / SR) + noise);
29 }
30}
32// --- onset detection ---------------------------------------------------------
34{
35 const times = [0.2, 0.55, 0.62, 1.1, 1.4];
36 const x = new Float32Array(Math.floor(2 * SR));
37 for (const [i, t] of times.entries()) burst(x, t, 200 + 300 * (i % 2), 0.03, 0.5, 7 + i);
38 const found = detectOnsets(x, SR, 0.6);
39 check(found.length === times.length, `onsets: found ${found.length} of ${times.length}`);
40 if (found.length === times.length) {
41 const worst = Math.max(...times.map((t, i) => Math.abs(found[i] - t)));
42 check(worst < 0.012, `onsets: worst timing error ${(worst * 1000).toFixed(1)} ms < 12 ms`);
43 }
44}
46// --- quiet hits at low vs high sensitivity -----------------------------------
48{
49 const x = new Float32Array(Math.floor(1.5 * SR));
50 burst(x, 0.2, 250, 0.03, 0.5, 3);
51 burst(x, 0.7, 250, 0.03, 0.04, 4); // 22 dB quieter
52 const insensitive = detectOnsets(x, SR, 0.1);
53 const sensitive = detectOnsets(x, SR, 0.95);
54 check(sensitive.length >= insensitive.length, `sensitivity: ${sensitive.length} hits at 0.95 >= ${insensitive.length} at 0.1`);
55 check(sensitive.length === 2, `sensitivity 0.95 finds the quiet hit (${sensitive.length}/2)`);
56}
58// --- features + classifier ----------------------------------------------------
60{
61 // Two classes: a low, slow thump and a high, fast tick.
62 const mk = (freq: number, decay: number, amp: number, seed: number) => {
63 const x = new Float32Array(Math.floor(0.5 * SR));
64 burst(x, 0.05, freq, decay, amp, seed);
65 return extractHit(x, SR, Math.floor(0.05 * SR), x.length);
66 };
67 const thumps = [0, 1, 2, 3].map((i) => mk(85 + 6 * i, 0.06, 0.4 + 0.1 * i, 11 + i));
68 const ticks = [0, 1, 2, 3].map((i) => mk(3600 + 250 * i, 0.008, 0.3 + 0.1 * i, 31 + i));
70 const train: TrainingSet = {
71 voiceIds: ['thump', 'thump', 'thump', 'tick', 'tick', 'tick'],
72 vecs: [...thumps.slice(0, 3), ...ticks.slice(0, 3)].map((f) => f.vec),
73 };
74 const a = classify(thumps[3].vec, train);
75 const b = classify(ticks[3].vec, train);
76 check(a?.voiceId === 'thump', `classify: held-out thump -> ${a?.voiceId} (conf ${a?.confidence.toFixed(2)})`);
77 check(b?.voiceId === 'tick', `classify: held-out tick -> ${b?.voiceId} (conf ${b?.confidence.toFixed(2)})`);
79 // Loudness must not leak into identity: a soft thump is still a thump.
80 const soft = mk(90, 0.06, 0.02, 55);
81 const c = classify(soft.vec, train);
82 check(c?.voiceId === 'thump', `classify: 26 dB softer thump -> ${c?.voiceId}`);
83 check(soft.peakDb < thumps[0].peakDb - 15, `loudness lives in peakDb (${soft.peakDb.toFixed(1)} vs ${thumps[0].peakDb.toFixed(1)} dB)`);
84}
86// --- gain mapping --------------------------------------------------------------
88{
89 check(Math.abs(gainFromPeakDb(-10, -10) - 1) < 1e-9, 'gain: the loudest hit maps to 1');
90 const g6 = gainFromPeakDb(-16, -10);
91 check(g6 > 0.6 && g6 < 0.8, `gain: -6 dB maps to ${g6.toFixed(2)} (compressed, ordering kept)`);
92 check(gainFromPeakDb(-90, -10) > 0.15, 'gain: very soft hits stay audible');
93}
95console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed');
96process.exit(failures ? 1 : 0);