// Node-side test of the cymbal2 track-render instrument: executes the // actual MATLAB model through numbl on the CPU path (no browser, no // worker, no GPU) and checks the audio. The request's params override // shrinks the render (low rate, short tail) so the full time integration // stays test-sized; the GPU path has its own harness in // webapp/scripts/gpu-selftest.ts. Run with: npx tsx test.ts import { readdirSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { renderRequest, type RenderEnv } from './core.ts'; import type { NoteSpec, TrackRenderRequest } from './types.ts'; const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'matlab'); const mfiles = readdirSync(matlabDir) .filter((f) => f.endsWith('.m')) .sort() .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') })); const env: RenderEnv = { mfiles, shipped: mfiles, post: () => {}, checkCancel: () => {}, yieldToHost: async () => {}, }; let failures = 0; function check(label: string, ok: boolean, detail = ''): void { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`); if (!ok) failures++; } function stats(y: Float32Array) { let peak = 0; let finite = true; for (let i = 0; i < y.length; i++) { if (!Number.isFinite(y[i])) finite = false; peak = Math.max(peak, Math.abs(y[i])); } return { peak, finite }; } // The explicit nonlinearity needs the full simulation rate: dropping the // sample rate raises the time step and the integration diverges. Shrink // the render by shortening the tail instead. const SR = 44100; function req(notes: NoteSpec[], overrides: Partial = {}): TrackRenderRequest { const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0); return { notes, sampleRate: SR, lengthSamples: Math.ceil((end + 0.4) * SR), quality: 'preview', settings: {}, params: { tail_s: 0.25 }, // test-sized ring; the physics is the same ...overrides, }; } const note = (timeSec: number, pitch: number, velocity: number): NoteSpec => ({ timeSec, durationSec: 0.1, pitch, velocity }); const t0 = performance.now(); // One stroke: finite and audible. (The renders are cached by note key, so // an identical repeat returns the same buffer; determinism of the model // itself is pinned in matlab/cymbal2_validate.m.) const a = await renderRequest('cymbal2', req([note(0, 60, 0.4)]), env); { const s = stats(a.audio); check('single stroke is finite', s.finite); check('single stroke is audible', s.peak > 1e-6, `peak ${s.peak.toExponential(2)}`); } // The note cache. { const t = performance.now(); await renderRequest('cymbal2', req([note(0, 60, 0.4)]), env); const ms = performance.now() - t; check('repeat render hits the note cache', ms < 500, `${ms.toFixed(0)} ms`); } // Velocity changes the sound (normalize is off, so amplitude scales). const soft = await renderRequest('cymbal2', req([note(0, 60, 0.15)]), env); { const pa = stats(a.audio).peak; const ps = stats(soft.audio).peak; check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`); } // A pattern: both strokes come from the cache and land at their onsets. const tr = await renderRequest('cymbal2', req([note(0, 60, 0.4), note(0.5, 60, 0.4), note(1.0, 60, 0.15)]), env); { const s = stats(tr.audio); check('track render is finite', s.finite); const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * SR), Math.floor((sec + 0.25) * SR))).peak; check('energy at first onset', at(0.0) > 1e-6); check('energy at second onset', at(0.5) > 1e-6); check('energy at third onset', at(1.0) > 1e-6); } console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`); if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }