// Node-side test of the triangle track-render instrument: executes the // actual MATLAB model through numbl (no browser, no worker) and checks the // audio. 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, 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 }; } const SR = 22050; 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 + 1.5) * SR), quality: 'preview', settings: { tail_s: 1 }, ...overrides, }; } const note = (timeSec: number, pitch: number, velocity: number): NoteSpec => ({ timeSec, durationSec: 0.1, pitch, velocity }); const t0 = performance.now(); // One strike: finite, audible, decaying. const a = await renderRequest('triangle', req([note(0, 84, 0.7)]), env); { const s = stats(a.audio); check('single strike is finite', s.finite); check('single strike is audible', s.peak > 1e-5, `peak ${s.peak.toExponential(2)}`); const n = a.audio.length; const head = stats(a.audio.slice(0, Math.floor(n / 3))).peak; const tail = stats(a.audio.slice(Math.floor((2 * n) / 3))).peak; check('single strike decays', tail < head * 0.8, `head ${head.toExponential(2)} tail ${tail.toExponential(2)}`); } // The note cache. { const t = performance.now(); await renderRequest('triangle', req([note(0, 84, 0.7)]), env); const ms = performance.now() - t; check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`); } // Velocity changes the sound (normalize is off, so amplitude scales); the // second velocity reuses the pitch's cached eigensolve, so it only re-runs // the radiate stage. const soft = await (async () => { const t = performance.now(); const r = await renderRequest('triangle', req([note(0, 84, 0.2)]), env); return { r, ms: performance.now() - t }; })(); { const pa = stats(a.audio).peak; const ps = stats(soft.r.audio).peak; check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`); } // Pitch and knobs change the sound. const high = await renderRequest('triangle', req([note(0, 89, 0.7)]), env); { let diff = high.audio.length !== a.audio.length; 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; } check('pitch changes the sound', diff); } const angled = await renderRequest('triangle', req([note(0, 84, 0.7)], { settings: { tail_s: 1, strike_angle_deg: 80 } }), env); { let diff = false; const n = Math.min(a.audio.length, angled.audio.length); for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - angled.audio[i]) > 1e-9) { diff = true; break; } check('strike_angle_deg knob changes the sound', diff); } // A pattern: repeats from the cache, distinct sounds at their onsets, and // the buffer covers at least the requested length. const tr = await renderRequest('triangle', req([note(0, 84, 0.7), note(0.6, 84, 0.7), note(1.2, 89, 0.7)]), 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.3) * SR))).peak; check('energy at first onset', at(0.0) > 1e-5); check('energy at second onset', at(0.6) > 1e-5); check('energy at third onset', at(1.2) > 1e-5); const asked = req([note(0, 84, 0.7), note(0.6, 84, 0.7), note(1.2, 89, 0.7)]).lengthSamples; check('buffer covers the requested length', tr.audio.length >= asked, `${tr.audio.length} >= ${asked}`); } console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`); if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }