/** * Integration test for the simulated-instrument path: real manifests, real * MATLAB sources, real numbl execution in node (no browser, no worker). * Renders a woodblock strike and a bass pluck at the preview tier and * checks the audio is shaped right, including that velocity actually * changes the result. Run with `npm run test:sim`; slower than npm test * because it runs the physics. */ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { parseManifest, type SimManifest } from '../src/instruments/simulated/manifest.ts'; import { renderSimNote, type MFile } from '../src/instruments/simulated/mrender.ts'; const ROOT = join(import.meta.dirname, '../../../simulated_instruments'); function loadDef(name: string): { manifest: SimManifest; mfiles: MFile[] } { const manifest = parseManifest(readFileSync(join(ROOT, name, 'instrument.json'), 'utf8')); const mdir = join(ROOT, name, 'matlab'); const mfiles = readdirSync(mdir) .filter((f) => f.endsWith('.m')) .map((f) => ({ name: f, source: readFileSync(join(mdir, f), 'utf8') })); return { manifest, mfiles }; } let failures = 0; function check(cond: boolean, msg: string): void { console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`); if (!cond) failures++; } function peak(x: Float32Array, from = 0, to = x.length): number { let p = 0; for (let i = from; i < Math.min(to, x.length); i++) p = Math.max(p, Math.abs(x[i])); return p; } function finite(x: Float32Array): boolean { for (let i = 0; i < x.length; i++) if (!Number.isFinite(x[i])) return false; return true; } if (!existsSync(ROOT)) { console.log(`skipped: ${ROOT} not found`); process.exit(0); } const SR = 44100; { const { manifest, mfiles } = loadDef('woodblock'); const t0 = performance.now(); const soft = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 0.25, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: {} }); const hard = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 1.0, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: {} }); console.log(`woodblock: two strikes in ${((performance.now() - t0) / 1000).toFixed(1)} s`); check(soft.length > SR * 0.2, `woodblock trace has audio length (${soft.length})`); check(finite(soft) && finite(hard), 'woodblock samples finite'); check(peak(soft) > 0, 'woodblock soft strike sounds'); check(peak(hard) > peak(soft) * 1.5, `harder mallet is louder (${peak(hard).toExponential(2)} vs ${peak(soft).toExponential(2)})`); const moved = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 1.0, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: { strike_position: 0.25 } }); let differs = moved.length !== hard.length; for (let i = 0; i < Math.min(moved.length, hard.length) && !differs; i++) if (moved[i] !== hard[i]) differs = true; check(differs, 'strike position knob changes the sound'); } { const { manifest, mfiles } = loadDef('bass'); const t0 = performance.now(); const y = renderSimNote(manifest, mfiles, { pitch: 33, velocity: 0.7, durationSec: 0.5, sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 } }); console.log(`bass: one pluck in ${((performance.now() - t0) / 1000).toFixed(1)} s`); check(finite(y), 'bass samples finite'); check(peak(y) > 0, 'bass pluck sounds'); check(Math.abs(y.length - SR * 2.0) < SR * 0.2, `bass trace spans the tail (${y.length} samples)`); check(peak(y, 0, Math.round(SR * 0.5)) > peak(y, Math.round(SR * 1.5)), 'bass pluck decays'); // Determinism: fixed seed, same request, same samples. const y2 = renderSimNote(manifest, mfiles, { pitch: 33, velocity: 0.7, durationSec: 0.5, sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 } }); let same = y.length === y2.length; for (let i = 0; i < y.length && same; i++) if (y[i] !== y2[i]) same = false; check(same, 'bass render is deterministic'); } console.log(failures === 0 ? 'simulated-instrument test passed' : `${failures} failure(s)`); process.exit(failures > 0 ? 1 : 0);