2 * The test suite. Two layers:
3 *
4 * - unit checks on the host: the expression evaluator, the bake container
5 * round-trip, the bake key's tuning-blindness.
6 * - physics validation: each instrument, rendered from its own bake, must
7 * match the timestepped soundbox solver for the equivalent scene, run
8 * through numbl's MATLAB-to-WebGPU pipeline on the same device. That is
9 * the sense in which numbl stays the source of truth for the physics:
10 * the instrument files' hand-parameterized WGSL is held, numerically,
11 * to the compiled solver's output. The gate is soundbox's own 5e-3.
12 *
13 * The soundbox import assumes the vault layout (a sibling checkout at
14 * ../soundbox with its node_modules installed).
15 */
16import { parseBake, serializeBake } from '../src/host/bake.ts';
17import { runPhase } from '../src/host/engine.ts';
18import { raymarchWgsl, slotPartials } from '../src/host/view.ts';
19import { spectrumOf } from '../src/fft.ts';
20import { evalDerived, evalExpr } from '../src/expr.ts';
21import { requestDevice } from '../src/host/device.ts';
22import { InstrumentSession } from '../src/host/session.ts';
23import { parseInstrument, paramValues, type InstrumentFile } from '../src/format.ts';
24import { buildInstrument } from '../author/build.ts';
25import { errMsg, installWebGpu } from './nodeWebGpu.ts';
26import { readFileSync } from 'node:fs';
28// soundbox: the same scenes through the numbl pipeline's timestepped solver.
29import { StudioSession, specParams } from '../../soundbox/src/studio/session.ts';
30import { presetByKey, cloneSpec } from '../../soundbox/src/studio/presets.ts';
31import type { Params, SceneSpec } from '../../soundbox/src/studio/types.ts';
33let passed = 0;
34let failed = 0;
35const check = (name: string, ok: boolean, detail?: string): void => {
36 console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${!ok && detail ? ` — ${detail}` : ''}`);
37 if (ok) passed++;
38 else failed++;
39};
41const relErr = (a: Float32Array, b: Float32Array, n: number): number => {
42 let d = 0;
43 let r = 0;
44 for (let i = 0; i < n; i++) {
45 d += (a[i] - b[i]) * (a[i] - b[i]);
46 r += b[i] * b[i];
47 }
48 return Math.sqrt(d / Math.max(r, 1e-300));
49};
51/* ------------------------------------------------------- unit checks --- */
53console.log('host checks');
54{
55 const s = { nx: 96, Lx: 1 };
56 check('expr arithmetic', evalExpr('nx / 2 + 3 * 4', s) === 60);
57 check('expr functions', evalExpr('max(5, min(256, floor(0.32 / 0.002) + 1))', {}) === 161);
58 check('expr clamp/round', evalExpr('clamp(round(2.6), 0, 2)', {}) === 2);
59 let threw = false;
60 try {
61 evalExpr('nx + undefined_name', s);
62 } catch {
63 threw = true;
64 }
65 check('expr rejects unknown names', threw);
66}
67{
68 const bake = {
69 key: 'k',
70 instrument: 'x',
71 scope: { a: 1.5 },
72 arrays: { kernels: Float32Array.from([1, 2, 3.5]), other: Float32Array.from([-1]) },
73 };
74 const round = parseBake(serializeBake(bake));
75 check(
76 'bake container round-trips',
77 round.key === 'k' &&
78 round.scope.a === 1.5 &&
79 round.arrays.kernels.length === 3 &&
80 round.arrays.kernels[2] === 3.5 &&
81 round.arrays.other[0] === -1,
82 );
83}
85const instruments: Record<string, InstrumentFile> = {};
86for (const name of ['drum', 'dulcimer', 'chime']) {
87 const text = readFileSync(new URL(`../instruments/${name}.instrument.json`, import.meta.url), 'utf8');
88 instruments[name] = parseInstrument(text);
89 const rebuilt = buildInstrument(
90 name === 'drum'
91 ? { name: 'drum', description: instruments[name].description, object: 'membrane', body: 'shell' }
92 : name === 'dulcimer'
93 ? { name, description: instruments[name].description, object: 'string', body: 'box', defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 } }
94 : { name, description: instruments[name].description, object: 'bar', body: 'none', defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 } },
95 );
96 check(
97 `${name}.instrument.json is the builder's output`,
98 `${JSON.stringify(rebuilt, null, 2)}\n` === text,
99 'run npm run author',
100 );
101}
103console.log(await installWebGpu());
104const device = await requestDevice();
106{
107 const sess = (v: Record<string, number>): InstrumentSession =>
108 new InstrumentSession(device, instruments.drum, paramValues(instruments.drum, v));
109 const kA = await sess({}).bakeKey();
110 const kB = await sess({ f0: 200, strikex: 0.7, t60: 0.4 }).bakeKey();
111 const kC = await sess({ shellr: 0.2 }).bakeKey();
112 const kD = await sess({ mic_x: 0.1 }).bakeKey();
113 const kE = await sess({ nx: 64 }).bakeKey();
114 check('tuning does not touch the bake key', kA === kB);
115 check('geometry touches the bake key', kA !== kC && kA !== kD);
116 check('accuracy touches the bake key', kA !== kE);
117}
119/* -------------------------------------------- solver-matched renders --- */
121const NX = 32;
122const STEPS = 6000;
124/** The timestepped reference: the equivalent soundbox scene, compiled by
125 * numbl, stepped on the same device, recorded at the microphone. */
126async function reference(
127 presetKey: string,
128 edits: Params,
129 mic: { x: number; y: number; z: number },
130): Promise<Float32Array> {
131 const spec: SceneSpec = cloneSpec(presetByKey(presetKey)!.spec);
132 const s = await StudioSession.create({ device, spec, nx: NX, params: edits });
133 s.setMic(mic.x, mic.y, mic.z);
134 s.excite();
135 s.step(STEPS);
136 await s.sync();
137 const trace = await s.recorder.read();
138 s.destroy();
139 return trace;
140}
142interface Case {
143 name: string;
144 preset: string;
145 /** instrawave parameter overrides beyond nx/ir_seconds. */
146 set: Record<string, number>;
147 /** the same edits in soundbox's suffixed names. */
148 edits: Params;
149 mic: { x: number; y: number; z: number };
150}
152const mic1 = { x: 0.12, y: 0.08, z: 0.1 };
153const micDrum = { x: 0.15, y: 0.1, z: 0.12 };
154const cases: Case[] = [
155 { name: 'chime', preset: 'chime', set: {}, edits: {}, mic: mic1 },
156 { name: 'dulcimer', preset: 'dulcimer', set: {}, edits: {}, mic: mic1 },
157 { name: 'drum', preset: 'drum', set: {}, edits: {}, mic: micDrum },
158];
160console.log(`\nagainst the timestepped solver (nx = ${NX}, ${STEPS} steps)`);
161const bakes: Record<string, Awaited<ReturnType<InstrumentSession['precompute']>>> = {};
162for (const c of cases) {
163 const inst = instruments[c.name];
164 const session = new InstrumentSession(device, inst, paramValues(inst, c.set));
165 const dt = session.dt;
166 // A kernel at least as long as the trace, so truncation plays no part.
167 session.values = paramValues(inst, {
168 ...c.set,
169 nx: NX,
170 ir_seconds: (STEPS + 100) * dt,
171 ...(c.name === 'drum' ? { mode_margin: 4 } : {}),
172 });
173 const bake = await session.precompute();
174 bakes[c.name] = bake;
175 const { trace } = await session.render(bake, STEPS * session.dt);
176 const ref = await reference(c.preset, c.edits, c.mic);
177 const n = Math.min(trace.length, ref.length, STEPS);
178 const err = relErr(trace, ref, n);
179 console.log(` ${c.name}: ${bake.scope.n_rows} kernel rows; rel err ${err.toExponential(2)}`);
180 check(`${c.name} matches the solver`, err < 5e-3, `rel err ${err.toExponential(2)}`);
181}
183// The default (geometric) drum mode set, as a number: what the band limit
184// at the default mode_margin costs against the full solver. Measured on the
185// development machine: 5.3e-2 at margin 1.25, 4.0e-3 at 2 (the default),
186// and a 2.4e-3 f32 floor from 2.5 up.
187{
188 const inst = instruments.drum;
189 const margin = inst.params.find((p) => p.key === 'mode_margin')!.value;
190 const session = new InstrumentSession(device, inst, paramValues(inst, {}));
191 const dt = session.dt;
192 session.values = paramValues(inst, { nx: NX, ir_seconds: (STEPS + 100) * dt });
193 const bake = await session.precompute();
194 const { trace } = await session.render(bake, STEPS * session.dt);
195 const ref = await reference('drum', {}, micDrum);
196 const err = relErr(trace, ref, Math.min(trace.length, ref.length, STEPS));
197 console.log(` drum at default mode_margin ${margin}: ${bake.scope.n_rows} rows; rel err ${err.toExponential(2)}`);
198 check('the default drum mode set stays close', err < 8e-3, `rel err ${err.toExponential(2)}`);
199}
201// Retune and restrike from the same bake: tuning moves, the bake does not.
202{
203 const inst = instruments.drum;
204 const set = { f0: 150, t60: 0.7, sig1: 0.002, strikex: 0.62, striker: 0.03 };
205 const session = new InstrumentSession(
206 device,
207 inst,
208 paramValues(inst, {
209 ...set,
210 nx: NX,
211 ir_seconds: (STEPS + 100) * new InstrumentSession(device, inst, paramValues(inst, {})).dt,
212 mode_margin: 4,
213 }),
214 );
215 const { trace } = await session.render(bakes.drum, STEPS * session.dt);
216 const ref = await reference(
217 'drum',
218 { f0_m1: 150, t60_m1: 0.7, sig1_m1: 0.002, strikex_m1: 0.62, striker_m1: 0.03 },
219 micDrum,
220 );
221 const err = relErr(trace, ref, Math.min(trace.length, ref.length, STEPS));
222 console.log(` retuned, restruck drum from the same bake: rel err ${err.toExponential(2)}`);
223 check('the same bake serves a retuned drum', err < 5e-3, `rel err ${err.toExponential(2)}`);
224}
226// The visual section: the medium fields run headless through the same
227// engine, the raymarch shader compiles, the markers evaluate.
228console.log('\nvisual');
229for (const name of ['drum', 'dulcimer', 'chime'] as const) {
230 const inst = instruments[name];
231 const visual = inst.visual!;
232 // nx = 64: at 32 the default walls are under half a cell and the mask
233 // never saturates (a real property, which the viewer draws as a haze).
234 const scope = evalDerived(inst.derived, paramValues(inst, { nx: 64 }));
235 const fields = await runPhase({
236 device,
237 shaders: inst.shaders,
238 phase: { buffers: visual.buffers, setup: visual.setup },
239 scope,
240 outputs: visual.volumes.map((v) => v.buffer),
241 label: `${name}-visual`,
242 });
243 const wall = fields.wall;
244 let solid = 0;
245 let air = 0;
246 for (const v of wall) {
247 if (v < 0.5) solid++;
248 if (v > 0.95) air++;
249 }
250 const hasBody = name !== 'chime';
251 check(
252 `${name}: the medium fields draw ${hasBody ? 'a body in air' : 'open air'}`,
253 air > 0.5 * wall.length && (hasBody ? solid > 100 : solid === 0),
254 `${solid} solid cells, ${air} air cells of ${wall.length}`,
255 );
256 let profPeak = 0;
257 for (const v of fields[visual.volumes[1].buffer]) profPeak = Math.max(profPeak, Math.abs(v));
258 check(`${name}: the coupling profile is present`, profPeak > 0, `peak ${profPeak}`);
260 device.pushErrorScope('validation');
261 const module = device.createShaderModule({ code: raymarchWgsl(visual) });
262 await device.createRenderPipelineAsync({
263 layout: 'auto',
264 vertex: { module, entryPoint: 'vmain' },
265 fragment: {
266 module,
267 entryPoint: 'fmain',
268 targets: [{ format: 'bgra8unorm' }],
269 constants: {
270 NX: evalExpr(visual.dims[0], scope),
271 NY: evalExpr(visual.dims[1], scope),
272 NZ: evalExpr(visual.dims[2], scope),
273 },
274 },
275 });
276 const err = await device.popErrorScope();
277 check(`${name}: the raymarch shader compiles`, err === null, err?.message);
278 const half = visual.extents.map((e) => evalExpr(e, scope) / 2);
279 let markersOk = true;
280 for (const m of visual.markers) {
281 const pts = m.kind === 'point' ? [m.at] : m.kind === 'line' ? [m.from, m.to] : [m.corner];
282 for (const p of pts) {
283 const v = p.map((x) => evalExpr(x, scope));
284 if (v.some((x, i) => !Number.isFinite(x) || Math.abs(x) > half[i] * 1.001)) markersOk = false;
285 }
286 }
287 check(`${name}: the markers land inside the domain`, markersOk);
288}
290// The spectrum tools: a pure sine peaks at its own bin, and a rendered
291// chime's strongest measured peak lands on the strongest predicted partial.
292{
293 const dt = 1 / 48000;
294 const sine = new Float32Array(1 << 15);
295 for (let i = 0; i < sine.length; i++) sine[i] = Math.sin(2 * Math.PI * 440 * i * dt);
296 const { db, binHz } = spectrumOf(sine, dt);
297 let best = 0;
298 for (let i = 1; i < db.length; i++) if (db[i] > db[best]) best = i;
299 check(
300 'the spectrum of a 440 Hz sine peaks at 440 Hz',
301 Math.abs(best * binHz - 440) < 2 * binHz,
302 `peak at ${(best * binHz).toFixed(1)} Hz`,
303 );
304}
306// Stimuli are LTI: two events must equal the sum of their single renders.
307{
308 const inst = instruments.chime;
309 const session = new InstrumentSession(device, inst, paramValues(inst, { nx: NX }));
310 const dt = session.dt;
311 const bake = await session.precompute();
312 const seconds = 0.4;
313 const t1 = 0.15;
314 const evB = { t: t1, set: { f1: 620, strikepos: 0.44 } };
315 const both = await session.render(bake, seconds, [{ t: 0 }, evB]);
316 const a = await session.render(bake, seconds, [{ t: 0 }]);
317 const b = await session.render(bake, seconds, [evB]);
318 const sum = new Float32Array(both.trace.length);
319 for (let i = 0; i < sum.length; i++) sum[i] = a.trace[i] + b.trace[i];
320 const err = relErr(both.trace, sum, sum.length);
321 console.log(` two stimuli vs summed singles: rel err ${err.toExponential(2)}`);
322 check('stimuli superpose', err < 1e-5, `rel err ${err.toExponential(2)}`);
323 let head = 0;
324 const startB = Math.round(t1 / dt);
325 for (let i = 0; i < Math.min(startB, b.trace.length); i++) head = Math.max(head, Math.abs(b.trace[i]));
326 check('a later event is silent before its time', head === 0, `peak ${head}`);
328 // The predicted partials, probed from the slots buffer, against the
329 // measured spectrum of the same render.
330 const vp = inst.visual!.partials!;
331 const r = await session.render(bake, 1.2, [{ t: 0 }], [vp.buffer]);
332 const partials = slotPartials(r.probes[vp.buffer], vp, r.dt);
333 const { db, binHz } = spectrumOf(r.trace, r.dt);
334 let best = 0;
335 for (let i = 1; i < db.length; i++) if (db[i] > db[best]) best = i;
336 const peakHz = best * binHz;
337 const nearest = partials.reduce((a, p) => (Math.abs(p.freq - peakHz) < Math.abs(a.freq - peakHz) ? p : a), partials[0]);
338 console.log(
339 ` chime partials: ${partials.map((p) => p.freq.toFixed(0)).join(', ')} Hz; ` +
340 `measured peak ${peakHz.toFixed(0)} Hz`,
341 );
342 check(
343 'the measured peak lands on a predicted partial',
344 partials.length >= 1 && Math.abs(peakHz - nearest.freq) < Math.max(3 * binHz, 0.01 * nearest.freq),
345 `peak ${peakHz.toFixed(1)} Hz vs nearest predicted ${nearest?.freq.toFixed(1)} Hz`,
346 );
347}
349console.log(`\n${passed} passed, ${failed} failed`);
350process.exit(failed === 0 ? 0 : 1);