/** * Integration test for the exported-instrument path: real committed * bundles, real numbl execution in node (no browser, no worker). Renders * woodblock strikes and bass plucks at the preview tier through the same * renderNoteBatch the worker uses, and checks the audio is shaped right, * including that velocity actually changes the result and that the * recorder's prepare stage feeds its fingering chart through. Run with * `npm run test:sim`; slower than npm test because it runs the physics. */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { parseBundle, type InstrumentBundle } from '../src/instruments/numbl/bundle.ts'; import { renderNoteBatch } from '../src/instruments/numbl/render.ts'; const ROOT = join(import.meta.dirname, '../../../simulated_instruments'); function loadBundle(repo: string, id: string): InstrumentBundle { return parseBundle(readFileSync(join(ROOT, repo, 'exports', `${id}.json`), 'utf8')); } 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; } /** Goertzel magnitude at one frequency over a window into the ring. */ function goertzel(y: Float32Array, from: number, len: number, f: number, sr: number): number { const w = (2 * Math.PI * f) / sr; const c = 2 * Math.cos(w); let s1 = 0, s2 = 0; for (let i = from; i < from + len && i < y.length; i++) { const s0 = y[i] + c * s1 - s2; s2 = s1; s1 = s0; } return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2); } /** The strongest spectral peak in [lo, hi], 2 Hz grid, past the attack. */ function peakFreq(y: Float32Array, sr: number, lo: number, hi: number): number { const from = Math.floor(y.length * 0.3); const len = Math.min(8192, y.length - from); let best = lo, bm = -1; for (let f = lo; f <= hi; f += 2) { const m = goertzel(y, from, len, f, sr); if (m > bm) { bm = m; best = f; } } return best; } if (!existsSync(ROOT)) { console.log(`skipped: ${ROOT} not found`); process.exit(0); } const SR = 44100; { const b = loadBundle('woodblock', 'woodblock'); const t0 = performance.now(); const [soft, hard] = renderNoteBatch(b, [ { pitch: 76, velocity: 0.25, durationSec: 0.25 }, { pitch: 76, 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] = renderNoteBatch(b, [{ pitch: 76, 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'); // Pitch by geometric scaling: an octave down halves the dominant mode. const [lo, hi] = renderNoteBatch(b, [ { pitch: 64, velocity: 0.8, durationSec: 0.25 }, { pitch: 76, velocity: 0.8, durationSec: 0.25 }, ], { sampleRate: SR, quality: 'preview', settings: {} }); const fLo = peakFreq(lo, SR, 300, 4000); const fHi = peakFreq(hi, SR, 600, 8000); const ratio = fHi / fLo; check(ratio > 1.94 && ratio < 2.06, `octave apart in pitch is an octave apart in frequency (${fLo} Hz -> ${fHi} Hz)`); } { const b = loadBundle('bass', 'bass'); const t0 = performance.now(); const [y] = renderNoteBatch(b, [{ 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] = renderNoteBatch(b, [{ 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'); // The muted variant of the same repo is a distinct bundle with its own driver. const muted = loadBundle('bass', 'bass-muted'); const [ym] = renderNoteBatch(muted, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], { sampleRate: SR, quality: 'preview', settings: {}, }); check(finite(ym) && peak(ym) > 0, 'muted bass sounds'); check(ym.length < y.length, `muted bass rings shorter (${ym.length} vs ${y.length} samples)`); } { // The recorder exercises the prepare stage (fingering chart in state) // at a low sample rate to keep the jet integration quick. const b = loadBundle('recorder', 'recorder'); const t0 = performance.now(); const [g5] = renderNoteBatch(b, [{ pitch: 79, velocity: 0.6, durationSec: 0.3 }], { sampleRate: 22050, quality: 'preview', settings: {}, }); console.log(`recorder: one note in ${((performance.now() - t0) / 1000).toFixed(1)} s`); check(finite(g5) && peak(g5) > 0, 'recorder note sounds (prepare state reached the driver)'); } console.log(failures === 0 ? 'exported-instrument test passed' : `${failures} failure(s)`); process.exit(failures > 0 ? 1 : 0);