1// Node-side test of the cymbal2 track-render instrument: executes the
2// actual MATLAB model through numbl on the CPU path (no browser, no
3// worker, no GPU) and checks the audio. The request's params override
4// shrinks the render (low rate, short tail) so the full time integration
5// stays test-sized; the GPU path has its own harness in
6// webapp/scripts/gpu-selftest.ts. Run with: npx tsx test.ts
7import { readdirSync, readFileSync } from 'node:fs';
8import { join, dirname } from 'node:path';
9import { fileURLToPath } from 'node:url';
10import { renderRequest, type RenderEnv } from './core.ts';
11import type { NoteSpec, TrackRenderRequest } from './types.ts';
13const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'matlab');
14const mfiles = readdirSync(matlabDir)
15 .filter((f) => f.endsWith('.m'))
16 .sort()
17 .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') }));
19const env: RenderEnv = {
20 mfiles,
21 shipped: mfiles,
22 post: () => {},
23 checkCancel: () => {},
24 yieldToHost: async () => {},
25};
27let failures = 0;
28function check(label: string, ok: boolean, detail = ''): void {
29 console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
30 if (!ok) failures++;
31}
33function stats(y: Float32Array) {
34 let peak = 0;
35 let finite = true;
36 for (let i = 0; i < y.length; i++) {
37 if (!Number.isFinite(y[i])) finite = false;
38 peak = Math.max(peak, Math.abs(y[i]));
39 }
40 return { peak, finite };
41}
43// The explicit nonlinearity needs the full simulation rate: dropping the
44// sample rate raises the time step and the integration diverges. Shrink
45// the render by shortening the tail instead.
46const SR = 44100;
48function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
49 const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
50 return {
51 notes,
52 sampleRate: SR,
53 lengthSamples: Math.ceil((end + 0.4) * SR),
54 quality: 'preview',
55 settings: {},
56 params: { tail_s: 0.25 }, // test-sized ring; the physics is the same
57 ...overrides,
58 };
59}
61const note = (timeSec: number, pitch: number, velocity: number): NoteSpec =>
62 ({ timeSec, durationSec: 0.1, pitch, velocity });
64const t0 = performance.now();
66// One stroke: finite and audible. (The renders are cached by note key, so
67// an identical repeat returns the same buffer; determinism of the model
68// itself is pinned in matlab/cymbal2_validate.m.)
69const a = await renderRequest('cymbal2', req([note(0, 60, 0.4)]), env);
70{
71 const s = stats(a.audio);
72 check('single stroke is finite', s.finite);
73 check('single stroke is audible', s.peak > 1e-6, `peak ${s.peak.toExponential(2)}`);
74}
76// The note cache.
77{
78 const t = performance.now();
79 await renderRequest('cymbal2', req([note(0, 60, 0.4)]), env);
80 const ms = performance.now() - t;
81 check('repeat render hits the note cache', ms < 500, `${ms.toFixed(0)} ms`);
82}
84// Velocity changes the sound (normalize is off, so amplitude scales).
85const soft = await renderRequest('cymbal2', req([note(0, 60, 0.15)]), env);
86{
87 const pa = stats(a.audio).peak;
88 const ps = stats(soft.audio).peak;
89 check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`);
90}
92// A pattern: both strokes come from the cache and land at their onsets.
93const tr = await renderRequest('cymbal2', req([note(0, 60, 0.4), note(0.5, 60, 0.4), note(1.0, 60, 0.15)]), env);
94{
95 const s = stats(tr.audio);
96 check('track render is finite', s.finite);
97 const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * SR), Math.floor((sec + 0.25) * SR))).peak;
98 check('energy at first onset', at(0.0) > 1e-6);
99 check('energy at second onset', at(0.5) > 1e-6);
100 check('energy at third onset', at(1.0) > 1e-6);
101}
103console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
104if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }