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 */
12import { StudioSession } from '../src/studio/session.ts';
13import { cloneSpec, presetByKey } from '../src/studio/presets.ts';
14import type { Params, SceneSpec } from '../src/studio/types.ts';
15import { planPlayback } from '../src/audio/play.ts';
17type Check = (name: string, ok: boolean, detail: string) => void;
18type Log = (s: string) => void;
20const peakOf = (a: Float32Array): number => {
21 let m = 0;
22 for (const v of a) m = Math.max(m, Math.abs(v));
23 return m;
24};
26const spec = (key: string): SceneSpec => cloneSpec(presetByKey(key)!.spec);
28async function makeSession(
29 device: GPUDevice,
30 sceneSpec: SceneSpec,
31 nx: number,
32 params: Params = {},
33 sceneParams: Params = {},
34 operandBudget?: number,
35): Promise<StudioSession> {
36 return StudioSession.create({
37 device, spec: sceneSpec, nx, params, sceneParams, operandBudget,
38 });
39}
41/** Frequency content of a series sampled every `sdt` seconds. */
42function dftAt(series: number[], sdt: number, f: number): number {
43 let re = 0;
44 let im = 0;
45 for (let i = 0; i < series.length; i++) {
46 const w = 2 * Math.PI * f * i * sdt;
47 re += series[i] * Math.cos(w);
48 im -= series[i] * Math.sin(w);
49 }
50 return Math.hypot(re, im) / series.length;
51}
53/** Run a session and sample one state element every `every` steps. */
54async function sampleField(
55 s: StudioSession,
56 field: string,
57 index: number,
58 totalSteps: number,
59 every: number,
60): Promise<{ series: number[]; sdt: number }> {
61 const series: number[] = [];
62 for (let done = 0; done < totalSteps; done += every) {
63 s.step(every);
64 const u = await s.read(field);
65 series.push(u[index]);
66 }
67 return { series, sdt: every * s.dt };
68}
70/** The compiled plan: per-object external ops present, excitation in place,
71 * mic riding along. */
72export async function planChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
73 const s = await makeSession(device, spec('dulcimer'), 32);
74 const { step } = s.describe();
75 for (const l of step) log(` ${l}`);
76 const externals = step.filter((l) => l.startsWith('external'));
77 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)'];
78 for (const w of wanted) {
79 check(
80 `the step uses ${w}`,
81 externals.some((l) => l.includes(w)),
82 externals.length ? externals.join('; ') : 'no external ops planned',
83 );
84 }
86 s.excite();
87 const u = await s.read('u_s1');
88 const amp = s.params.amp_s1;
89 const peak = peakOf(u);
90 check(
91 'the pluck draws the string to its set height',
92 Math.abs(peak - amp) < 0.15 * amp,
93 `peak |u| = ${peak.toExponential(3)}, amp = ${amp}`,
94 );
95 check('the ends are pinned', u[0] === 0 && u[u.length - 1] === 0, `u[0]=${u[0]}, u[end]=${u[u.length - 1]}`);
97 s.step(100);
98 check(
99 'the microphone samples once per timestep',
100 s.recorder.count === 100,
101 `${s.recorder.count} samples after 100 steps`,
102 );
103 const plan = planPlayback(s.recorder.count, s.dt);
104 check(
105 'playback is real time at the solver rate',
106 plan.realTime && Math.abs(plan.rate * s.dt - 1) < 1e-9,
107 `rate ${plan.rate.toFixed(0)} Hz, dt ${s.dt.toExponential(3)}`,
108 );
109 s.destroy();
110}
112/** The generated sources look like what the Lab view promises. */
113export async function codegenChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
114 const s = await makeSession(device, spec('duet'), 32);
115 const m = s.generated.modelSource;
116 const sc = s.generated.sceneSource;
117 check(
118 'the generated model carries both strings',
119 m.includes('un_s1 = pin_s1') && m.includes('un_s2 = pin_s2'),
120 'looked for un_s1 and un_s2 updates',
121 );
122 check(
123 'the generated scene builds a profile per object',
124 sc.includes('prof_s1 =') && sc.includes('prof_s2 =') && sc.includes('bprof_s2 ='),
125 'looked for prof_s1, prof_s2, bprof_s2',
126 );
127 log(` model: ${m.split('\n').length} lines, scene: ${sc.split('\n').length} lines`);
128 s.destroy();
129}
131/** A string sounds the pitch it is tuned to. */
132export async function stringChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
133 // An ideal string (no stiffness, no damping to speak of), watched at a
134 // point for a dozen periods; the zero crossings say the frequency.
135 const f0 = 294;
136 const s = await makeSession(device, spec('dulcimer'), 32, {
137 f0_s1: f0, B_s1: 0, sig1_s1: 0, t60_s1: 8,
138 });
139 s.excite();
140 const g = s.grids.s1;
141 const node = Math.round(g.n1 * 0.4);
142 const periods = 12;
143 const stepsTotal = Math.round(periods / f0 / s.dt);
144 const { series } = await sampleField(s, 'u_s1', node, stepsTotal, 10);
145 let crossings = 0;
146 for (let i = 1; i < series.length; i++) {
147 if ((series[i - 1] < 0 && series[i] >= 0) || (series[i - 1] >= 0 && series[i] < 0)) crossings++;
148 }
149 const measured = crossings / 2 / (stepsTotal * s.dt);
150 log(` ${crossings} zero crossings over ${(stepsTotal * s.dt * 1000).toFixed(1)} ms -> ${measured.toFixed(1)} Hz`);
151 check(
152 `the string sounds its fundamental (${f0} Hz)`,
153 Math.abs(measured - f0) < 0.04 * f0,
154 `measured ${measured.toFixed(1)} Hz`,
155 );
157 // The decay knob means what it says: at half of t60 the string should be
158 // down 30 dB.
159 const t60 = 0.5;
160 const s2 = await makeSession(device, spec('dulcimer'), 32, {
161 t60_s1: t60, B_s1: 0, sig1_s1: 0,
162 });
163 s2.excite();
164 const before = peakOf(await s2.read('u_s1'));
165 s2.step(Math.round(t60 / 2 / s2.dt));
166 const after = peakOf(await s2.read('u_s1'));
167 const db = 20 * Math.log10(after / before);
168 log(` |u| ${before.toExponential(2)} -> ${after.toExponential(2)} in ${t60 / 2} s (${db.toFixed(1)} dB)`);
169 check(
170 't60 decays the string on schedule',
171 Math.abs(db + 30) < 4,
172 `${db.toFixed(1)} dB over t60/2, want -30`,
173 );
174 s.destroy();
175 s2.destroy();
176}
178/**
179 * A bar rings at the frequencies its own discrete dispersion relation
180 * predicts. The bar grid is coarse by construction (an explicit stiff scheme
181 * wants coarse grids), so the continuous n² f1 comb is audibly detuned; what
182 * must hold exactly is the scheme's own relation: mode n of the pinned
183 * fourth-difference operator has eigenvalue lam_n = (4/h²) sin²(n π h / 2L),
184 * and rings at f_n = kap·lam_n / 2π. This is the "you can hear the
185 * dispersion" check: the same numbers the Lab spectrum overlay draws.
186 */
187export async function barChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
188 const f1 = 780;
189 const s = await makeSession(device, spec('chime'), 32, {
190 f1_b1: f1, sig1_b1: 0, t60_b1: 12,
191 });
192 s.excite();
193 const g = s.grids.b1;
194 const kap = (2 * f1 * g.L1 * g.L1) / Math.PI;
195 const lam = (n: number): number =>
196 (4 / (g.h1 * g.h1)) * Math.sin((n * Math.PI * g.h1) / (2 * g.L1)) ** 2;
197 const fd = (n: number): number => (kap * lam(n) * lam(n) === 0 ? 0 : (kap * lam(n)) / (2 * Math.PI));
198 const node = Math.round(g.n1 * 0.37);
199 const total = Math.round(0.06 / s.dt);
200 const { series, sdt } = await sampleField(s, 'u_b1', node, total, 4);
201 const comb = (dftAt(series, sdt, fd(1)) + dftAt(series, sdt, fd(2))) / 2;
202 const off =
203 (dftAt(series, sdt, 1.4 * fd(1)) + dftAt(series, sdt, Math.sqrt(fd(1) * fd(2)))) / 2;
204 log(
205 ` discrete modes ${fd(1).toFixed(0)}, ${fd(2).toFixed(0)} Hz ` +
206 `(continuous would be ${f1}, ${4 * f1}); comb ${comb.toExponential(2)}, off ${off.toExponential(2)}`,
207 );
208 check(
209 'the bar rings where its discrete dispersion relation says',
210 comb > 5 * off,
211 `comb/off = ${(comb / off).toFixed(1)}`,
212 );
213 s.destroy();
214}
216/** A membrane struck at its centre sounds its (1,1) fundamental. */
217export async function membraneChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
218 const f0 = 110;
219 const s = await makeSession(device, spec('drum'), 32, {
220 f0_m1: f0, sig1_m1: 0, t60_m1: 6, strikex_m1: 0.5, strikey_m1: 0.5,
221 });
222 s.excite();
223 const g = s.grids.m1;
224 const centre = Math.round(g.n1 / 2) + g.n1 * Math.round(g.n2 / 2);
225 const total = Math.round(10 / f0 / s.dt);
226 const { series, sdt } = await sampleField(s, 'u_m1', centre, total, 10);
227 const at = dftAt(series, sdt, f0);
228 const off = (dftAt(series, sdt, 1.31 * f0) + dftAt(series, sdt, 0.71 * f0)) / 2;
229 log(` membrane at f0 ${at.toExponential(2)}, off ${off.toExponential(2)}`);
230 check(
231 `the membrane sounds its (1,1) mode (${f0} Hz)`,
232 at > 4 * off,
233 `on/off = ${(at / off).toFixed(1)}`,
234 );
235 s.destroy();
236}
238/** The wall mask is a wall: a sealed box keeps the sound out. */
239export async function wallChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
240 // Bridge drive only (the direct route off), so the source sits above the
241 // plate; a microphone inside a sealed box should hear far less than one
242 // outside beside it.
243 const seconds = 0.06;
244 const run = async (mic: [number, number, number], holer: number): Promise<number> => {
245 const s = await makeSession(
246 device,
247 spec('dulcimer'),
248 64,
249 { gair_s1: 0, gbridge_s1: 1 },
250 { holer, absorb: 0, boxd: 0.12, thick: 0.02 },
251 );
252 s.setMic(...mic);
253 s.excite();
254 s.step(Math.round(seconds / s.dt));
255 const trace = await s.recorder.read();
256 s.destroy();
257 return peakOf(trace);
258 };
259 const inside = await run([0, 0, -0.06], 0);
260 const outside = await run([0, 0, 0.08], 0);
261 log(` |p| inside the sealed box ${inside.toExponential(2)}, outside ${outside.toExponential(2)}`);
262 check(
263 'a sealed box keeps the sound out',
264 inside < 0.05 * outside,
265 `inside/outside = ${(inside / outside).toExponential(2)}`,
266 );
268 const open = await run([0, 0, -0.06], 0.05);
269 log(` |p| in the cavity: sealed ${inside.toExponential(2)}, open ${open.toExponential(2)}`);
270 check(
271 'the sound hole lets the cavity speak',
272 open > 5 * inside,
273 `open/sealed = ${(open / inside).toExponential(2)}`,
274 );
275}
277/** Two objects in one scene both reach the microphone. */
278export async function duetChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
279 const s = await makeSession(device, spec('duet'), 64, {
280 B_s1: 0, B_s2: 0, sig1_s1: 0, sig1_s2: 0,
281 });
282 s.setMic(0.12, 0.08, 0.1);
283 s.excite();
284 const seconds = 0.12;
285 const total = Math.round(seconds / s.dt);
286 for (let done = 0; done < total; done += 2048) {
287 s.step(Math.min(2048, total - done));
288 await s.sync();
289 }
290 const trace = await s.recorder.read();
291 const series = [...trace];
292 const at = (f: number): number => dftAt(series, s.dt, f);
293 const s1 = at(294);
294 const s2 = at(441);
295 const off = (at(360) + at(520)) / 2;
296 log(` duet: 294 Hz ${s1.toExponential(2)}, 441 Hz ${s2.toExponential(2)}, off ${off.toExponential(2)}`);
297 check('the first string reaches the microphone', s1 > 4 * off, `294/off = ${(s1 / off).toFixed(1)}`);
298 check('the second string reaches the microphone', s2 > 4 * off, `441/off = ${(s2 / off).toFixed(1)}`);
299 s.destroy();
300}
302/** Moving an object is a scene edit: no recompile, and the profile follows. */
303export async function placementChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
304 const s = await makeSession(device, spec('bare'), 32);
305 const before = s.scene.fields.prof_s1.slice();
306 s.setScene({ ...s.sceneParams, oy_s1: 0.1 });
307 const after = s.scene.fields.prof_s1;
308 let moved = 0;
309 for (let i = 0; i < before.length; i++) {
310 if (Math.abs(before[i] - after[i]) > 1e-6) moved++;
311 }
312 log(` ${moved} profile cells changed after moving the string`);
313 check('moving an object moves its coupling profile', moved > 0, `${moved} cells changed`);
314 s.excite();
315 s.step(50);
316 const p = await s.read('p');
317 let bad = 0;
318 for (const v of p) if (!Number.isFinite(v)) bad++;
319 check('the moved scene still runs', bad === 0, `${bad} non-finite values`);
320 s.destroy();
321}
323/** Air sound sits on the string's partial comb, not somewhere else. */
324export async function spectrumChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
325 const f0 = 294;
326 const s = await makeSession(device, spec('dulcimer'), 64, { f0_s1: f0 });
327 s.setMic(0.12, 0.08, 0.1);
328 s.excite();
329 const seconds = 0.12;
330 const total = Math.round(seconds / s.dt);
331 for (let done = 0; done < total; done += 2048) {
332 s.step(Math.min(2048, total - done));
333 await s.sync();
334 }
335 const trace = await s.recorder.read();
336 const series = [...trace];
337 const comb = (dftAt(series, s.dt, f0) + dftAt(series, s.dt, 2 * f0) + dftAt(series, s.dt, 3 * f0)) / 3;
338 const off = (dftAt(series, s.dt, 1.41 * f0) + dftAt(series, s.dt, 2.53 * f0)) / 2;
339 log(` comb ${comb.toExponential(2)}, off-comb ${off.toExponential(2)}`);
340 check(
341 'the microphone hears the string’s partials',
342 comb > 8 * off,
343 `comb/off = ${(comb / off).toFixed(1)}`,
344 );
345 s.destroy();
346}
348/** A starved operand budget splits kernels instead of failing. */
349export async function splitChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
350 const s = await makeSession(device, spec('dulcimer'), 32, {}, {}, 2);
351 const { step } = s.describe();
352 const parts = step.filter((l) => l.includes('_part')).length;
353 log(` ${parts} split kernels at budget 2`);
354 check('a tight binding budget splits the update', parts > 0, `${parts} split kernels`);
355 s.excite();
356 s.step(50);
357 const p = await s.read('p');
358 let bad = 0;
359 for (const v of p) if (!Number.isFinite(v)) bad++;
360 check('the split model still runs', bad === 0, `${bad} non-finite values`);
362 // The split result must agree with the unsplit one.
363 const s2 = await makeSession(device, spec('dulcimer'), 32);
364 s2.excite();
365 s2.step(50);
366 const p2 = await s2.read('p');
367 let worst = 0;
368 const scale = Math.max(peakOf(p), 1e-30);
369 for (let i = 0; i < p.length; i++) worst = Math.max(worst, Math.abs(p[i] - p2[i]));
370 check(
371 'split and unsplit agree',
372 worst < 1e-4 * scale,
373 `worst |diff| = ${worst.toExponential(2)} of peak ${scale.toExponential(2)}`,
374 );
375 s.destroy();
376 s2.destroy();
377}