// Node-side test of the woodblock 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 }; } function req(notes: NoteSpec[], overrides: Partial = {}): TrackRenderRequest { const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0); return { notes, sampleRate: 22050, lengthSamples: Math.ceil((end + 1.0) * 22050), quality: 'preview', settings: {}, ...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('woodblock', req([note(0, 76, 0.7)]), env); { const s = stats(a.audio); check('single strike is finite', s.finite); check('single strike is audible', s.peak > 1e-4, `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)}`); } // Determinism and the note cache. const b = await renderRequest('woodblock', req([note(0, 76, 0.7)]), env); { let same = a.audio.length === b.audio.length; if (same) for (let i = 0; i < a.audio.length; i++) if (a.audio[i] !== b.audio[i]) { same = false; break; } check('render is deterministic', same); } { const t = performance.now(); await renderRequest('woodblock', req([note(0, 76, 0.7)]), env); const ms = performance.now() - t; check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`); } // Velocity and pitch change the sound. const soft = await renderRequest('woodblock', req([note(0, 76, 0.2)]), 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)}`); } const high = await renderRequest('woodblock', req([note(0, 83, 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); } // A pattern: the repeated strike is placed, not re-rendered. const tr = await renderRequest('woodblock', req([note(0, 76, 0.7), note(0.5, 76, 0.7), note(1.0, 83, 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 * 22050), Math.floor((sec + 0.2) * 22050))).peak; check('energy at first onset', at(0.0) > 1e-4); check('energy at second onset', at(0.5) > 1e-4); check('energy at third onset', at(1.0) > 1e-4); } console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`); if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }