/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / scripts / test-node.ts
127 lines · 5.8 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';
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++;
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 }
34// --- onset detection ---------------------------------------------------------
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 }
48// --- quiet hits at low vs high sensitivity -----------------------------------
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)`);
60// --- features + classifier ----------------------------------------------------
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)`);
88// --- gain mapping --------------------------------------------------------------
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');
97// --- mix helpers ---------------------------------------------------------------
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');
113// --- wav encoding ----------------------------------------------------------------
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');
126console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed');
127process.exit(failures ? 1 : 0);
↑↓ move↵openescclose