/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / scripts / test-node.ts
113 lines · 5.1 KBBlameHistoryRaw
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';
13const SR = 48000;
14let failures = 0;
16function check(cond: boolean, msg: string): void {
17 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
18 if (!cond) failures++;
21/** A decaying sine burst with a touch of noise; freq and decay set its character. */
22function burst(x: Float32Array, t0: number, freq: number, decay: number, amp: number, seed: number): void {
23 const s0 = Math.floor(t0 * SR);
24 const n = Math.min(Math.floor(0.3 * SR), x.length - s0);
25 let s = seed;
26 for (let i = 0; i < n; i++) {
27 s = (s * 1103515245 + 12345) & 0x7fffffff;
28 const noise = (s / 0x3fffffff - 1) * 0.1;
29 x[s0 + i] += amp * Math.exp(-i / (decay * SR)) * (Math.sin((2 * Math.PI * freq * i) / SR) + noise);
30 }
33// --- onset detection ---------------------------------------------------------
36 const times = [0.2, 0.55, 0.62, 1.1, 1.4];
37 const x = new Float32Array(Math.floor(2 * SR));
38 for (const [i, t] of times.entries()) burst(x, t, 200 + 300 * (i % 2), 0.03, 0.5, 7 + i);
39 const found = detectOnsets(x, SR, 0.6);
40 check(found.length === times.length, `onsets: found ${found.length} of ${times.length}`);
41 if (found.length === times.length) {
42 const worst = Math.max(...times.map((t, i) => Math.abs(found[i] - t)));
43 check(worst < 0.012, `onsets: worst timing error ${(worst * 1000).toFixed(1)} ms < 12 ms`);
44 }
47// --- quiet hits at low vs high sensitivity -----------------------------------
50 const x = new Float32Array(Math.floor(1.5 * SR));
51 burst(x, 0.2, 250, 0.03, 0.5, 3);
52 burst(x, 0.7, 250, 0.03, 0.04, 4); // 22 dB quieter
53 const insensitive = detectOnsets(x, SR, 0.1);
54 const sensitive = detectOnsets(x, SR, 0.95);
55 check(sensitive.length >= insensitive.length, `sensitivity: ${sensitive.length} hits at 0.95 >= ${insensitive.length} at 0.1`);
56 check(sensitive.length === 2, `sensitivity 0.95 finds the quiet hit (${sensitive.length}/2)`);
59// --- features + classifier ----------------------------------------------------
62 // Two classes: a low, slow thump and a high, fast tick.
63 const mk = (freq: number, decay: number, amp: number, seed: number) => {
64 const x = new Float32Array(Math.floor(0.5 * SR));
65 burst(x, 0.05, freq, decay, amp, seed);
66 return extractHit(x, SR, Math.floor(0.05 * SR), x.length);
67 };
68 const thumps = [0, 1, 2, 3].map((i) => mk(85 + 6 * i, 0.06, 0.4 + 0.1 * i, 11 + i));
69 const ticks = [0, 1, 2, 3].map((i) => mk(3600 + 250 * i, 0.008, 0.3 + 0.1 * i, 31 + i));
71 const train: TrainingSet = {
72 voiceIds: ['thump', 'thump', 'thump', 'tick', 'tick', 'tick'],
73 vecs: [...thumps.slice(0, 3), ...ticks.slice(0, 3)].map((f) => f.vec),
74 };
75 const a = classify(thumps[3].vec, train);
76 const b = classify(ticks[3].vec, train);
77 check(a?.voiceId === 'thump', `classify: held-out thump -> ${a?.voiceId} (conf ${a?.confidence.toFixed(2)})`);
78 check(b?.voiceId === 'tick', `classify: held-out tick -> ${b?.voiceId} (conf ${b?.confidence.toFixed(2)})`);
80 // Loudness must not leak into identity: a soft thump is still a thump.
81 const soft = mk(90, 0.06, 0.02, 55);
82 const c = classify(soft.vec, train);
83 check(c?.voiceId === 'thump', `classify: 26 dB softer thump -> ${c?.voiceId}`);
84 check(soft.peakDb < thumps[0].peakDb - 15, `loudness lives in peakDb (${soft.peakDb.toFixed(1)} vs ${thumps[0].peakDb.toFixed(1)} dB)`);
87// --- gain mapping --------------------------------------------------------------
90 check(Math.abs(gainFromPeakDb(-10, -10) - 1) < 1e-9, 'gain: the loudest hit maps to 1');
91 const g6 = gainFromPeakDb(-16, -10);
92 check(g6 > 0.6 && g6 < 0.8, `gain: -6 dB maps to ${g6.toFixed(2)} (compressed, ordering kept)`);
93 check(gainFromPeakDb(-90, -10) > 0.15, 'gain: very soft hits stay audible');
96// --- mix helpers ---------------------------------------------------------------
99 const tone = new Float32Array(1000);
100 for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 40 * i) / 1000);
101 const down = resampleLinear(tone, 96000, 48000);
102 check(down.length === 500, `resample: 1000 @ 96k -> ${down.length} @ 48k`);
103 let worst = 0;
104 for (let i = 0; i < down.length; i++) worst = Math.max(worst, Math.abs(down[i] - tone[2 * i]));
105 check(worst < 5e-3, `resample: worst deviation ${worst.toExponential(1)} on a low tone`);
106 const a = new Float32Array([1, 2, 3]);
107 mixInto(a, new Float32Array([1, 1, 1, 9]), 0.5);
108 check(a[0] === 1.5 && a[2] === 3.5, 'mixInto: sums with gain, clipped to dst length');
109 check(Math.abs(peak(new Float32Array([0.1, -0.9, 0.4])) - 0.9) < 1e-6, 'peak: absolute maximum');
112console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed');
113process.exit(failures ? 1 : 0);
↑↓ move↵openescclose