/** * The checks, against the real pipeline: SceneSpec -> generated MATLAB -> * numbl lowering -> generated WGSL -> GPU. Physics first — does each kind of * object sound the pitch it is tuned to, does the sealed box actually seal, * do two objects in one scene both reach the microphone — 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 { StudioSession, specParams } from '../src/studio/session.ts'; import { cloneSpec, presetByKey } from '../src/studio/presets.ts'; import { makeSceneFile, parseSceneFile, serializeSceneFile } from '../src/studio/scenefile.ts'; import { generate } from '../src/studio/codegen.ts'; import { fastRender, prepareRoom, roomContributions } from '../src/studio/fast.ts'; import { synthesize } from '../src/studio/modal.ts'; import { makeAirGrid, stableDt } from '../src/grids.ts'; import { C_AIR, CFL } from '../src/units.ts'; import type { Params, SceneSpec } from '../src/studio/types.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; }; const spec = (key: string): SceneSpec => cloneSpec(presetByKey(key)!.spec); async function makeSession( device: GPUDevice, sceneSpec: SceneSpec, nx: number, params: Params = {}, sceneParams: Params = {}, operandBudget?: number, ): Promise { return StudioSession.create({ device, spec: sceneSpec, nx, params, sceneParams, operandBudget, }); } /** Frequency content of a series sampled every `sdt` seconds. */ function dftAt(series: number[], sdt: number, f: number): number { let re = 0; let im = 0; for (let i = 0; i < series.length; i++) { const w = 2 * Math.PI * f * i * sdt; re += series[i] * Math.cos(w); im -= series[i] * Math.sin(w); } return Math.hypot(re, im) / series.length; } /** Run a session and sample one state element every `every` steps. */ async function sampleField( s: StudioSession, field: string, index: number, totalSteps: number, every: number, ): Promise<{ series: number[]; sdt: number }> { const series: number[] = []; for (let done = 0; done < totalSteps; done += every) { s.step(every); const u = await s.read(field); series.push(u[index]); } return { series, sdt: every * s.dt }; } /** The compiled plan: per-object external ops present, excitation in place, * mic riding along. */ export async function planChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, spec('dulcimer'), 32); const { step } = s.describe(); for (const l of step) log(` ${l}`); const externals = step.filter((l) => l.startsWith('external')); 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)']; 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.excite(); const u = await s.read('u_s1'); const amp = s.params.amp_s1; 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 generated sources look like what the Lab view promises. */ export async function codegenChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, spec('duet'), 32); const m = s.generated.modelSource; const sc = s.generated.sceneSource; check( 'the generated model carries both strings', m.includes('un_s1 = pin_s1') && m.includes('un_s2 = pin_s2'), 'looked for un_s1 and un_s2 updates', ); check( 'the generated scene builds a profile per object', sc.includes('prof_s1 =') && sc.includes('prof_s2 =') && sc.includes('bprof_s2 ='), 'looked for prof_s1, prof_s2, bprof_s2', ); log(` model: ${m.split('\n').length} lines, scene: ${sc.split('\n').length} lines`); s.destroy(); } /** A saved scene file round-trips: same spec, same generated MATLAB, and a * malformed file is refused with a named error rather than a compile * failure three layers down. */ export async function sceneFileChecks(_device: GPUDevice, check: Check, log: Log): Promise { const original = spec('duet'); const defaults = specParams(original); const file = makeSceneFile({ spec: original, params: { ...defaults.params, f0_s1: 300 }, sceneParams: { ...defaults.sceneParams, oy_s1: -0.05 }, mic: { x: 0.1, y: 0.05, z: 0.08 }, nx: 96, }); const parsed = parseSceneFile(serializeSceneFile(file)); check( 'a scene file round-trips its values', parsed.params.f0_s1 === 300 && parsed.sceneParams.oy_s1 === -0.05 && parsed.nx === 96 && parsed.spec.objects.length === 2, `f0_s1 = ${parsed.params.f0_s1}, oy_s1 = ${parsed.sceneParams.oy_s1}`, ); const air = makeAirGrid(32, 1.0); const dt = stableDt(air.h, C_AIR, CFL); const a = generate(original, air, dt); const b = generate(parsed.spec, air, dt); check( 'a round-tripped spec generates identical MATLAB', a.modelSource === b.modelSource && a.sceneSource === b.sceneSource, `model ${a.modelSource === b.modelSource ? 'matches' : 'DIFFERS'}, ` + `scene ${a.sceneSource === b.sceneSource ? 'matches' : 'DIFFERS'}`, ); const bad = [ ['{}', 'format marker'], ['{"soundbox":1,"spec":{"objects":[],"body":{"type":"none"}},"mic":{"x":0,"y":0,"z":0},"nx":32}', 'non-empty'], ['{"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'], ] as const; for (const [text, wants] of bad) { let msg = ''; try { parseSceneFile(text); } catch (e) { msg = e instanceof Error ? e.message : String(e); } check( `a malformed scene file is refused (${wants})`, msg.includes(wants), msg || 'no error was thrown', ); } log(' scene file round-trip and refusals exercised'); } /** A string sounds the pitch it is tuned to. */ export async function stringChecks(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, spec('dulcimer'), 32, { f0_s1: f0, B_s1: 0, sig1_s1: 0, t60_s1: 8, }); s.excite(); const g = s.grids.s1; const node = Math.round(g.n1 * 0.4); const periods = 12; const stepsTotal = Math.round(periods / f0 / s.dt); const { series } = await sampleField(s, 'u_s1', node, stepsTotal, 10); 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`, ); // The decay knob means what it says: at half of t60 the string should be // down 30 dB. const t60 = 0.5; const s2 = await makeSession(device, spec('dulcimer'), 32, { t60_s1: t60, B_s1: 0, sig1_s1: 0, }); s2.excite(); const before = peakOf(await s2.read('u_s1')); s2.step(Math.round(t60 / 2 / s2.dt)); const after = peakOf(await s2.read('u_s1')); 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(); s2.destroy(); } /** * A bar rings at the frequencies its own discrete dispersion relation * predicts. The bar grid is coarse by construction (an explicit stiff scheme * wants coarse grids), so the continuous n² f1 comb is audibly detuned; what * must hold exactly is the scheme's own relation: mode n of the pinned * fourth-difference operator has eigenvalue lam_n = (4/h²) sin²(n π h / 2L), * and rings at f_n = kap·lam_n / 2π. This is the "you can hear the * dispersion" check: the same numbers the Lab spectrum overlay draws. */ export async function barChecks(device: GPUDevice, check: Check, log: Log): Promise { const f1 = 780; const s = await makeSession(device, spec('chime'), 32, { f1_b1: f1, sig1_b1: 0, t60_b1: 12, }); s.excite(); const g = s.grids.b1; const kap = (2 * f1 * g.L1 * g.L1) / Math.PI; const lam = (n: number): number => (4 / (g.h1 * g.h1)) * Math.sin((n * Math.PI * g.h1) / (2 * g.L1)) ** 2; const fd = (n: number): number => (kap * lam(n) * lam(n) === 0 ? 0 : (kap * lam(n)) / (2 * Math.PI)); const node = Math.round(g.n1 * 0.37); const total = Math.round(0.06 / s.dt); const { series, sdt } = await sampleField(s, 'u_b1', node, total, 4); const comb = (dftAt(series, sdt, fd(1)) + dftAt(series, sdt, fd(2))) / 2; const off = (dftAt(series, sdt, 1.4 * fd(1)) + dftAt(series, sdt, Math.sqrt(fd(1) * fd(2)))) / 2; log( ` discrete modes ${fd(1).toFixed(0)}, ${fd(2).toFixed(0)} Hz ` + `(continuous would be ${f1}, ${4 * f1}); comb ${comb.toExponential(2)}, off ${off.toExponential(2)}`, ); check( 'the bar rings where its discrete dispersion relation says', comb > 5 * off, `comb/off = ${(comb / off).toFixed(1)}`, ); s.destroy(); } /** A membrane struck at its centre sounds its (1,1) fundamental. */ export async function membraneChecks(device: GPUDevice, check: Check, log: Log): Promise { const f0 = 110; const s = await makeSession(device, spec('drum'), 32, { f0_m1: f0, sig1_m1: 0, t60_m1: 6, strikex_m1: 0.5, strikey_m1: 0.5, }); s.excite(); const g = s.grids.m1; const centre = Math.round(g.n1 / 2) + g.n1 * Math.round(g.n2 / 2); const total = Math.round(10 / f0 / s.dt); const { series, sdt } = await sampleField(s, 'u_m1', centre, total, 10); const at = dftAt(series, sdt, f0); const off = (dftAt(series, sdt, 1.31 * f0) + dftAt(series, sdt, 0.71 * f0)) / 2; log(` membrane at f0 ${at.toExponential(2)}, off ${off.toExponential(2)}`); check( `the membrane sounds its (1,1) mode (${f0} Hz)`, at > 4 * off, `on/off = ${(at / off).toFixed(1)}`, ); 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], holer: number): Promise => { const s = await makeSession( device, spec('dulcimer'), 64, { gair_s1: 0, gbridge_s1: 1 }, { holer, absorb: 0, boxd: 0.12, thick: 0.02 }, ); s.setMic(...mic); s.excite(); 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], 0); const outside = await run([0, 0, 0.08], 0); 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)}`, ); const open = await run([0, 0, -0.06], 0.05); log(` |p| in the cavity: sealed ${inside.toExponential(2)}, open ${open.toExponential(2)}`); check( 'the sound hole lets the cavity speak', open > 5 * inside, `open/sealed = ${(open / inside).toExponential(2)}`, ); } /** Two objects in one scene both reach the microphone. */ export async function duetChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, spec('duet'), 64, { B_s1: 0, B_s2: 0, sig1_s1: 0, sig1_s2: 0, }); s.setMic(0.12, 0.08, 0.1); s.excite(); 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 series = [...trace]; const at = (f: number): number => dftAt(series, s.dt, f); const s1 = at(294); const s2 = at(441); const off = (at(360) + at(520)) / 2; log(` duet: 294 Hz ${s1.toExponential(2)}, 441 Hz ${s2.toExponential(2)}, off ${off.toExponential(2)}`); check('the first string reaches the microphone', s1 > 4 * off, `294/off = ${(s1 / off).toFixed(1)}`); check('the second string reaches the microphone', s2 > 4 * off, `441/off = ${(s2 / off).toFixed(1)}`); s.destroy(); } /** Moving an object is a scene edit: no recompile, and the profile follows. */ export async function placementChecks(device: GPUDevice, check: Check, log: Log): Promise { const s = await makeSession(device, spec('bare'), 32); const before = s.scene.fields.prof_s1.slice(); s.setScene({ ...s.sceneParams, oy_s1: 0.1 }); const after = s.scene.fields.prof_s1; let moved = 0; for (let i = 0; i < before.length; i++) { if (Math.abs(before[i] - after[i]) > 1e-6) moved++; } log(` ${moved} profile cells changed after moving the string`); check('moving an object moves its coupling profile', moved > 0, `${moved} cells changed`); s.excite(); s.step(50); const p = await s.read('p'); let bad = 0; for (const v of p) if (!Number.isFinite(v)) bad++; check('the moved scene still runs', bad === 0, `${bad} non-finite values`); s.destroy(); } /** 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, spec('dulcimer'), 64, { f0_s1: f0 }); s.setMic(0.12, 0.08, 0.1); s.excite(); 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 series = [...trace]; const comb = (dftAt(series, s.dt, f0) + dftAt(series, s.dt, 2 * f0) + dftAt(series, s.dt, 3 * f0)) / 3; const off = (dftAt(series, s.dt, 1.41 * f0) + dftAt(series, s.dt, 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(); } /** 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, spec('dulcimer'), 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.excite(); 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, spec('dulcimer'), 32); s2.excite(); 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(); } /** The modal fast path (src/studio/fast.ts): every preset's mic trace, * synthesized from closed-form modes and one reciprocal room recording per * scene, must match the timestepped solver to its own f32 rounding noise — * across all three object types, both string routes, and every body. And * it must keep matching from the *same* room when pitch, decay and strike * change, since the room depends only on geometry. */ export async function modalChecks(device: GPUDevice, check: Check, log: Log): Promise { const relErr = (a: Float32Array, b: Float32Array, n: number): number => { let d = 0; let r = 0; for (let i = 0; i < n; i++) { d += (a[i] - b[i]) * (a[i] - b[i]); r += b[i] * b[i]; } return Math.sqrt(d / Math.max(r, 1e-300)); }; const steps = 6000; const run = async (sceneSpec: SceneSpec, mic: { x: number; y: number; z: number }, params: Params): Promise => { const s = await makeSession(device, sceneSpec, 32, params); s.setMic(mic.x, mic.y, mic.z); s.excite(); s.step(steps); await s.sync(); const trace = await s.recorder.read(); s.destroy(); return trace; }; // One scene per object type: the drum (membrane over a shell), the // dulcimer (string, both routes, over the box), and the kit (membrane and // bar together — two objects in one room). for (const key of ['drum', 'dulcimer', 'kit'] as const) { const preset = presetByKey(key)!; const sceneSpec = cloneSpec(preset.spec); const defaults = specParams(sceneSpec); const fast = await fastRender({ device, spec: sceneSpec, params: defaults.params, sceneParams: defaults.sceneParams, nx: 32, mic: preset.mic, steps, irSeconds: 1e9, modeOpts: { eps: 1e-9 }, }); const gpu = await run(sceneSpec, preset.mic, {}); const err = relErr(fast.trace, gpu, steps); log(` ${key}: ${fast.room.rows} kernels, ${fast.used} modes; rel err ${err.toExponential(2)}`); check(`the modal path reproduces the ${key}`, err < 5e-3, `rel err ${err.toExponential(2)}`); } // Retune and restrike from one kept room: pitch, decay, brightness and // excitation move, the room does not. Membranes are recorded a decade // deeper than synthesis keeps, so a moved strike stays covered. { const preset = presetByKey('drum')!; const sceneSpec = cloneSpec(preset.spec); const defaults = specParams(sceneSpec); const fr = await prepareRoom({ device, spec: sceneSpec, params: defaults.params, sceneParams: defaults.sceneParams, nx: 32, mic: preset.mic, irSeconds: 1e9, maxKernelSteps: steps, modeOpts: { eps: 1e-12 }, }); const edits: Params = { f0_m1: 150, t60_m1: 0.7, sig1_m1: 0.002, strikex_m1: 0.62, striker_m1: 0.03 }; const paramsB = { ...defaults.params, ...edits }; const { contribs, used, wanted, missing } = roomContributions(fr, sceneSpec, paramsB, { eps: 1e-8 }); const traceB = synthesize(contribs, fr.room.steps, steps); const gpuB = await run(sceneSpec, preset.mic, edits); const errB = relErr(traceB, gpuB, steps); log(` retuned drum: ${used}/${wanted} modes (${missing} unrecorded); rel err ${errB.toExponential(2)}`); // A stray borderline mode may sit outside the recorded set (a mode near // a node of the first strike); the error bound is the gate, the count // just guards against wholesale mismatch. check( 'the same room serves a retuned, restruck drum', errB < 5e-3 && missing <= 0.02 * wanted, `rel err ${errB.toExponential(2)}, ${missing}/${wanted} modes missing from the room`, ); } }