/ simulated_instruments / recorder
Sign in
simulated_instruments / recorder
recorder / instrument / test.ts
112 lines · 4.2 KBBlameHistoryRaw
1// Node-side test of the recorder track-render instrument: executes the
2// actual MATLAB model through numbl (no browser, no worker) and checks the
3// audio. Run with: npx tsx test.ts
4import { readdirSync, readFileSync } from 'node:fs';
5import { join, dirname } from 'node:path';
6import { fileURLToPath } from 'node:url';
7import { chartIndexForPitch, renderRequest, type RenderEnv } from './core.ts';
8import type { NoteSpec, TrackRenderRequest } from './types.ts';
10const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'matlab');
11const mfiles = readdirSync(matlabDir)
12 .filter((f) => f.endsWith('.m'))
13 .sort()
14 .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') }));
16const env: RenderEnv = {
17 mfiles,
18 post: () => {},
19 checkCancel: () => {},
20 yieldToHost: async () => {},
21};
23let failures = 0;
24function check(label: string, ok: boolean, detail = ''): void {
25 console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
26 if (!ok) failures++;
29function stats(y: Float32Array) {
30 let peak = 0;
31 let finite = true;
32 for (let i = 0; i < y.length; i++) {
33 if (!Number.isFinite(y[i])) finite = false;
34 peak = Math.max(peak, Math.abs(y[i]));
35 }
36 return { peak, finite };
39function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
40 const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
41 return {
42 notes,
43 sampleRate: 22050,
44 lengthSamples: Math.ceil((end + 1.0) * 22050),
45 quality: 'preview',
46 settings: {},
47 ...overrides,
48 };
51const note = (timeSec: number, pitch: number, velocity: number, durationSec = 0.5): NoteSpec =>
52 ({ timeSec, durationSec, pitch, velocity });
54// The pitch fold: in-range pitches map to their own row, out-of-range ones
55// keep their contour an octave in.
56check('C5 is chart row 1', chartIndexForPitch(72) === 1);
57check('A6 is chart row 14', chartIndexForPitch(93) === 14);
58check('C4 folds up to row 1', chartIndexForPitch(60) === 1);
59check('C7 folds down', chartIndexForPitch(96) === chartIndexForPitch(84));
61const t0 = performance.now();
63// One note: finite, audible.
64const a = await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
66 const s = stats(a.audio);
67 check('single note is finite', s.finite);
68 check('single note is audible', s.peak > 1e-4, `peak ${s.peak.toExponential(2)}`);
71// Determinism and the note cache.
72const b = await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
74 let same = a.audio.length === b.audio.length;
75 if (same) for (let i = 0; i < a.audio.length; i++) if (a.audio[i] !== b.audio[i]) { same = false; break; }
76 check('render is deterministic', same);
79 const t = performance.now();
80 await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
81 const ms = performance.now() - t;
82 check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
85// Velocity (breath) and duration change the sound.
86const soft = await renderRequest('recorder', req([note(0, 79, 0.2)]), env);
88 let diff = false;
89 const n = Math.min(a.audio.length, soft.audio.length);
90 for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - soft.audio[i]) > 1e-9) { diff = true; break; }
91 check('velocity changes the sound', diff);
93const long = await renderRequest('recorder', req([note(0, 79, 0.7, 1.5)]), env);
95 const shortTail = stats(a.audio.slice(Math.floor(1.2 * 22050))).peak;
96 const longTail = stats(long.audio.slice(Math.floor(1.2 * 22050))).peak;
97 check('duration changes the sound', longTail > shortTail, `long ${longTail.toExponential(2)} > short ${shortTail.toExponential(2)}`);
100// A phrase: distinct pitches at their onsets, repeats from the cache.
101const tr = await renderRequest('recorder', req([note(0, 79, 0.7), note(0.8, 84, 0.7), note(1.6, 79, 0.7)]), env);
103 const s = stats(tr.audio);
104 check('track render is finite', s.finite);
105 const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * 22050), Math.floor((sec + 0.4) * 22050))).peak;
106 check('energy at first onset', at(0.05) > 1e-4);
107 check('energy at second onset', at(0.85) > 1e-4);
108 check('energy at third onset', at(1.65) > 1e-4);
111console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
112if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }
↑↓ move↵openescclose