1/**
2 * Integration test for the exported-instrument path: real committed
3 * bundles, real numbl execution in node (no browser, no worker). Renders
4 * woodblock strikes and bass plucks at the preview tier through the same
5 * renderNoteBatch the worker uses, and checks the audio is shaped right,
6 * including that velocity actually changes the result and that the
7 * recorder's prepare stage feeds its fingering chart through. Run with
8 * `npm run test:sim`; slower than npm test because it runs the physics.
9 */
10import { readFileSync, existsSync } from 'node:fs';
11import { join } from 'node:path';
12import { parseBundle, type InstrumentBundle } from '../src/instruments/numbl/bundle.ts';
13import { renderNoteBatch } from '../src/instruments/numbl/render.ts';
15const ROOT = join(import.meta.dirname, '../../../simulated_instruments');
17function loadBundle(repo: string, id: string): InstrumentBundle {
18 return parseBundle(readFileSync(join(ROOT, repo, 'exports', `${id}.json`), 'utf8'));
19}
21let failures = 0;
22function check(cond: boolean, msg: string): void {
23 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
24 if (!cond) failures++;
25}
27function peak(x: Float32Array, from = 0, to = x.length): number {
28 let p = 0;
29 for (let i = from; i < Math.min(to, x.length); i++) p = Math.max(p, Math.abs(x[i]));
30 return p;
31}
33function finite(x: Float32Array): boolean {
34 for (let i = 0; i < x.length; i++) if (!Number.isFinite(x[i])) return false;
35 return true;
36}
38/** Goertzel magnitude at one frequency over a window into the ring. */
39function goertzel(y: Float32Array, from: number, len: number, f: number, sr: number): number {
40 const w = (2 * Math.PI * f) / sr;
41 const c = 2 * Math.cos(w);
42 let s1 = 0, s2 = 0;
43 for (let i = from; i < from + len && i < y.length; i++) {
44 const s0 = y[i] + c * s1 - s2;
45 s2 = s1;
46 s1 = s0;
47 }
48 return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2);
49}
51/** The strongest spectral peak in [lo, hi], 2 Hz grid, past the attack. */
52function peakFreq(y: Float32Array, sr: number, lo: number, hi: number): number {
53 const from = Math.floor(y.length * 0.3);
54 const len = Math.min(8192, y.length - from);
55 let best = lo, bm = -1;
56 for (let f = lo; f <= hi; f += 2) {
57 const m = goertzel(y, from, len, f, sr);
58 if (m > bm) { bm = m; best = f; }
59 }
60 return best;
61}
63if (!existsSync(ROOT)) {
64 console.log(`skipped: ${ROOT} not found`);
65 process.exit(0);
66}
68const SR = 44100;
70{
71 const b = loadBundle('woodblock', 'woodblock');
72 const t0 = performance.now();
73 const [soft, hard] = renderNoteBatch(b, [
74 { pitch: 76, velocity: 0.25, durationSec: 0.25 },
75 { pitch: 76, velocity: 1.0, durationSec: 0.25 },
76 ], { sampleRate: SR, quality: 'preview', settings: {} });
77 console.log(`woodblock: two strikes in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
78 check(soft.length > SR * 0.2, `woodblock trace has audio length (${soft.length})`);
79 check(finite(soft) && finite(hard), 'woodblock samples finite');
80 check(peak(soft) > 0, 'woodblock soft strike sounds');
81 check(peak(hard) > peak(soft) * 1.5, `harder mallet is louder (${peak(hard).toExponential(2)} vs ${peak(soft).toExponential(2)})`);
82 const [moved] = renderNoteBatch(b, [{ pitch: 76, velocity: 1.0, durationSec: 0.25 }], {
83 sampleRate: SR, quality: 'preview', settings: { strike_position: 0.25 },
84 });
85 let differs = moved.length !== hard.length;
86 for (let i = 0; i < Math.min(moved.length, hard.length) && !differs; i++) if (moved[i] !== hard[i]) differs = true;
87 check(differs, 'strike position knob changes the sound');
88 // Pitch by geometric scaling: an octave down halves the dominant mode.
89 const [lo, hi] = renderNoteBatch(b, [
90 { pitch: 64, velocity: 0.8, durationSec: 0.25 },
91 { pitch: 76, velocity: 0.8, durationSec: 0.25 },
92 ], { sampleRate: SR, quality: 'preview', settings: {} });
93 const fLo = peakFreq(lo, SR, 300, 4000);
94 const fHi = peakFreq(hi, SR, 600, 8000);
95 const ratio = fHi / fLo;
96 check(ratio > 1.94 && ratio < 2.06, `octave apart in pitch is an octave apart in frequency (${fLo} Hz -> ${fHi} Hz)`);
97}
99{
100 const b = loadBundle('bass', 'bass');
101 const t0 = performance.now();
102 const [y] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
103 sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
104 });
105 console.log(`bass: one pluck in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
106 check(finite(y), 'bass samples finite');
107 check(peak(y) > 0, 'bass pluck sounds');
108 check(Math.abs(y.length - SR * 2.0) < SR * 0.2, `bass trace spans the tail (${y.length} samples)`);
109 check(peak(y, 0, Math.round(SR * 0.5)) > peak(y, Math.round(SR * 1.5)), 'bass pluck decays');
110 // Determinism: fixed seed, same request, same samples.
111 const [y2] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
112 sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
113 });
114 let same = y.length === y2.length;
115 for (let i = 0; i < y.length && same; i++) if (y[i] !== y2[i]) same = false;
116 check(same, 'bass render is deterministic');
117 // The muted variant of the same repo is a distinct bundle with its own driver.
118 const muted = loadBundle('bass', 'bass-muted');
119 const [ym] = renderNoteBatch(muted, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
120 sampleRate: SR, quality: 'preview', settings: {},
121 });
122 check(finite(ym) && peak(ym) > 0, 'muted bass sounds');
123 check(ym.length < y.length, `muted bass rings shorter (${ym.length} vs ${y.length} samples)`);
124}
126{
127 // The recorder exercises the prepare stage (fingering chart in state)
128 // at a low sample rate to keep the jet integration quick.
129 const b = loadBundle('recorder', 'recorder');
130 const t0 = performance.now();
131 const RSR = 22050;
132 const [g5, c6, g4] = renderNoteBatch(b, [
133 { pitch: 79, velocity: 0.6, durationSec: 0.5 },
134 { pitch: 84, velocity: 0.6, durationSec: 0.5 },
135 { pitch: 67, velocity: 0.6, durationSec: 0.5 }, // below the chart: folds up an octave
136 ], { sampleRate: RSR, quality: 'preview', settings: {} });
137 console.log(`recorder: three notes in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
138 check(finite(g5) && peak(g5) > 0, 'recorder note sounds (prepare state reached the driver)');
139 const f79 = peakFreq(g5, RSR, 400, 2000);
140 const f84 = peakFreq(c6, RSR, 400, 2000);
141 const f67 = peakFreq(g4, RSR, 400, 2000);
142 check(Math.abs(f79 - 784) < 25, `pitch 79 locks near G5 (${f79} Hz)`);
143 check(Math.abs(f84 - 1047) < 35, `pitch 84 locks near C6 (${f84} Hz)`);
144 check(Math.abs(f67 - f79) < 10, `below-range pitch folds up an octave (${f67} Hz vs ${f79} Hz)`);
145}
147console.log(failures === 0 ? 'exported-instrument test passed' : `${failures} failure(s)`);
148process.exit(failures > 0 ? 1 : 0);