/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / test / checks.ts
434 lines · 16.3 KBCodeBlameHistory
2 * The checks, against the real pipeline: SceneSpec -> generated MATLAB ->
3 * numbl lowering -> generated WGSL -> GPU. Physics first — does each kind of
4 * object sound the pitch it is tuned to, does the sealed box actually seal,
5 * do two objects in one scene both reach the microphone — and the planner's
6 * mechanics after.
7 *
8 * Everything runs on deliberately small grids: these are correctness checks,
9 * and a 32-point air grid already carries every code path the 128-point one
10 * does.
11 */
e18e57eRender scenes outside the browserJeremy Magland 12import { StudioSession, specParams } from '../src/studio/session.ts';
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 13import { cloneSpec, presetByKey } from '../src/studio/presets.ts';
e18e57eRender scenes outside the browserJeremy Magland 14import { makeSceneFile, parseSceneFile, serializeSceneFile } from '../src/studio/scenefile.ts';
15import { generate } from '../src/studio/codegen.ts';
16import { makeAirGrid, stableDt } from '../src/grids.ts';
17import { C_AIR, CFL } from '../src/units.ts';
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 18import type { Params, SceneSpec } from '../src/studio/types.ts';
19import { planPlayback } from '../src/audio/play.ts';
21type Check = (name: string, ok: boolean, detail: string) => void;
22type Log = (s: string) => void;
24const peakOf = (a: Float32Array): number => {
25 let m = 0;
26 for (const v of a) m = Math.max(m, Math.abs(v));
27 return m;
28};
30const spec = (key: string): SceneSpec => cloneSpec(presetByKey(key)!.spec);
32async function makeSession(
33 device: GPUDevice,
34 sceneSpec: SceneSpec,
35 nx: number,
36 params: Params = {},
37 sceneParams: Params = {},
38 operandBudget?: number,
39): Promise<StudioSession> {
40 return StudioSession.create({
41 device, spec: sceneSpec, nx, params, sceneParams, operandBudget,
42 });
45/** Frequency content of a series sampled every `sdt` seconds. */
46function dftAt(series: number[], sdt: number, f: number): number {
47 let re = 0;
48 let im = 0;
49 for (let i = 0; i < series.length; i++) {
50 const w = 2 * Math.PI * f * i * sdt;
51 re += series[i] * Math.cos(w);
52 im -= series[i] * Math.sin(w);
53 }
54 return Math.hypot(re, im) / series.length;
57/** Run a session and sample one state element every `every` steps. */
58async function sampleField(
59 s: StudioSession,
60 field: string,
61 index: number,
62 totalSteps: number,
63 every: number,
64): Promise<{ series: number[]; sdt: number }> {
65 const series: number[] = [];
66 for (let done = 0; done < totalSteps; done += every) {
67 s.step(every);
68 const u = await s.read(field);
69 series.push(u[index]);
70 }
71 return { series, sdt: every * s.dt };
74/** The compiled plan: per-object external ops present, excitation in place,
75 * mic riding along. */
76export async function planChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
77 const s = await makeSession(device, spec('dulcimer'), 32);
78 const { step } = s.describe();
79 for (const l of step) log(` ${l}`);
80 const externals = step.filter((l) => l.startsWith('external'));
81 const wanted = ['dxx_s1(u_s1)', 'dxx_s1(um_s1)', 'dxxxx_s1(u_s1)', 'rad_s1(acc_s1)', 'brg_s1(un_s1)', 'lapw(p, wall)'];
82 for (const w of wanted) {
83 check(
84 `the step uses ${w}`,
85 externals.some((l) => l.includes(w)),
86 externals.length ? externals.join('; ') : 'no external ops planned',
87 );
88 }
90 s.excite();
91 const u = await s.read('u_s1');
92 const amp = s.params.amp_s1;
93 const peak = peakOf(u);
94 check(
95 'the pluck draws the string to its set height',
96 Math.abs(peak - amp) < 0.15 * amp,
97 `peak |u| = ${peak.toExponential(3)}, amp = ${amp}`,
98 );
99 check('the ends are pinned', u[0] === 0 && u[u.length - 1] === 0, `u[0]=${u[0]}, u[end]=${u[u.length - 1]}`);
101 s.step(100);
102 check(
103 'the microphone samples once per timestep',
104 s.recorder.count === 100,
105 `${s.recorder.count} samples after 100 steps`,
106 );
107 const plan = planPlayback(s.recorder.count, s.dt);
108 check(
109 'playback is real time at the solver rate',
110 plan.realTime && Math.abs(plan.rate * s.dt - 1) < 1e-9,
111 `rate ${plan.rate.toFixed(0)} Hz, dt ${s.dt.toExponential(3)}`,
112 );
113 s.destroy();
116/** The generated sources look like what the Lab view promises. */
117export async function codegenChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
118 const s = await makeSession(device, spec('duet'), 32);
119 const m = s.generated.modelSource;
120 const sc = s.generated.sceneSource;
121 check(
122 'the generated model carries both strings',
123 m.includes('un_s1 = pin_s1') && m.includes('un_s2 = pin_s2'),
124 'looked for un_s1 and un_s2 updates',
125 );
126 check(
127 'the generated scene builds a profile per object',
128 sc.includes('prof_s1 =') && sc.includes('prof_s2 =') && sc.includes('bprof_s2 ='),
129 'looked for prof_s1, prof_s2, bprof_s2',
130 );
131 log(` model: ${m.split('\n').length} lines, scene: ${sc.split('\n').length} lines`);
132 s.destroy();
e18e57eRender scenes outside the browserJeremy Magland 135/** A saved scene file round-trips: same spec, same generated MATLAB, and a
136 * malformed file is refused with a named error rather than a compile
137 * failure three layers down. */
138export async function sceneFileChecks(_device: GPUDevice, check: Check, log: Log): Promise<void> {
139 const original = spec('duet');
140 const defaults = specParams(original);
141 const file = makeSceneFile({
142 spec: original,
143 params: { ...defaults.params, f0_s1: 300 },
144 sceneParams: { ...defaults.sceneParams, oy_s1: -0.05 },
145 mic: { x: 0.1, y: 0.05, z: 0.08 },
146 nx: 96,
147 });
148 const parsed = parseSceneFile(serializeSceneFile(file));
149 check(
150 'a scene file round-trips its values',
151 parsed.params.f0_s1 === 300 && parsed.sceneParams.oy_s1 === -0.05 &&
152 parsed.nx === 96 && parsed.spec.objects.length === 2,
153 `f0_s1 = ${parsed.params.f0_s1}, oy_s1 = ${parsed.sceneParams.oy_s1}`,
154 );
156 const air = makeAirGrid(32, 1.0);
157 const dt = stableDt(air.h, C_AIR, CFL);
158 const a = generate(original, air, dt);
159 const b = generate(parsed.spec, air, dt);
160 check(
161 'a round-tripped spec generates identical MATLAB',
162 a.modelSource === b.modelSource && a.sceneSource === b.sceneSource,
163 `model ${a.modelSource === b.modelSource ? 'matches' : 'DIFFERS'}, ` +
164 `scene ${a.sceneSource === b.sceneSource ? 'matches' : 'DIFFERS'}`,
165 );
167 const bad = [
168 ['{}', 'format marker'],
169 ['{"soundbox":1,"spec":{"objects":[],"body":{"type":"none"}},"mic":{"x":0,"y":0,"z":0},"nx":32}', 'non-empty'],
170 ['{"soundbox":1,"spec":{"objects":[{"id":"s1","type":"gong","size":{"L1":0.3}}],"body":{"type":"none"}},"mic":{"x":0,"y":0,"z":0},"nx":32}', 'unknown type'],
171 ] as const;
172 for (const [text, wants] of bad) {
173 let msg = '';
174 try {
175 parseSceneFile(text);
176 } catch (e) {
177 msg = e instanceof Error ? e.message : String(e);
178 }
179 check(
180 `a malformed scene file is refused (${wants})`,
181 msg.includes(wants),
182 msg || 'no error was thrown',
183 );
184 }
185 log(' scene file round-trip and refusals exercised');
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 188/** A string sounds the pitch it is tuned to. */
189export async function stringChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
190 // An ideal string (no stiffness, no damping to speak of), watched at a
191 // point for a dozen periods; the zero crossings say the frequency.
192 const f0 = 294;
193 const s = await makeSession(device, spec('dulcimer'), 32, {
194 f0_s1: f0, B_s1: 0, sig1_s1: 0, t60_s1: 8,
195 });
196 s.excite();
197 const g = s.grids.s1;
198 const node = Math.round(g.n1 * 0.4);
199 const periods = 12;
200 const stepsTotal = Math.round(periods / f0 / s.dt);
201 const { series } = await sampleField(s, 'u_s1', node, stepsTotal, 10);
202 let crossings = 0;
203 for (let i = 1; i < series.length; i++) {
204 if ((series[i - 1] < 0 && series[i] >= 0) || (series[i - 1] >= 0 && series[i] < 0)) crossings++;
205 }
206 const measured = crossings / 2 / (stepsTotal * s.dt);
207 log(` ${crossings} zero crossings over ${(stepsTotal * s.dt * 1000).toFixed(1)} ms -> ${measured.toFixed(1)} Hz`);
208 check(
209 `the string sounds its fundamental (${f0} Hz)`,
210 Math.abs(measured - f0) < 0.04 * f0,
211 `measured ${measured.toFixed(1)} Hz`,
212 );
214 // The decay knob means what it says: at half of t60 the string should be
215 // down 30 dB.
216 const t60 = 0.5;
217 const s2 = await makeSession(device, spec('dulcimer'), 32, {
218 t60_s1: t60, B_s1: 0, sig1_s1: 0,
219 });
220 s2.excite();
221 const before = peakOf(await s2.read('u_s1'));
222 s2.step(Math.round(t60 / 2 / s2.dt));
223 const after = peakOf(await s2.read('u_s1'));
224 const db = 20 * Math.log10(after / before);
225 log(` |u| ${before.toExponential(2)} -> ${after.toExponential(2)} in ${t60 / 2} s (${db.toFixed(1)} dB)`);
226 check(
227 't60 decays the string on schedule',
228 Math.abs(db + 30) < 4,
229 `${db.toFixed(1)} dB over t60/2, want -30`,
230 );
231 s.destroy();
232 s2.destroy();
235/**
236 * A bar rings at the frequencies its own discrete dispersion relation
237 * predicts. The bar grid is coarse by construction (an explicit stiff scheme
238 * wants coarse grids), so the continuous n² f1 comb is audibly detuned; what
239 * must hold exactly is the scheme's own relation: mode n of the pinned
240 * fourth-difference operator has eigenvalue lam_n = (4/h²) sin²(n π h / 2L),
241 * and rings at f_n = kap·lam_n / 2π. This is the "you can hear the
242 * dispersion" check: the same numbers the Lab spectrum overlay draws.
243 */
244export async function barChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
245 const f1 = 780;
246 const s = await makeSession(device, spec('chime'), 32, {
247 f1_b1: f1, sig1_b1: 0, t60_b1: 12,
248 });
249 s.excite();
250 const g = s.grids.b1;
251 const kap = (2 * f1 * g.L1 * g.L1) / Math.PI;
252 const lam = (n: number): number =>
253 (4 / (g.h1 * g.h1)) * Math.sin((n * Math.PI * g.h1) / (2 * g.L1)) ** 2;
254 const fd = (n: number): number => (kap * lam(n) * lam(n) === 0 ? 0 : (kap * lam(n)) / (2 * Math.PI));
255 const node = Math.round(g.n1 * 0.37);
256 const total = Math.round(0.06 / s.dt);
257 const { series, sdt } = await sampleField(s, 'u_b1', node, total, 4);
258 const comb = (dftAt(series, sdt, fd(1)) + dftAt(series, sdt, fd(2))) / 2;
259 const off =
260 (dftAt(series, sdt, 1.4 * fd(1)) + dftAt(series, sdt, Math.sqrt(fd(1) * fd(2)))) / 2;
261 log(
262 ` discrete modes ${fd(1).toFixed(0)}, ${fd(2).toFixed(0)} Hz ` +
263 `(continuous would be ${f1}, ${4 * f1}); comb ${comb.toExponential(2)}, off ${off.toExponential(2)}`,
264 );
265 check(
266 'the bar rings where its discrete dispersion relation says',
267 comb > 5 * off,
268 `comb/off = ${(comb / off).toFixed(1)}`,
269 );
270 s.destroy();
273/** A membrane struck at its centre sounds its (1,1) fundamental. */
274export async function membraneChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
275 const f0 = 110;
276 const s = await makeSession(device, spec('drum'), 32, {
277 f0_m1: f0, sig1_m1: 0, t60_m1: 6, strikex_m1: 0.5, strikey_m1: 0.5,
278 });
279 s.excite();
280 const g = s.grids.m1;
281 const centre = Math.round(g.n1 / 2) + g.n1 * Math.round(g.n2 / 2);
282 const total = Math.round(10 / f0 / s.dt);
283 const { series, sdt } = await sampleField(s, 'u_m1', centre, total, 10);
284 const at = dftAt(series, sdt, f0);
285 const off = (dftAt(series, sdt, 1.31 * f0) + dftAt(series, sdt, 0.71 * f0)) / 2;
286 log(` membrane at f0 ${at.toExponential(2)}, off ${off.toExponential(2)}`);
287 check(
288 `the membrane sounds its (1,1) mode (${f0} Hz)`,
289 at > 4 * off,
290 `on/off = ${(at / off).toFixed(1)}`,
291 );
292 s.destroy();
295/** The wall mask is a wall: a sealed box keeps the sound out. */
296export async function wallChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
297 // Bridge drive only (the direct route off), so the source sits above the
298 // plate; a microphone inside a sealed box should hear far less than one
299 // outside beside it.
300 const seconds = 0.06;
301 const run = async (mic: [number, number, number], holer: number): Promise<number> => {
302 const s = await makeSession(
303 device,
304 spec('dulcimer'),
305 64,
306 { gair_s1: 0, gbridge_s1: 1 },
307 { holer, absorb: 0, boxd: 0.12, thick: 0.02 },
308 );
309 s.setMic(...mic);
310 s.excite();
311 s.step(Math.round(seconds / s.dt));
312 const trace = await s.recorder.read();
313 s.destroy();
314 return peakOf(trace);
315 };
316 const inside = await run([0, 0, -0.06], 0);
317 const outside = await run([0, 0, 0.08], 0);
318 log(` |p| inside the sealed box ${inside.toExponential(2)}, outside ${outside.toExponential(2)}`);
319 check(
320 'a sealed box keeps the sound out',
321 inside < 0.05 * outside,
322 `inside/outside = ${(inside / outside).toExponential(2)}`,
323 );
325 const open = await run([0, 0, -0.06], 0.05);
326 log(` |p| in the cavity: sealed ${inside.toExponential(2)}, open ${open.toExponential(2)}`);
327 check(
328 'the sound hole lets the cavity speak',
329 open > 5 * inside,
330 `open/sealed = ${(open / inside).toExponential(2)}`,
331 );
334/** Two objects in one scene both reach the microphone. */
335export async function duetChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
336 const s = await makeSession(device, spec('duet'), 64, {
337 B_s1: 0, B_s2: 0, sig1_s1: 0, sig1_s2: 0,
338 });
339 s.setMic(0.12, 0.08, 0.1);
340 s.excite();
341 const seconds = 0.12;
342 const total = Math.round(seconds / s.dt);
343 for (let done = 0; done < total; done += 2048) {
344 s.step(Math.min(2048, total - done));
345 await s.sync();
346 }
347 const trace = await s.recorder.read();
348 const series = [...trace];
349 const at = (f: number): number => dftAt(series, s.dt, f);
350 const s1 = at(294);
351 const s2 = at(441);
352 const off = (at(360) + at(520)) / 2;
353 log(` duet: 294 Hz ${s1.toExponential(2)}, 441 Hz ${s2.toExponential(2)}, off ${off.toExponential(2)}`);
354 check('the first string reaches the microphone', s1 > 4 * off, `294/off = ${(s1 / off).toFixed(1)}`);
355 check('the second string reaches the microphone', s2 > 4 * off, `441/off = ${(s2 / off).toFixed(1)}`);
356 s.destroy();
359/** Moving an object is a scene edit: no recompile, and the profile follows. */
360export async function placementChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
361 const s = await makeSession(device, spec('bare'), 32);
362 const before = s.scene.fields.prof_s1.slice();
363 s.setScene({ ...s.sceneParams, oy_s1: 0.1 });
364 const after = s.scene.fields.prof_s1;
365 let moved = 0;
366 for (let i = 0; i < before.length; i++) {
367 if (Math.abs(before[i] - after[i]) > 1e-6) moved++;
368 }
369 log(` ${moved} profile cells changed after moving the string`);
370 check('moving an object moves its coupling profile', moved > 0, `${moved} cells changed`);
371 s.excite();
372 s.step(50);
373 const p = await s.read('p');
374 let bad = 0;
375 for (const v of p) if (!Number.isFinite(v)) bad++;
376 check('the moved scene still runs', bad === 0, `${bad} non-finite values`);
377 s.destroy();
380/** Air sound sits on the string's partial comb, not somewhere else. */
381export async function spectrumChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
382 const f0 = 294;
383 const s = await makeSession(device, spec('dulcimer'), 64, { f0_s1: f0 });
384 s.setMic(0.12, 0.08, 0.1);
385 s.excite();
386 const seconds = 0.12;
387 const total = Math.round(seconds / s.dt);
388 for (let done = 0; done < total; done += 2048) {
389 s.step(Math.min(2048, total - done));
390 await s.sync();
391 }
392 const trace = await s.recorder.read();
393 const series = [...trace];
394 const comb = (dftAt(series, s.dt, f0) + dftAt(series, s.dt, 2 * f0) + dftAt(series, s.dt, 3 * f0)) / 3;
395 const off = (dftAt(series, s.dt, 1.41 * f0) + dftAt(series, s.dt, 2.53 * f0)) / 2;
396 log(` comb ${comb.toExponential(2)}, off-comb ${off.toExponential(2)}`);
397 check(
398 'the microphone hears the string’s partials',
399 comb > 8 * off,
400 `comb/off = ${(comb / off).toFixed(1)}`,
401 );
402 s.destroy();
405/** A starved operand budget splits kernels instead of failing. */
406export async function splitChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
407 const s = await makeSession(device, spec('dulcimer'), 32, {}, {}, 2);
408 const { step } = s.describe();
409 const parts = step.filter((l) => l.includes('_part')).length;
410 log(` ${parts} split kernels at budget 2`);
411 check('a tight binding budget splits the update', parts > 0, `${parts} split kernels`);
412 s.excite();
413 s.step(50);
414 const p = await s.read('p');
415 let bad = 0;
416 for (const v of p) if (!Number.isFinite(v)) bad++;
417 check('the split model still runs', bad === 0, `${bad} non-finite values`);
419 // The split result must agree with the unsplit one.
420 const s2 = await makeSession(device, spec('dulcimer'), 32);
421 s2.excite();
422 s2.step(50);
423 const p2 = await s2.read('p');
424 let worst = 0;
425 const scale = Math.max(peakOf(p), 1e-30);
426 for (let i = 0; i < p.length; i++) worst = Math.max(worst, Math.abs(p[i] - p2[i]));
427 check(
428 'split and unsplit agree',
429 worst < 1e-4 * scale,
430 `worst |diff| = ${worst.toExponential(2)} of peak ${scale.toExponential(2)}`,
431 );
432 s.destroy();
433 s2.destroy();
↑↓ move↵openescclose