4c0259bAdd instrument/: the triangle behind the track-render interfaceJeremy Magland 1// Node-side test of the triangle 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 { 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++;
27}
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 };
37}
39const SR = 22050;
41function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
42 const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
43 return {
44 notes,
45 sampleRate: SR,
46 lengthSamples: Math.ceil((end + 1.5) * SR),
47 quality: 'preview',
48 settings: { tail_s: 1 },
49 ...overrides,
50 };
51}
53const note = (timeSec: number, pitch: number, velocity: number): NoteSpec =>
54 ({ timeSec, durationSec: 0.1, pitch, velocity });
56const t0 = performance.now();
58// One strike: finite, audible, decaying.
59const a = await renderRequest('triangle', req([note(0, 84, 0.7)]), env);
60{
61 const s = stats(a.audio);
62 check('single strike is finite', s.finite);
63 check('single strike is audible', s.peak > 1e-5, `peak ${s.peak.toExponential(2)}`);
64 const n = a.audio.length;
65 const head = stats(a.audio.slice(0, Math.floor(n / 3))).peak;
66 const tail = stats(a.audio.slice(Math.floor((2 * n) / 3))).peak;
67 check('single strike decays', tail < head * 0.8, `head ${head.toExponential(2)} tail ${tail.toExponential(2)}`);
68}
70// The note cache.
71{
72 const t = performance.now();
73 await renderRequest('triangle', req([note(0, 84, 0.7)]), env);
74 const ms = performance.now() - t;
75 check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
76}
78// Velocity changes the sound (normalize is off, so amplitude scales); the
79// second velocity reuses the pitch's cached eigensolve, so it only re-runs
80// the radiate stage.
81const soft = await (async () => {
82 const t = performance.now();
83 const r = await renderRequest('triangle', req([note(0, 84, 0.2)]), env);
84 return { r, ms: performance.now() - t };
85})();
86{
87 const pa = stats(a.audio).peak;
88 const ps = stats(soft.r.audio).peak;
89 check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`);
90}
92// Pitch and knobs change the sound.
93const high = await renderRequest('triangle', req([note(0, 89, 0.7)]), env);
94{
95 let diff = high.audio.length !== a.audio.length;
96 if (!diff) for (let i = 0; i < a.audio.length; i++) if (Math.abs(a.audio[i] - high.audio[i]) > 1e-9) { diff = true; break; }
97 check('pitch changes the sound', diff);
98}
99const angled = await renderRequest('triangle', req([note(0, 84, 0.7)], { settings: { tail_s: 1, strike_angle_deg: 80 } }), env);
100{
101 let diff = false;
102 const n = Math.min(a.audio.length, angled.audio.length);
103 for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - angled.audio[i]) > 1e-9) { diff = true; break; }
104 check('strike_angle_deg knob changes the sound', diff);
105}
107// A pattern: repeats from the cache, distinct sounds at their onsets, and
108// the buffer covers at least the requested length.
109const tr = await renderRequest('triangle', req([note(0, 84, 0.7), note(0.6, 84, 0.7), note(1.2, 89, 0.7)]), env);
110{
111 const s = stats(tr.audio);
112 check('track render is finite', s.finite);
113 const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * SR), Math.floor((sec + 0.3) * SR))).peak;
114 check('energy at first onset', at(0.0) > 1e-5);
115 check('energy at second onset', at(0.6) > 1e-5);
116 check('energy at third onset', at(1.2) > 1e-5);
117 const asked = req([note(0, 84, 0.7), note(0.6, 84, 0.7), note(1.2, 89, 0.7)]).lengthSamples;
118 check('buffer covers the requested length', tr.audio.length >= asked, `${tr.audio.length} >= ${asked}`);
119}
121console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
122if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }