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';
11import { mixInto, peak, resampleLinear } from '../src/render/mix.ts';
12import { wavBytes } from '../src/render/wav.ts';
14const SR = 48000;
15let failures = 0;
17function check(cond: boolean, msg: string): void {
18 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
19 if (!cond) failures++;
20}
22/** A decaying sine burst with a touch of noise; freq and decay set its character. */
23function burst(x: Float32Array, t0: number, freq: number, decay: number, amp: number, seed: number): void {
24 const s0 = Math.floor(t0 * SR);
25 const n = Math.min(Math.floor(0.3 * SR), x.length - s0);
26 let s = seed;
27 for (let i = 0; i < n; i++) {
28 s = (s * 1103515245 + 12345) & 0x7fffffff;
29 const noise = (s / 0x3fffffff - 1) * 0.1;
30 x[s0 + i] += amp * Math.exp(-i / (decay * SR)) * (Math.sin((2 * Math.PI * freq * i) / SR) + noise);
31 }
32}
34// --- onset detection ---------------------------------------------------------
36{
37 const times = [0.2, 0.55, 0.62, 1.1, 1.4];
38 const x = new Float32Array(Math.floor(2 * SR));
39 for (const [i, t] of times.entries()) burst(x, t, 200 + 300 * (i % 2), 0.03, 0.5, 7 + i);
40 const found = detectOnsets(x, SR, 0.6);
41 check(found.length === times.length, `onsets: found ${found.length} of ${times.length}`);
42 if (found.length === times.length) {
43 const worst = Math.max(...times.map((t, i) => Math.abs(found[i] - t)));
44 check(worst < 0.012, `onsets: worst timing error ${(worst * 1000).toFixed(1)} ms < 12 ms`);
45 }
46}
48// --- quiet hits at low vs high sensitivity -----------------------------------
50{
51 const x = new Float32Array(Math.floor(1.5 * SR));
52 burst(x, 0.2, 250, 0.03, 0.5, 3);
53 burst(x, 0.7, 250, 0.03, 0.04, 4); // 22 dB quieter
54 const insensitive = detectOnsets(x, SR, 0.1);
55 const sensitive = detectOnsets(x, SR, 0.95);
56 check(sensitive.length >= insensitive.length, `sensitivity: ${sensitive.length} hits at 0.95 >= ${insensitive.length} at 0.1`);
57 check(sensitive.length === 2, `sensitivity 0.95 finds the quiet hit (${sensitive.length}/2)`);
58}
60// --- features + classifier ----------------------------------------------------
62{
63 // Two classes: a low, slow thump and a high, fast tick.
64 const mk = (freq: number, decay: number, amp: number, seed: number) => {
65 const x = new Float32Array(Math.floor(0.5 * SR));
66 burst(x, 0.05, freq, decay, amp, seed);
67 return extractHit(x, SR, Math.floor(0.05 * SR), x.length);
68 };
69 const thumps = [0, 1, 2, 3].map((i) => mk(85 + 6 * i, 0.06, 0.4 + 0.1 * i, 11 + i));
70 const ticks = [0, 1, 2, 3].map((i) => mk(3600 + 250 * i, 0.008, 0.3 + 0.1 * i, 31 + i));
72 const train: TrainingSet = {
73 voiceIds: ['thump', 'thump', 'thump', 'tick', 'tick', 'tick'],
74 vecs: [...thumps.slice(0, 3), ...ticks.slice(0, 3)].map((f) => f.vec),
75 };
76 const a = classify(thumps[3].vec, train);
77 const b = classify(ticks[3].vec, train);
78 check(a?.voiceId === 'thump', `classify: held-out thump -> ${a?.voiceId} (conf ${a?.confidence.toFixed(2)})`);
79 check(b?.voiceId === 'tick', `classify: held-out tick -> ${b?.voiceId} (conf ${b?.confidence.toFixed(2)})`);
81 // Loudness must not leak into identity: a soft thump is still a thump.
82 const soft = mk(90, 0.06, 0.02, 55);
83 const c = classify(soft.vec, train);
84 check(c?.voiceId === 'thump', `classify: 26 dB softer thump -> ${c?.voiceId}`);
85 check(soft.peakDb < thumps[0].peakDb - 15, `loudness lives in peakDb (${soft.peakDb.toFixed(1)} vs ${thumps[0].peakDb.toFixed(1)} dB)`);
86}
88// --- gain mapping --------------------------------------------------------------
90{
91 check(Math.abs(gainFromPeakDb(-10, -10) - 1) < 1e-9, 'gain: the loudest hit maps to 1');
92 const g6 = gainFromPeakDb(-16, -10);
93 check(g6 > 0.6 && g6 < 0.8, `gain: -6 dB maps to ${g6.toFixed(2)} (compressed, ordering kept)`);
94 check(gainFromPeakDb(-90, -10) > 0.15, 'gain: very soft hits stay audible');
95}
97// --- mix helpers ---------------------------------------------------------------
99{
100 const tone = new Float32Array(1000);
101 for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 40 * i) / 1000);
102 const down = resampleLinear(tone, 96000, 48000);
103 check(down.length === 500, `resample: 1000 @ 96k -> ${down.length} @ 48k`);
104 let worst = 0;
105 for (let i = 0; i < down.length; i++) worst = Math.max(worst, Math.abs(down[i] - tone[2 * i]));
106 check(worst < 5e-3, `resample: worst deviation ${worst.toExponential(1)} on a low tone`);
107 const a = new Float32Array([1, 2, 3]);
108 mixInto(a, new Float32Array([1, 1, 1, 9]), 0.5);
109 check(a[0] === 1.5 && a[2] === 3.5, 'mixInto: sums with gain, clipped to dst length');
110 check(Math.abs(peak(new Float32Array([0.1, -0.9, 0.4])) - 0.9) < 1e-6, 'peak: absolute maximum');
111}
113// --- wav encoding ----------------------------------------------------------------
115{
116 const x = new Float32Array([0, 0.5, -0.5, 2]); // the last clips
117 const bytes = wavBytes(x, 44100);
118 const v = new DataView(bytes.buffer);
119 check(bytes.length === 44 + 8, `wav: ${bytes.length} bytes for 4 samples`);
120 check(String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) === 'RIFF', 'wav: RIFF magic');
121 check(v.getUint32(24, true) === 44100, 'wav: sample rate field');
122 check(v.getInt16(46, true) === Math.round(0.5 * 32767), 'wav: 0.5 encodes to half scale');
123 check(v.getInt16(50, true) === 32767, 'wav: over-range clips to full scale');
124}
126// --- numbl classifier path ---------------------------------------------------------
127// The default silence_gap_knn (with its noise-floor threshold adaptation)
128// run over the same pipeline the app uses: training snippets cut from
129// voice clips at 16 kHz, the take reduced to 16 kHz, detections mapped
130// back to voices. The take carries a realistic microphone noise floor,
131// which the original benchmark algorithm's silence assumption would not
132// survive; this is exactly what the adaptation is for.
134const { buildVoiceTraining } = await import('../src/numbl/snippets.ts');
135const { runAlgorithm } = await import('../src/numbl/execute.ts');
136const { downsampleBox } = await import('../src/numbl/dsp.ts');
137const { DEFAULT_CLASSIFIER_SOURCE } = await import('../src/numbl/index.ts');
138const { CLF_FS } = await import('../src/numbl/types.ts');
140{
141 const noiseInto = (x: Float32Array, amp: number, seed: number) => {
142 let s = seed;
143 for (let i = 0; i < x.length; i++) {
144 s = (s * 1103515245 + 12345) & 0x7fffffff;
145 x[i] += (s / 0x3fffffff - 1) * amp;
146 }
147 };
149 // one training clip per voice: 8 well-separated bursts at known times
150 const clipAudio = new Map<string, { samples: Float32Array; sr: number }>();
151 const mkVoice = (id: string, freq: number, decay: number, seed: number) => {
152 const times = Array.from({ length: 8 }, (_, i) => 0.3 + i * 0.35);
153 const samples = new Float32Array(Math.floor(3.2 * SR));
154 noiseInto(samples, 5e-4, seed);
155 times.forEach((t, i) => burst(samples, t, freq, decay, 0.5, seed + i));
156 clipAudio.set(`clip-${id}`, { samples, sr: SR });
157 return {
158 id, name: id, color: '#888',
159 binding: { kind: 'synth', preset: 'kick' } as const,
160 volume: 1,
161 clips: [{ id: `clip-${id}`, sr: SR, sens: 0.6, onsets: times, excluded: [], hits: [] }],
162 loose: [],
163 };
164 };
165 const testVoices = [mkVoice('thump', 200, 0.05, 11), mkVoice('tick', 1500, 0.015, 77)];
167 const { training, voiceIds, skipped } = await buildVoiceTraining(
168 testVoices,
169 (id) => Promise.resolve(clipAudio.get(id) ?? null),
170 );
171 check(voiceIds.length === 2 && skipped.length === 0,
172 `numbl training: 2 voices, ${training.snips.map((s) => s.count).join('+')} snippets`);
174 // a take with a mic-like noise floor and 6 alternating hits
175 const takeTimes = [0.4, 0.8, 1.2, 1.6, 2.0, 2.4];
176 const takeSamples = new Float32Array(Math.floor(3 * SR));
177 noiseInto(takeSamples, 5e-4, 999);
178 takeTimes.forEach((t, i) => burst(takeSamples, t, i % 2 === 0 ? 200 : 1500, i % 2 === 0 ? 0.05 : 0.015, 0.4, 300 + i));
180 const x16 = downsampleBox(takeSamples, SR, CLF_FS);
181 const { detections } = runAlgorithm(DEFAULT_CLASSIFIER_SOURCE, x16, CLF_FS, training);
182 check(detections.length === takeTimes.length,
183 `numbl classify: ${detections.length} of ${takeTimes.length} hits found on a noisy take`);
184 let timesOk = true, labelsOk = true;
185 detections.forEach((d, i) => {
186 if (i >= takeTimes.length) return;
187 if (Math.abs(d.t - takeTimes[i]) > 0.03) timesOk = false;
188 if (d.typeIndex !== i % 2) labelsOk = false;
189 });
190 check(timesOk, 'numbl classify: every hit within 30 ms of the truth');
191 check(labelsOk, 'numbl classify: thump/tick labels all correct');
192}
194console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed');
195process.exit(failures ? 1 : 0);