/** * 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'; import { mixInto, peak, resampleLinear } from '../src/render/mix.ts'; import { wavBytes } from '../src/render/wav.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'); } // --- mix helpers --------------------------------------------------------------- { const tone = new Float32Array(1000); for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 40 * i) / 1000); const down = resampleLinear(tone, 96000, 48000); check(down.length === 500, `resample: 1000 @ 96k -> ${down.length} @ 48k`); let worst = 0; for (let i = 0; i < down.length; i++) worst = Math.max(worst, Math.abs(down[i] - tone[2 * i])); check(worst < 5e-3, `resample: worst deviation ${worst.toExponential(1)} on a low tone`); const a = new Float32Array([1, 2, 3]); mixInto(a, new Float32Array([1, 1, 1, 9]), 0.5); check(a[0] === 1.5 && a[2] === 3.5, 'mixInto: sums with gain, clipped to dst length'); check(Math.abs(peak(new Float32Array([0.1, -0.9, 0.4])) - 0.9) < 1e-6, 'peak: absolute maximum'); } // --- wav encoding ---------------------------------------------------------------- { const x = new Float32Array([0, 0.5, -0.5, 2]); // the last clips const bytes = wavBytes(x, 44100); const v = new DataView(bytes.buffer); check(bytes.length === 44 + 8, `wav: ${bytes.length} bytes for 4 samples`); check(String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) === 'RIFF', 'wav: RIFF magic'); check(v.getUint32(24, true) === 44100, 'wav: sample rate field'); check(v.getInt16(46, true) === Math.round(0.5 * 32767), 'wav: 0.5 encodes to half scale'); check(v.getInt16(50, true) === 32767, 'wav: over-range clips to full scale'); } // --- numbl classifier path --------------------------------------------------------- // The default silence_gap_knn (with its noise-floor threshold adaptation) // run over the same pipeline the app uses: training snippets cut from // voice clips at 16 kHz, the take reduced to 16 kHz, detections mapped // back to voices. The take carries a realistic microphone noise floor, // which the original benchmark algorithm's silence assumption would not // survive; this is exactly what the adaptation is for. const { buildVoiceTraining } = await import('../src/numbl/snippets.ts'); const { runAlgorithm } = await import('../src/numbl/execute.ts'); const { downsampleBox } = await import('../src/numbl/dsp.ts'); const { DEFAULT_CLASSIFIER_SOURCE } = await import('../src/numbl/index.ts'); const { CLF_FS } = await import('../src/numbl/types.ts'); { const noiseInto = (x: Float32Array, amp: number, seed: number) => { let s = seed; for (let i = 0; i < x.length; i++) { s = (s * 1103515245 + 12345) & 0x7fffffff; x[i] += (s / 0x3fffffff - 1) * amp; } }; // one training clip per voice: 8 well-separated bursts at known times const clipAudio = new Map(); const mkVoice = (id: string, freq: number, decay: number, seed: number) => { const times = Array.from({ length: 8 }, (_, i) => 0.3 + i * 0.35); const samples = new Float32Array(Math.floor(3.2 * SR)); noiseInto(samples, 5e-4, seed); times.forEach((t, i) => burst(samples, t, freq, decay, 0.5, seed + i)); clipAudio.set(`clip-${id}`, { samples, sr: SR }); return { id, name: id, color: '#888', binding: { kind: 'synth', preset: 'kick' } as const, volume: 1, clips: [{ id: `clip-${id}`, sr: SR, sens: 0.6, onsets: times, excluded: [], hits: [] }], loose: [], }; }; const testVoices = [mkVoice('thump', 200, 0.05, 11), mkVoice('tick', 1500, 0.015, 77)]; const { training, voiceIds, skipped } = await buildVoiceTraining( testVoices, (id) => Promise.resolve(clipAudio.get(id) ?? null), ); check(voiceIds.length === 2 && skipped.length === 0, `numbl training: 2 voices, ${training.snips.map((s) => s.count).join('+')} snippets`); // a take with a mic-like noise floor and 6 alternating hits const takeTimes = [0.4, 0.8, 1.2, 1.6, 2.0, 2.4]; const takeSamples = new Float32Array(Math.floor(3 * SR)); noiseInto(takeSamples, 5e-4, 999); takeTimes.forEach((t, i) => burst(takeSamples, t, i % 2 === 0 ? 200 : 1500, i % 2 === 0 ? 0.05 : 0.015, 0.4, 300 + i)); const x16 = downsampleBox(takeSamples, SR, CLF_FS); const { detections } = runAlgorithm(DEFAULT_CLASSIFIER_SOURCE, x16, CLF_FS, training); check(detections.length === takeTimes.length, `numbl classify: ${detections.length} of ${takeTimes.length} hits found on a noisy take`); let timesOk = true, labelsOk = true; detections.forEach((d, i) => { if (i >= takeTimes.length) return; if (Math.abs(d.t - takeTimes[i]) > 0.03) timesOk = false; if (d.typeIndex !== i % 2) labelsOk = false; }); check(timesOk, 'numbl classify: every hit within 30 ms of the truth'); check(labelsOk, 'numbl classify: thump/tick labels all correct'); } console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed'); process.exit(failures ? 1 : 0);