/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
Render scenes outside the browser
Save scene / Load scene round-trip the whole scene (spec, parameters, mic, grid, hand-edited sources) as one .json, and scripts/render.ts renders a scene file or preset headless on desktop WebGPU to a 48 kHz .wav, with the trace capacity sized from the requested duration. Object grid caps raised for the finer air grids a big GPU allows; browser grid options up to 256.
Jeremy Magland <jeremy.magland@gmail.com> committed commit e18e57e4d7b8 parent 8333c76 Browse files
11 changed files+582−11
README.mdmodified+27−0View file
@@ -129,6 +129,33 @@ npm test # solver checks against desktop WebGPU (Google Dawn)
129129 npm run smoke # headless-browser check of the built page (environment permitting)
130130 ```
131131
132+## Rendering outside the browser
133+
134+A scene built in the page can be rendered on another machine, without a
135+browser, on whatever GPU that machine has. The Build tab's **Save scene**
136+button downloads a small .json carrying the scene description, every
137+parameter value, the microphone, the grid size, and any hand-edited .m
138+sources (generated sources are not stored; the generator is deterministic).
139+The same file loads back into the page with **Load scene**, and renders
140+headless with:
141+
142+```
143+npm run render -- scene.json --seconds 20 --nx 192 --out clip.wav
144+npm run render -- --preset drum --seconds 5
145+npm run render -- --list-presets
146+```
147+
148+The renderer builds the same StudioSession the page builds — same generator,
149+same compiler, same solver, through desktop WebGPU (the optional `webgpu`
150+package, Google Dawn) — so a scene sounds the same wherever it runs; on a
151+headless Linux box Dawn needs working Vulkan drivers. What a bigger GPU
152+buys, besides speed, is bandwidth: the simulation is honest to about
153+c/(8h), so `--nx 256` reaches ~8 kHz where the browser default's ~4 kHz
154+stops, at 16 times the work per second of audio (the solver is
155+memory-bandwidth-bound, which is exactly what a discrete card is good at).
156+Memory on the host grows as nx³ during scene evaluation; beyond nx = 384
157+expect several gigabytes.
158+
132159 numbl is a `file:../../numbl` dependency, expected as a sibling checkout two
133160 directories up (the layout of the concept-collection vault).
134161
index.htmlmodified+5−0View file
@@ -276,8 +276,13 @@
276276 <option value="96" selected>96</option>
277277 <option value="128">128</option>
278278 <option value="160">160</option>
279+ <option value="192">192</option>
280+ <option value="256">256 (slow)</option>
279281 </select>
280282 </label>
283+ <button id="savescene" title="Download this scene — objects, body, every knob, the mic, and any hand-edited .m — as a .json that loads back here or renders headless with `npm run render`">Save scene</button>
284+ <button id="loadscene" title="Load a scene .json saved from this page">Load scene</button>
285+ <input type="file" id="loadfile" accept=".json,application/json" hidden />
281286 </div>
282287 <div id="objects"></div>
283288 <div class="controls" style="margin-top:10px">
package.jsonmodified+1−0View file
@@ -11,6 +11,7 @@
1111 "dev": "vite",
1212 "build": "tsc --noEmit && vite build",
1313 "test:node": "vite-node scripts/test-node.ts",
14+ "render": "vite-node scripts/render.ts",
1415 "smoke": "vite build && node scripts/smoke.mjs",
1516 "test": "npm run test:node"
1617 },
scripts/render.tsadded+231−0View file
@@ -0,0 +1,231 @@
1+/**
2+ * Render a scene to a .wav outside the browser, on whatever GPU this
3+ * machine has.
4+ *
5+ * The browser's Save scene button writes a scene .json; this script loads
6+ * it (or starts from a named preset), builds the same StudioSession the
7+ * page builds — same generator, same compiler, same solver — runs it flat
8+ * out with no display, and resamples the microphone's trace to a 48 kHz
9+ * .wav. Because the pipeline is identical, a scene renders the same
10+ * everywhere; what a bigger GPU buys is speed, and with --nx, a finer air
11+ * grid than the browser would enjoy: the audio bandwidth of the simulation
12+ * is c_air/(8h), so doubling nx doubles the honest bandwidth at 16x the
13+ * work per second of audio.
14+ *
15+ * npm run render -- scene.json --seconds 10 --out clip.wav
16+ * npm run render -- --preset drum --seconds 5 --nx 192
17+ * npm run render -- --list-presets
18+ *
19+ * Options:
20+ * --seconds N duration of audio to render (default 5)
21+ * --nx N override the air grid (even; memory grows as nx^3)
22+ * --mic x,y,z override the microphone position, metres
23+ * --out FILE output path (default: the scene's or preset's name.wav)
24+ *
25+ * Runs through vite-node (which resolves numbl's compiler sources) on
26+ * desktop WebGPU via the optional `webgpu` package (Google Dawn). On a
27+ * headless Linux box Dawn needs working Vulkan; nodeWebGpu.ts explains
28+ * itself when it cannot load.
29+ */
30+import { readFile, writeFile } from 'node:fs/promises';
31+import { basename } from 'node:path';
32+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
33+import { requestAcousticDevice } from '../src/device.ts';
34+import { StudioSession } from '../src/studio/session.ts';
35+import { parseSceneFile, makeSceneFile, type SceneFile } from '../src/studio/scenefile.ts';
36+import { PRESETS, cloneSpec, presetByKey } from '../src/studio/presets.ts';
37+import { specParams } from '../src/studio/session.ts';
38+import { stableDt } from '../src/grids.ts';
39+import { traceToWav, WAV_RATE } from '../src/audio/wav.ts';
40+import { C_AIR, CFL, DOMAIN_X, POOR_RESOLUTION, fmtTime } from '../src/units.ts';
41+
42+function usage(): never {
43+ console.error(
44+ 'usage: npm run render -- <scene.json | --preset KEY> ' +
45+ '[--seconds N] [--nx N] [--mic x,y,z] [--out FILE]\n' +
46+ ' npm run render -- --list-presets',
47+ );
48+ process.exit(2);
49+}
50+
51+/* ---------------------------------------------------------------- args --- */
52+
53+const args = process.argv.slice(2);
54+let scenePath: string | null = null;
55+let presetKey: string | null = null;
56+let seconds = 5;
57+let nxOverride: number | null = null;
58+let micOverride: { x: number; y: number; z: number } | null = null;
59+let outPath: string | null = null;
60+
61+for (let i = 0; i < args.length; i++) {
62+ const a = args[i];
63+ const next = (): string => {
64+ const v = args[++i];
65+ if (v === undefined) usage();
66+ return v;
67+ };
68+ if (a === '--list-presets') {
69+ for (const p of PRESETS) console.log(`${p.key.padEnd(10)} ${p.blurb}`);
70+ process.exit(0);
71+ } else if (a === '--preset') presetKey = next();
72+ else if (a === '--seconds') seconds = Number(next());
73+ else if (a === '--nx') nxOverride = Number(next());
74+ else if (a === '--out') outPath = next();
75+ else if (a === '--mic') {
76+ const parts = next().split(',').map(Number);
77+ if (parts.length !== 3 || parts.some((v) => !Number.isFinite(v))) usage();
78+ micOverride = { x: parts[0], y: parts[1], z: parts[2] };
79+ } else if (a.startsWith('--')) usage();
80+ else if (scenePath === null) scenePath = a;
81+ else usage();
82+}
83+if ((scenePath === null) === (presetKey === null)) usage(); // exactly one
84+if (!(seconds > 0) || seconds > 600) {
85+ console.error('--seconds must be between 0 and 600');
86+ process.exit(2);
87+}
88+
89+/* --------------------------------------------------------------- scene --- */
90+
91+let file: SceneFile;
92+let name: string;
93+if (scenePath !== null) {
94+ const text = await readFile(scenePath, 'utf8').catch((e: unknown) => {
95+ console.error(`cannot read ${scenePath}: ${errMsg(e)}`);
96+ process.exit(1);
97+ });
98+ try {
99+ file = parseSceneFile(text);
100+ } catch (e) {
101+ console.error(errMsg(e));
102+ process.exit(1);
103+ }
104+ name = basename(scenePath).replace(/\.json$/i, '');
105+} else {
106+ const preset = presetByKey(presetKey!);
107+ if (!preset) {
108+ console.error(
109+ `no preset '${presetKey}' (one of: ${PRESETS.map((p) => p.key).join(', ')})`,
110+ );
111+ process.exit(1);
112+ }
113+ const spec = cloneSpec(preset.spec);
114+ file = makeSceneFile({ spec, ...specParams(spec), mic: preset.mic, nx: 96 });
115+ name = preset.key;
116+}
117+
118+const nx = nxOverride ?? file.nx;
119+if (!Number.isInteger(nx) || nx < 16 || nx % 2 !== 0) {
120+ console.error(`--nx must be an even integer of at least 16 (got ${nx})`);
121+ process.exit(1);
122+}
123+const mic = micOverride ?? file.mic;
124+const out = outPath ?? `${name}.wav`;
125+
126+/* -------------------------------------------------------------- render --- */
127+
128+let device: GPUDevice;
129+try {
130+ const runtime = await installWebGpu();
131+ device = await requestAcousticDevice();
132+ console.log(`soundbox render — ${runtime}`);
133+} catch (e) {
134+ console.error(`render: ${errMsg(e)}\n${NO_ADAPTER_HINT}`);
135+ process.exit(1);
136+}
137+
138+// Size the trace for the request before the session exists. The estimate
139+// assumes the medium tops out at the speed of air, which holds for every
140+// generated scene; a hand-edited faster medium shrinks dt, and the shortfall
141+// is reported below rather than silently truncated.
142+const dtEstimate = stableDt(DOMAIN_X / nx, C_AIR, CFL);
143+const capacity = Math.ceil((1.05 * seconds) / dtEstimate);
144+if (capacity > 300_000_000) {
145+ console.error(
146+ `${fmtTime(seconds)} at nx = ${nx} is ${capacity.toLocaleString()} samples of ` +
147+ `trace — more GPU memory than is sensible. Lower --seconds or --nx.`,
148+ );
149+ process.exit(1);
150+}
151+
152+const t0 = performance.now();
153+const session = await StudioSession.create({
154+ device,
155+ spec: file.spec,
156+ params: file.params,
157+ sceneParams: file.sceneParams,
158+ modelSource: file.sources?.model,
159+ sceneSource: file.sources?.scene,
160+ nx,
161+ traceCapacity: capacity,
162+}).catch((e: unknown) => {
163+ console.error(`compile failed: ${errMsg(e)}`);
164+ process.exit(1);
165+});
166+const tCompiled = performance.now();
167+
168+const { air } = session;
169+const fmax = C_AIR / (POOR_RESOLUTION * air.h);
170+const objects = file.spec.objects
171+ .map((o) => {
172+ const g = session.grids[o.id];
173+ return `${o.id} (${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`})`;
174+ })
175+ .join(', ');
176+console.log(
177+ `air ${air.nx}×${air.ny}×${air.nz} (${(air.npts / 1e6).toFixed(2)} M cells, ` +
178+ `honest to ~${(fmax / 1000).toFixed(1)} kHz) · ${objects}\n` +
179+ `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz) · ` +
180+ `compiled in ${((tCompiled - t0) / 1000).toFixed(1)} s`,
181+);
182+
183+session.setMic(mic.x, mic.y, mic.z);
184+session.excite();
185+
186+const total = Math.ceil(seconds / session.dt);
187+if (total > session.recorder.capacity) {
188+ console.error(
189+ `the scene's medium lowered dt below the estimate; only ` +
190+ `${fmtTime(session.recorder.capacity * session.dt)} of trace fits. Rendering that.`,
191+ );
192+}
193+const steps = Math.min(total, session.recorder.capacity);
194+const batch = 2048;
195+let done = 0;
196+let lastReport = -1;
197+while (done < steps) {
198+ const n = Math.min(batch, steps - done);
199+ session.step(n);
200+ done += n;
201+ await session.sync();
202+ const pct = Math.floor((10 * done) / steps);
203+ if (pct > lastReport) {
204+ lastReport = pct;
205+ const dt = (performance.now() - tCompiled) / 1000;
206+ const rate = done / Math.max(dt, 1e-9);
207+ process.stderr.write(
208+ ` ${String(10 * pct).padStart(3)}% ${(rate / 1000).toFixed(0)}k steps/s ` +
209+ `${fmtTime(done * session.dt)} of audio\n`,
210+ );
211+ }
212+}
213+const tRun = performance.now();
214+
215+const trace = await session.recorder.read();
216+let peak = 0;
217+for (const v of trace) peak = Math.max(peak, Math.abs(v));
218+if (!(peak > 0)) {
219+ console.error('warning: the microphone recorded silence — is every gain zero, or the mic inside a wall?');
220+}
221+const wav = traceToWav(trace, session.dt);
222+await writeFile(out, Buffer.from(await wav.arrayBuffer()));
223+
224+const wall = (tRun - tCompiled) / 1000;
225+console.log(
226+ `${fmtTime(trace.length * session.dt)} of audio in ${wall.toFixed(1)} s ` +
227+ `(${(trace.length * session.dt / wall).toFixed(2)}× real time) -> ` +
228+ `${out} (${WAV_RATE / 1000} kHz, ${(trace.length * session.dt).toFixed(2)} s)`,
229+);
230+session.destroy();
231+process.exit(0);
scripts/test-node.tsmodified+2−0View file
@@ -12,6 +12,7 @@ import { requestAcousticDevice } from '../src/device.ts';
1212 import {
1313 planChecks,
1414 codegenChecks,
15+ sceneFileChecks,
1516 stringChecks,
1617 barChecks,
1718 membraneChecks,
@@ -54,6 +55,7 @@ console.log(`soundbox tests — ${runtime}\n`);
5455
5556 await planChecks(device, check, log);
5657 await codegenChecks(device, check, log);
58+await sceneFileChecks(device, check, log);
5759 await stringChecks(device, check, log);
5860 await barChecks(device, check, log);
5961 await membraneChecks(device, check, log);
src/audio/recorder.tsmodified+14−7View file
@@ -14,9 +14,11 @@
1414 * because the simulation has no seconds in it — only model time.
1515 */
1616
17-/** Samples the trace holds: about 7 seconds of audio at the default
18- * timestep, and 4 MB of GPU memory. Recording stops when it is full rather
19- * than wrapping, so what you hear always starts where the pluck did. */
17+/** Samples the trace holds by default: about 9 seconds of audio at the
18+ * browser's default timestep, and 4 MB of GPU memory. Recording stops when
19+ * it is full rather than wrapping, so what you hear always starts where the
20+ * excitation did. The headless renderer passes its own capacity, sized from
21+ * the requested duration. */
2022 export const TRACE_CAPACITY = 1 << 20;
2123
2224 const SHADER = `
@@ -49,10 +51,12 @@ export interface RecorderOptions {
4951 nx: number;
5052 ny: number;
5153 nz: number;
54+ /** Samples the trace holds; defaults to TRACE_CAPACITY. */
55+ capacity?: number;
5256 }
5357
5458 export class Recorder {
55- readonly capacity = TRACE_CAPACITY;
59+ readonly capacity: number;
5660
5761 #device: GPUDevice;
5862 #pipeline: GPUComputePipeline | null = null;
@@ -83,7 +87,9 @@ export class Recorder {
8387 nx: number;
8488 ny: number;
8589 nz: number;
90+ capacity: number;
8691 }) {
92+ this.capacity = init.capacity;
8793 this.#device = init.device;
8894 this.#layout = init.layout;
8995 this.#trace = init.trace;
@@ -98,6 +104,7 @@ export class Recorder {
98104
99105 static async create(opts: RecorderOptions): Promise<Recorder> {
100106 const { device } = opts;
107+ const capacity = opts.capacity ?? TRACE_CAPACITY;
101108 const layout = device.createBindGroupLayout({
102109 label: 'recorder',
103110 entries: [
@@ -113,7 +120,7 @@ export class Recorder {
113120 });
114121 const trace = device.createBuffer({
115122 label: 'recorder-trace',
116- size: 4 * TRACE_CAPACITY,
123+ size: 4 * capacity,
117124 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
118125 });
119126 const head = device.createBuffer({
@@ -128,13 +135,13 @@ export class Recorder {
128135 });
129136 const readback = device.createBuffer({
130137 label: 'recorder-readback',
131- size: 4 * TRACE_CAPACITY,
138+ size: 4 * capacity,
132139 usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
133140 });
134141
135142 const rec = new Recorder({
136143 device, layout, trace, head, probe, readback,
137- field: opts.field, nx: opts.nx, ny: opts.ny, nz: opts.nz,
144+ field: opts.field, nx: opts.nx, ny: opts.ny, nz: opts.nz, capacity,
138145 });
139146 rec.#pipeline = await device.createComputePipelineAsync({
140147 label: 'recorder',
src/grids.tsmodified+5−3View file
@@ -150,9 +150,11 @@ export function stableH2(dt: number, c: number, sig1: number): number {
150150 }
151151
152152 /** Node-count caps. Objects are tiny beside the air grid; the caps exist so
153- * a worst-case slider range cannot ask for an absurd grid. */
154-const MAX_LINE_NODES = 256;
155-const MAX_RECT_NODES = 96;
153+ * a worst-case slider range cannot ask for an absurd grid. They are sized
154+ * for the finest air grid the headless renderer is likely to use — a
155+ * 256-node membrane is 65k points, still under one air slice. */
156+const MAX_LINE_NODES = 1024;
157+const MAX_RECT_NODES = 256;
156158
157159 /**
158160 * Build a line grid (string or bar) of length L1 at timestep dt, sized so
src/main.tsmodified+49−0View file
@@ -27,6 +27,7 @@ import {
2727 type ObjectType, type ParamSpec, type Params, type SceneSpec,
2828 } from './studio/types.ts';
2929 import { suffixedSpecs } from './studio/codegen.ts';
30+import { makeSceneFile, parseSceneFile, serializeSceneFile } from './studio/scenefile.ts';
3031 import {
3132 drawSpectrum, objectColor, predictedPartials, type PartialSet,
3233 } from './studio/spectrum.ts';
@@ -829,6 +830,54 @@ el('gridsize').addEventListener('change', (e) => {
829830 void rebuild();
830831 });
831832
833+el('savescene').addEventListener('click', () => {
834+ writeBack();
835+ const file = makeSceneFile({
836+ spec, params, sceneParams, mic, nx: gridNx,
837+ modelSource: sources.model, sceneSource: sources.scene,
838+ });
839+ const url = URL.createObjectURL(
840+ new Blob([serializeSceneFile(file)], { type: 'application/json' }),
841+ );
842+ const a = document.createElement('a');
843+ a.href = url;
844+ a.download = 'soundbox-scene.json';
845+ a.click();
846+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
847+});
848+
849+const loadFile = el<HTMLInputElement>('loadfile');
850+el('loadscene').addEventListener('click', () => loadFile.click());
851+loadFile.addEventListener('change', () => {
852+ const f = loadFile.files?.[0];
853+ loadFile.value = '';
854+ if (!f) return;
855+ void f.text().then((text) => {
856+ try {
857+ const file = parseSceneFile(text);
858+ spec = file.spec;
859+ presetKey = null;
860+ ({ params, sceneParams } = specParams(spec));
861+ Object.assign(params, file.params);
862+ Object.assign(sceneParams, file.sceneParams);
863+ Object.assign(mic, file.mic);
864+ gridNx = file.nx;
865+ const select = el<HTMLSelectElement>('gridsize');
866+ if (![...select.options].some((o) => o.value === String(gridNx))) {
867+ select.append(new Option(String(gridNx), String(gridNx)));
868+ }
869+ select.value = String(gridNx);
870+ sources.model = file.sources?.model ?? null;
871+ sources.scene = file.sources?.scene ?? null;
872+ refreshControls();
873+ clearError();
874+ void rebuild();
875+ } catch (e) {
876+ showError(e, '');
877+ }
878+ });
879+});
880+
832881 el('bodytype').addEventListener('change', (e) => {
833882 writeBack();
834883 spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} };
src/studio/scenefile.tsadded+185−0View file
@@ -0,0 +1,185 @@
1+/**
2+ * The scene file: everything the browser knows about a scene, as one JSON
3+ * document, so a scene built here can be rendered somewhere else.
4+ *
5+ * The file carries the scene description (objects and body), every parameter
6+ * value, the microphone, the air grid size, and, only when they have been
7+ * edited by hand, the two .m sources. Generated sources are deliberately
8+ * *not* stored: the generator is deterministic, so omitting them keeps the
9+ * file small, diffable, and honest about what the user actually authored.
10+ * The same file loads back into the browser (Load scene) and into the
11+ * headless renderer (scripts/render.ts), and both go through the same
12+ * StudioSession, so a scene renders identically wherever it runs.
13+ *
14+ * Parsing validates shape and types with named errors rather than letting a
15+ * malformed file surface as a compile failure three layers down.
16+ */
17+import { OBJECT_TEMPLATES } from './objects.ts';
18+import { BODY_TEMPLATES } from './bodies.ts';
19+import type { BodyType, ObjectType, Params, SceneSpec } from './types.ts';
20+
21+export interface SceneFile {
22+ /** Format marker and version. */
23+ soundbox: 1;
24+ spec: SceneSpec;
25+ /** Model parameter values, by suffixed key (f0_s1, ...). */
26+ params: Params;
27+ /** Scene parameter values: placement and body, by key. */
28+ sceneParams: Params;
29+ mic: { x: number; y: number; z: number };
30+ /** Air grid points along x. */
31+ nx: number;
32+ /** Hand-edited .m sources, present only where the user edited them. */
33+ sources?: { model?: string; scene?: string };
34+}
35+
36+export function makeSceneFile(init: {
37+ spec: SceneSpec;
38+ params: Params;
39+ sceneParams: Params;
40+ mic: { x: number; y: number; z: number };
41+ nx: number;
42+ modelSource?: string | null;
43+ sceneSource?: string | null;
44+}): SceneFile {
45+ const file: SceneFile = {
46+ soundbox: 1,
47+ spec: init.spec,
48+ params: init.params,
49+ sceneParams: init.sceneParams,
50+ mic: { ...init.mic },
51+ nx: init.nx,
52+ };
53+ const sources: { model?: string; scene?: string } = {};
54+ if (init.modelSource) sources.model = init.modelSource;
55+ if (init.sceneSource) sources.scene = init.sceneSource;
56+ if (sources.model || sources.scene) file.sources = sources;
57+ return file;
58+}
59+
60+export const serializeSceneFile = (file: SceneFile): string =>
61+ `${JSON.stringify(file, null, 2)}\n`;
62+
63+class SceneFileError extends Error {
64+ constructor(message: string) {
65+ super(`not a valid soundbox scene file: ${message}`);
66+ this.name = 'SceneFileError';
67+ }
68+}
69+
70+const isRecord = (v: unknown): v is Record<string, unknown> =>
71+ typeof v === 'object' && v !== null && !Array.isArray(v);
72+
73+function numberMap(v: unknown, what: string): Params {
74+ if (v === undefined) return {};
75+ if (!isRecord(v)) throw new SceneFileError(`${what} must be an object`);
76+ const out: Params = {};
77+ for (const [k, val] of Object.entries(v)) {
78+ if (typeof val !== 'number' || !Number.isFinite(val)) {
79+ throw new SceneFileError(`${what}.${k} must be a finite number`);
80+ }
81+ out[k] = val;
82+ }
83+ return out;
84+}
85+
86+export function parseSceneFile(text: string): SceneFile {
87+ let raw: unknown;
88+ try {
89+ raw = JSON.parse(text);
90+ } catch (e) {
91+ throw new SceneFileError(`not JSON (${e instanceof Error ? e.message : e})`);
92+ }
93+ if (!isRecord(raw)) throw new SceneFileError('the top level must be an object');
94+ if (raw.soundbox !== 1) {
95+ throw new SceneFileError(`missing or unsupported format marker (want "soundbox": 1)`);
96+ }
97+
98+ const specRaw = raw.spec;
99+ if (!isRecord(specRaw)) throw new SceneFileError('missing "spec"');
100+ const objectsRaw = specRaw.objects;
101+ if (!Array.isArray(objectsRaw) || objectsRaw.length === 0) {
102+ throw new SceneFileError('"spec.objects" must be a non-empty array');
103+ }
104+ const seen = new Set<string>();
105+ const objects = objectsRaw.map((o, i) => {
106+ if (!isRecord(o)) throw new SceneFileError(`object ${i} must be an object`);
107+ const { id, type } = o;
108+ if (typeof id !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(id)) {
109+ throw new SceneFileError(`object ${i} needs an alphanumeric "id"`);
110+ }
111+ if (seen.has(id)) throw new SceneFileError(`duplicate object id '${id}'`);
112+ seen.add(id);
113+ if (typeof type !== 'string' || !(type in OBJECT_TEMPLATES)) {
114+ throw new SceneFileError(
115+ `object '${id}' has unknown type '${String(type)}' ` +
116+ `(one of: ${Object.keys(OBJECT_TEMPLATES).join(', ')})`,
117+ );
118+ }
119+ const size = o.size;
120+ if (!isRecord(size) || typeof size.L1 !== 'number' || !(size.L1 > 0)) {
121+ throw new SceneFileError(`object '${id}' needs a positive "size.L1"`);
122+ }
123+ if (type === 'membrane' && size.L2 !== undefined && !(typeof size.L2 === 'number' && size.L2 > 0)) {
124+ throw new SceneFileError(`object '${id}': "size.L2" must be a positive number`);
125+ }
126+ return {
127+ id,
128+ type: type as ObjectType,
129+ size: { L1: size.L1, ...(typeof size.L2 === 'number' ? { L2: size.L2 } : {}) },
130+ params: numberMap(o.params, `object '${id}' params`),
131+ place: numberMap(o.place, `object '${id}' place`),
132+ };
133+ });
134+
135+ const bodyRaw = specRaw.body;
136+ if (!isRecord(bodyRaw)) throw new SceneFileError('missing "spec.body"');
137+ if (typeof bodyRaw.type !== 'string' || !(bodyRaw.type in BODY_TEMPLATES)) {
138+ throw new SceneFileError(
139+ `unknown body type '${String(bodyRaw.type)}' ` +
140+ `(one of: ${Object.keys(BODY_TEMPLATES).join(', ')})`,
141+ );
142+ }
143+ const spec: SceneSpec = {
144+ objects,
145+ body: {
146+ type: bodyRaw.type as BodyType,
147+ params: numberMap(bodyRaw.params, 'body params'),
148+ },
149+ };
150+
151+ const micRaw = raw.mic;
152+ if (
153+ !isRecord(micRaw) ||
154+ typeof micRaw.x !== 'number' || typeof micRaw.y !== 'number' || typeof micRaw.z !== 'number'
155+ ) {
156+ throw new SceneFileError('"mic" must be {x, y, z} in metres');
157+ }
158+
159+ const nx = raw.nx;
160+ if (typeof nx !== 'number' || !Number.isInteger(nx) || nx < 16 || nx % 2 !== 0) {
161+ throw new SceneFileError('"nx" must be an even integer of at least 16');
162+ }
163+
164+ let sources: SceneFile['sources'];
165+ if (raw.sources !== undefined) {
166+ if (!isRecord(raw.sources)) throw new SceneFileError('"sources" must be an object');
167+ sources = {};
168+ for (const key of ['model', 'scene'] as const) {
169+ const v = raw.sources[key];
170+ if (v === undefined) continue;
171+ if (typeof v !== 'string') throw new SceneFileError(`"sources.${key}" must be a string`);
172+ sources[key] = v;
173+ }
174+ }
175+
176+ return {
177+ soundbox: 1,
178+ spec,
179+ params: numberMap(raw.params, 'params'),
180+ sceneParams: numberMap(raw.sceneParams, 'sceneParams'),
181+ mic: { x: micRaw.x, y: micRaw.y, z: micRaw.z },
182+ nx,
183+ ...(sources && (sources.model || sources.scene) ? { sources } : {}),
184+ };
185+}
src/studio/session.tsmodified+5−0View file
@@ -43,6 +43,10 @@ export interface StudioSessionOptions {
4343 nx: number;
4444 /** Domain length along x, metres. */
4545 Lx?: number;
46+ /** Samples the microphone's trace holds; defaults to about 9 seconds at
47+ * the browser's default grid. The headless renderer sizes it from the
48+ * requested duration. */
49+ traceCapacity?: number;
4650 /** Grid fields one kernel may read, overriding what the device allows.
4751 * Only for tests. */
4852 operandBudget?: number;
@@ -172,6 +176,7 @@ export class StudioSession {
172176 nx: air.nx,
173177 ny: air.ny,
174178 nz: air.nz,
179+ capacity: opts.traceCapacity,
175180 });
176181
177182 const session = new StudioSession({
test/checks.tsmodified+58−1View file
@@ -9,8 +9,12 @@
99 * and a 32-point air grid already carries every code path the 128-point one
1010 * does.
1111 */
12-import { StudioSession } from '../src/studio/session.ts';
12+import { StudioSession, specParams } from '../src/studio/session.ts';
1313 import { cloneSpec, presetByKey } from '../src/studio/presets.ts';
14+import { makeSceneFile, parseSceneFile, serializeSceneFile } from '../src/studio/scenefile.ts';
15+import { generate } from '../src/studio/codegen.ts';
16+import { makeAirGrid, stableDt } from '../src/grids.ts';
17+import { C_AIR, CFL } from '../src/units.ts';
1418 import type { Params, SceneSpec } from '../src/studio/types.ts';
1519 import { planPlayback } from '../src/audio/play.ts';
1620
@@ -128,6 +132,59 @@ export async function codegenChecks(device: GPUDevice, check: Check, log: Log):
128132 s.destroy();
129133 }
130134
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. */
138+export 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+ );
155+
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+ );
166+
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');
186+}
187+
131188 /** A string sounds the pitch it is tuned to. */
132189 export async function stringChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
133190 // An ideal string (no stiffness, no damping to speak of), watched at a
↑↓ move↵openescclose