/** * The checks, against the real pipeline: MATLAB source -> numbl lowering -> * generated WGSL -> GPU. Physics first — does the string sound its pitch, * does it decay on schedule, does the sealed box actually seal — and the * planner's mechanics after. * * Everything runs on deliberately small grids: these are correctness checks, * and a 32-point air grid already carries every code path the 128-point one * does. */ import { ModelSession } from '../src/mgpu/session.ts'; import { dulcimerModel, defaultParams, type Params } from '../src/mgpu/registry.ts'; import { boxScene, defaultSceneParams } from '../src/scene/registry.ts'; import { planPlayback } from '../src/audio/play.ts'; type Check = (name: string, ok: boolean, detail: string) => void; type Log = (s: string) => void; const peakOf = (a: Float32Array): number => { let m = 0; for (const v of a) m = Math.max(m, Math.abs(v)); return m; }; async function makeSession( device: GPUDevice, nx: number, params: Params = {}, sceneParams: Params = {}, operandBudget?: number, ): Promise { return ModelSession.create({ device, model: dulcimerModel, params: { ...defaultParams(dulcimerModel), ...params }, scene: boxScene, sceneParams: { ...defaultSceneParams(boxScene), ...sceneParams }, nx, Ls: 0.6, operandBudget, }); } /** The compiled plan: external ops present, pluck in place, mic riding along. */ export async function planChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, 32); const { step } = s.describe(); for (const l of step) log(` ${l}`); const externals = step.filter((l) => l.startsWith('external')); const wanted = ['dxx(u)', 'dxx(um)', 'dxxxx(u)', 'spread(acc)', 'bridge(un)', 'lapw(p, wall)']; for (const w of wanted) { check( `the step uses ${w}`, externals.some((l) => l.includes(w)), externals.length ? externals.join('; ') : 'no external ops planned', ); } s.pluck(); const u = await s.read('u'); const amp = defaultParams(dulcimerModel).amp; const peak = peakOf(u); check( 'the pluck draws the string to its set height', Math.abs(peak - amp) < 0.15 * amp, `peak |u| = ${peak.toExponential(3)}, amp = ${amp}`, ); check('the ends are pinned', u[0] === 0 && u[u.length - 1] === 0, `u[0]=${u[0]}, u[end]=${u[u.length - 1]}`); s.step(100); check( 'the microphone samples once per timestep', s.recorder.count === 100, `${s.recorder.count} samples after 100 steps`, ); const plan = planPlayback(s.recorder.count, s.dt); check( 'playback is real time at the solver rate', plan.realTime && Math.abs(plan.rate * s.dt - 1) < 1e-9, `rate ${plan.rate.toFixed(0)} Hz, dt ${s.dt.toExponential(3)}`, ); s.destroy(); } /** The string sounds the pitch it is tuned to. */ export async function pitchChecks(device: GPUDevice, check: Check, log: Log): Promise { // An ideal string (no stiffness, no damping to speak of), watched at a // point for a dozen periods; the zero crossings say the frequency. const f0 = 294; const s = await makeSession(device, 32, { f0, B: 0, sig1: 0, t60: 8 }); s.pluck(); const node = Math.round(s.string.ns * 0.4); const periods = 12; const stepsTotal = Math.round(periods / f0 / s.dt); const chunk = 10; const series: number[] = []; for (let done = 0; done < stepsTotal; done += chunk) { s.step(chunk); const u = await s.read('u'); series.push(u[node]); } let crossings = 0; for (let i = 1; i < series.length; i++) { if ((series[i - 1] < 0 && series[i] >= 0) || (series[i - 1] >= 0 && series[i] < 0)) crossings++; } const measured = crossings / 2 / (stepsTotal * s.dt); log(` ${crossings} zero crossings over ${(stepsTotal * s.dt * 1000).toFixed(1)} ms -> ${measured.toFixed(1)} Hz`); check( `the string sounds its fundamental (${f0} Hz)`, Math.abs(measured - f0) < 0.04 * f0, `measured ${measured.toFixed(1)} Hz`, ); // d'Alembert: at half a period the string is the mirror of its pluck, so // the displacement at the pluck point flips sign and shrinks to the // triangle's value at the mirrored position. s.pluck(); const u0 = await s.read('u'); s.step(Math.round(1 / f0 / 2 / s.dt)); const u1 = await s.read('u'); const at = Math.round(0.22 * (s.string.ns - 1)); check( 'half a period later the pluck point has swung through zero', u0[at] > 0 && u1[at] < 0, `u ${u0[at].toExponential(2)} -> ${u1[at].toExponential(2)}`, ); s.destroy(); } /** The decay knob means what it says. */ export async function decayChecks(device: GPUDevice, check: Check, log: Log): Promise { const t60 = 0.5; const s = await makeSession(device, 32, { t60, B: 0, sig1: 0 }); s.pluck(); const before = peakOf(await s.read('u')); // Half of t60: amplitude should be down 30 dB, i.e. to about 3.2%. s.step(Math.round(t60 / 2 / s.dt)); const after = peakOf(await s.read('u')); const db = 20 * Math.log10(after / before); log(` |u| ${before.toExponential(2)} -> ${after.toExponential(2)} in ${t60 / 2} s (${db.toFixed(1)} dB)`); check( 't60 decays the string on schedule', Math.abs(db + 30) < 4, `${db.toFixed(1)} dB over t60/2, want -30`, ); s.destroy(); } /** The wall mask is a wall: a sealed box keeps the sound out. */ export async function wallChecks(device: GPUDevice, check: Check, log: Log): Promise { // Bridge drive only (the direct route off), so the source sits above the // plate; a microphone inside a sealed box should hear far less than one // outside beside it. const seconds = 0.06; const run = async (mic: [number, number, number]): Promise => { const s = await makeSession( device, 64, { gline: 0, gbridge: 1 }, { holer: 0, absorb: 0, boxd: 0.12, thick: 0.02 }, ); s.setMic(...mic); s.pluck(); s.step(Math.round(seconds / s.dt)); const trace = await s.recorder.read(); s.destroy(); return peakOf(trace); }; const inside = await run([0, 0, -0.06]); const outside = await run([0, 0, 0.08]); log(` |p| inside the sealed box ${inside.toExponential(2)}, outside ${outside.toExponential(2)}`); check( 'a sealed box keeps the sound out', inside < 0.05 * outside, `inside/outside = ${(inside / outside).toExponential(2)}`, ); } /** The sound hole lets the cavity speak: opening it raises what gets in. */ export async function holeChecks(device: GPUDevice, check: Check, log: Log): Promise { const seconds = 0.06; const run = async (holer: number): Promise => { const s = await makeSession( device, 64, { gline: 0, gbridge: 1 }, { holer, absorb: 0, boxd: 0.12, thick: 0.02 }, ); s.setMic(0, 0, -0.06); s.pluck(); s.step(Math.round(seconds / s.dt)); const trace = await s.recorder.read(); s.destroy(); return peakOf(trace); }; const sealed = await run(0); const open = await run(0.05); log(` |p| in the cavity: sealed ${sealed.toExponential(2)}, open ${open.toExponential(2)}`); check( 'the sound hole lets the cavity speak', open > 5 * sealed, `open/sealed = ${(open / sealed).toExponential(2)}`, ); } /** Air sound sits on the string's partial comb, not somewhere else. */ export async function spectrumChecks(device: GPUDevice, check: Check, log: Log): Promise { const f0 = 294; const s = await makeSession(device, 64, { f0 }); s.setMic(0.12, 0.08, 0.1); s.pluck(); const seconds = 0.12; const total = Math.round(seconds / s.dt); for (let done = 0; done < total; done += 2048) { s.step(Math.min(2048, total - done)); await s.sync(); } const trace = await s.recorder.read(); const dft = (f: number): number => { let re = 0; let im = 0; for (let i = 0; i < trace.length; i++) { const w = 2 * Math.PI * f * i * s.dt; re += trace[i] * Math.cos(w); im -= trace[i] * Math.sin(w); } return Math.hypot(re, im) / trace.length; }; const comb = (dft(f0) + dft(2 * f0) + dft(3 * f0)) / 3; const off = (dft(1.41 * f0) + dft(2.53 * f0)) / 2; log(` comb ${comb.toExponential(2)}, off-comb ${off.toExponential(2)}`); check( 'the microphone hears the string’s partials', comb > 8 * off, `comb/off = ${(comb / off).toFixed(1)}`, ); s.destroy(); } /** Taking the body away leaves open air that still carries the string. */ export async function bareChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, 32, { gline: 1, gbridge: 0 }, { body: 0 }); let minWall = 1; for (const v of s.scene.wall) minWall = Math.min(minWall, v); check( 'removing the body leaves pure air', minWall > 0.999, `min wall mask = ${minWall}`, ); s.setMic(0.1, 0.05, 0.05); s.pluck(); s.step(Math.round(0.03 / s.dt)); const trace = await s.recorder.read(); const peak = peakOf(trace); log(` bare-string trace peak ${peak.toExponential(2)}`); check('the bare string still sounds', peak > 0, `trace peak ${peak.toExponential(2)}`); s.destroy(); } /** A starved operand budget splits kernels instead of failing. */ export async function splitChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, 32, {}, {}, 2); const { step } = s.describe(); const parts = step.filter((l) => l.includes('_part')).length; log(` ${parts} split kernels at budget 2`); check('a tight binding budget splits the update', parts > 0, `${parts} split kernels`); s.pluck(); s.step(50); const p = await s.read('p'); let bad = 0; for (const v of p) if (!Number.isFinite(v)) bad++; check('the split model still runs', bad === 0, `${bad} non-finite values`); // The split result must agree with the unsplit one. const s2 = await makeSession(device, 32); s2.pluck(); s2.step(50); const p2 = await s2.read('p'); let worst = 0; const scale = Math.max(peakOf(p), 1e-30); for (let i = 0; i < p.length; i++) worst = Math.max(worst, Math.abs(p[i] - p2[i])); check( 'split and unsplit agree', worst < 1e-4 * scale, `worst |diff| = ${worst.toExponential(2)} of peak ${scale.toExponential(2)}`, ); s.destroy(); s2.destroy(); }