/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / scripts / test-sim.ts
105 lines · 4.7 KBBlameHistoryRaw
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'));
21let failures = 0;
22function check(cond: boolean, msg: string): void {
23 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
24 if (!cond) failures++;
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;
33function finite(x: Float32Array): boolean {
34 for (let i = 0; i < x.length; i++) if (!Number.isFinite(x[i])) return false;
35 return true;
38if (!existsSync(ROOT)) {
39 console.log(`skipped: ${ROOT} not found`);
40 process.exit(0);
43const SR = 44100;
46 const b = loadBundle('woodblock', 'woodblock');
47 const t0 = performance.now();
48 const [soft, hard] = renderNoteBatch(b, [
49 { pitch: 60, velocity: 0.25, durationSec: 0.25 },
50 { pitch: 60, velocity: 1.0, durationSec: 0.25 },
51 ], { sampleRate: SR, quality: 'preview', settings: {} });
52 console.log(`woodblock: two strikes in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
53 check(soft.length > SR * 0.2, `woodblock trace has audio length (${soft.length})`);
54 check(finite(soft) && finite(hard), 'woodblock samples finite');
55 check(peak(soft) > 0, 'woodblock soft strike sounds');
56 check(peak(hard) > peak(soft) * 1.5, `harder mallet is louder (${peak(hard).toExponential(2)} vs ${peak(soft).toExponential(2)})`);
57 const [moved] = renderNoteBatch(b, [{ pitch: 60, velocity: 1.0, durationSec: 0.25 }], {
58 sampleRate: SR, quality: 'preview', settings: { strike_position: 0.25 },
59 });
60 let differs = moved.length !== hard.length;
61 for (let i = 0; i < Math.min(moved.length, hard.length) && !differs; i++) if (moved[i] !== hard[i]) differs = true;
62 check(differs, 'strike position knob changes the sound');
66 const b = loadBundle('bass', 'bass');
67 const t0 = performance.now();
68 const [y] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
69 sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
70 });
71 console.log(`bass: one pluck in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
72 check(finite(y), 'bass samples finite');
73 check(peak(y) > 0, 'bass pluck sounds');
74 check(Math.abs(y.length - SR * 2.0) < SR * 0.2, `bass trace spans the tail (${y.length} samples)`);
75 check(peak(y, 0, Math.round(SR * 0.5)) > peak(y, Math.round(SR * 1.5)), 'bass pluck decays');
76 // Determinism: fixed seed, same request, same samples.
77 const [y2] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
78 sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
79 });
80 let same = y.length === y2.length;
81 for (let i = 0; i < y.length && same; i++) if (y[i] !== y2[i]) same = false;
82 check(same, 'bass render is deterministic');
83 // The muted variant of the same repo is a distinct bundle with its own driver.
84 const muted = loadBundle('bass', 'bass-muted');
85 const [ym] = renderNoteBatch(muted, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
86 sampleRate: SR, quality: 'preview', settings: {},
87 });
88 check(finite(ym) && peak(ym) > 0, 'muted bass sounds');
89 check(ym.length < y.length, `muted bass rings shorter (${ym.length} vs ${y.length} samples)`);
93 // The recorder exercises the prepare stage (fingering chart in state)
94 // at a low sample rate to keep the jet integration quick.
95 const b = loadBundle('recorder', 'recorder');
96 const t0 = performance.now();
97 const [g5] = renderNoteBatch(b, [{ pitch: 79, velocity: 0.6, durationSec: 0.3 }], {
98 sampleRate: 22050, quality: 'preview', settings: {},
99 });
100 console.log(`recorder: one note in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
101 check(finite(g5) && peak(g5) > 0, 'recorder note sounds (prepare state reached the driver)');
104console.log(failures === 0 ? 'exported-instrument test passed' : `${failures} failure(s)`);
105process.exit(failures > 0 ? 1 : 0);
↑↓ move↵openescclose