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 -- --preset drum --seconds 20 --fast
18 * npm run render -- --list-presets
19 *
20 * Options:
21 * --seconds N duration of audio to render (default 5)
22 * --nx N override the air grid (even; memory grows as nx^3)
23 * --mic x,y,z override the microphone position, metres
24 * --out FILE output path (default: the scene's or preset's name.wav)
25 * --fast the modal path: record the room's impulse responses
26 * once by reciprocity, synthesize the trace from every
27 * object's closed-form modes. Same numbers as the full
28 * solver up to the options below; the win grows with
29 * --seconds, since only the room recording touches the
30 * air grid. See src/studio/fast.ts. Needs the generated
31 * model .m (a hand-edited scene .m is fine).
32 * --ir-seconds N fast: kernel (room response) length (default 0.5, or the
33 * full duration if shorter). Raise it if the decay figure
34 * the render prints looks high.
35 * --modal-eps X fast: drop membrane modes weaker than X times the
36 * strongest (default 1e-6; 0 keeps every mode the strike
37 * touches; strings and bars always keep every mode)
38 * --fmax F fast: drop membrane modes above F Hz (default: keep all)
39 *
40 * Runs through vite-node (which resolves numbl's compiler sources) on
41 * desktop WebGPU via the optional `webgpu` package (Google Dawn). On a
42 * headless Linux box Dawn needs working Vulkan; nodeWebGpu.ts explains
43 * itself when it cannot load.
44 */
45import { readFile, writeFile } from 'node:fs/promises';
46import { basename } from 'node:path';
47import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
48import { requestAcousticDevice } from '../src/device.ts';
49import { StudioSession } from '../src/studio/session.ts';
50import { parseSceneFile, makeSceneFile, type SceneFile } from '../src/studio/scenefile.ts';
51import { PRESETS, cloneSpec, presetByKey } from '../src/studio/presets.ts';
52import { specParams } from '../src/studio/session.ts';
53import { stableDt } from '../src/grids.ts';
54import { fastIneligible, fastRender } from '../src/studio/fast.ts';
55import { traceToWav, WAV_RATE } from '../src/audio/wav.ts';
56import { C_AIR, CFL, DOMAIN_X, POOR_RESOLUTION, fmtTime } from '../src/units.ts';
58function usage(): never {
59 console.error(
60 'usage: npm run render -- <scene.json | --preset KEY> ' +
61 '[--seconds N] [--nx N] [--mic x,y,z] [--out FILE]\n' +
62 ' npm run render -- --list-presets',
63 );
64 process.exit(2);
65}
67/* ---------------------------------------------------------------- args --- */
69const args = process.argv.slice(2);
70let scenePath: string | null = null;
71let presetKey: string | null = null;
72let seconds = 5;
73let nxOverride: number | null = null;
74let micOverride: { x: number; y: number; z: number } | null = null;
75let outPath: string | null = null;
76let fast = false;
77let irSeconds: number | null = null;
78let modalEps = 1e-6;
79let modalFmax = Infinity;
81for (let i = 0; i < args.length; i++) {
82 const a = args[i];
83 const next = (): string => {
84 const v = args[++i];
85 if (v === undefined) usage();
86 return v;
87 };
88 if (a === '--list-presets') {
89 for (const p of PRESETS) console.log(`${p.key.padEnd(10)} ${p.blurb}`);
90 process.exit(0);
91 } else if (a === '--preset') presetKey = next();
92 else if (a === '--seconds') seconds = Number(next());
93 else if (a === '--nx') nxOverride = Number(next());
94 else if (a === '--out') outPath = next();
95 else if (a === '--fast') fast = true;
96 else if (a === '--ir-seconds') irSeconds = Number(next());
97 else if (a === '--modal-eps') modalEps = Number(next());
98 else if (a === '--fmax') modalFmax = Number(next());
99 else if (a === '--mic') {
100 const parts = next().split(',').map(Number);
101 if (parts.length !== 3 || parts.some((v) => !Number.isFinite(v))) usage();
102 micOverride = { x: parts[0], y: parts[1], z: parts[2] };
103 } else if (a.startsWith('--')) usage();
104 else if (scenePath === null) scenePath = a;
105 else usage();
106}
107if ((scenePath === null) === (presetKey === null)) usage(); // exactly one
108if (!(seconds > 0) || seconds > 600) {
109 console.error('--seconds must be between 0 and 600');
110 process.exit(2);
111}
113/* --------------------------------------------------------------- scene --- */
115let file: SceneFile;
116let name: string;
117if (scenePath !== null) {
118 const text = await readFile(scenePath, 'utf8').catch((e: unknown) => {
119 console.error(`cannot read ${scenePath}: ${errMsg(e)}`);
120 process.exit(1);
121 });
122 try {
123 file = parseSceneFile(text);
124 } catch (e) {
125 console.error(errMsg(e));
126 process.exit(1);
127 }
128 name = basename(scenePath).replace(/\.json$/i, '');
129} else {
130 const preset = presetByKey(presetKey!);
131 if (!preset) {
132 console.error(
133 `no preset '${presetKey}' (one of: ${PRESETS.map((p) => p.key).join(', ')})`,
134 );
135 process.exit(1);
136 }
137 const spec = cloneSpec(preset.spec);
138 file = makeSceneFile({ spec, ...specParams(spec), mic: preset.mic, nx: 96 });
139 name = preset.key;
140}
142const nx = nxOverride ?? file.nx;
143if (!Number.isInteger(nx) || nx < 16 || nx % 2 !== 0) {
144 console.error(`--nx must be an even integer of at least 16 (got ${nx})`);
145 process.exit(1);
146}
147const mic = micOverride ?? file.mic;
148const out = outPath ?? `${name}.wav`;
150/* -------------------------------------------------------------- render --- */
152let device: GPUDevice;
153try {
154 const runtime = await installWebGpu();
155 device = await requestAcousticDevice();
156 console.log(`soundbox render — ${runtime}`);
157} catch (e) {
158 console.error(`render: ${errMsg(e)}\n${NO_ADAPTER_HINT}`);
159 process.exit(1);
160}
162/* ---------------------------------------------------- the fast path ----- */
164if (fast) {
165 const bad = fastIneligible(file.spec, file.sources?.model);
166 if (bad) {
167 console.error(`--fast: ${bad}. Rendering without --fast works for every scene.`);
168 process.exit(1);
169 }
170 const defaults = specParams(file.spec);
171 const params = { ...defaults.params, ...file.params };
172 const sceneParams = { ...defaults.sceneParams, ...file.sceneParams };
173 const dt = stableDt(DOMAIN_X / nx, C_AIR, CFL);
174 const steps = Math.ceil(seconds / dt);
175 const ir = irSeconds ?? Math.min(seconds, 0.5);
177 const t0 = performance.now();
178 let lastPct = -1;
179 const result = await fastRender({
180 device,
181 spec: file.spec,
182 params,
183 sceneParams,
184 sceneSource: file.sources?.scene,
185 nx,
186 mic,
187 steps,
188 irSeconds: ir,
189 modeOpts: { eps: modalEps, fmax: modalFmax },
190 onProgress: (done, total) => {
191 const pct = Math.floor((10 * done) / total);
192 if (pct > lastPct) {
193 lastPct = pct;
194 process.stderr.write(` room ${String(10 * pct).padStart(3)}%\n`);
195 }
196 },
197 }).catch((e: unknown) => {
198 console.error(`--fast failed: ${errMsg(e)}`);
199 process.exit(1);
200 });
202 const { air } = result;
203 const fmaxHonest = C_AIR / (POOR_RESOLUTION * air.h);
204 const decayDb = result.kernelDecay > 0 ? 20 * Math.log10(result.kernelDecay) : -Infinity;
205 const objects = result.objects
206 .map((or) => {
207 const g = or.grid;
208 const size = g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`;
209 return `${or.id} (${size}, ${or.modes.length} modes${or.bridgeRow !== null ? ' + bridge' : ''})`;
210 })
211 .join(', ');
212 console.log(
213 `air ${air.nx}×${air.ny}×${air.nz} (honest to ~${(fmaxHonest / 1000).toFixed(1)} kHz) · ` +
214 `in closed form: ${objects}\n` +
215 `room ${fmtTime(result.room.steps * result.dt)} by reciprocity ` +
216 `(${result.room.rows} kernels), ` +
217 `${decayDb === -Infinity ? 'silent' : `${decayDb.toFixed(0)} dB down`} at its end` +
218 (decayDb > -60 && result.room.steps < steps
219 ? ' — still ringing; consider a longer --ir-seconds'
220 : '') +
221 (result.missing > 0 ? ` · ${result.missing} wanted modes unrecorded` : ''),
222 );
223 let peak = 0;
224 for (const v of result.trace) peak = Math.max(peak, Math.abs(v));
225 if (!(peak > 0)) {
226 console.error('warning: the trace is silence — is the gain zero, or the mic inside a wall?');
227 }
228 const wav = traceToWav(result.trace, result.dt);
229 await writeFile(out, Buffer.from(await wav.arrayBuffer()));
230 const wall = (performance.now() - t0) / 1000;
231 console.log(
232 `${fmtTime(steps * result.dt)} of audio in ${wall.toFixed(1)} s ` +
233 `(${((steps * result.dt) / wall).toFixed(2)}× real time) -> ` +
234 `${out} (${WAV_RATE / 1000} kHz, ${(steps * result.dt).toFixed(2)} s)`,
235 );
236 process.exit(0);
237}
239// Size the trace for the request before the session exists. The estimate
240// assumes the medium tops out at the speed of air, which holds for every
241// generated scene; a hand-edited faster medium shrinks dt, and the shortfall
242// is reported below rather than silently truncated.
243const dtEstimate = stableDt(DOMAIN_X / nx, C_AIR, CFL);
244const capacity = Math.ceil((1.05 * seconds) / dtEstimate);
245if (capacity > 300_000_000) {
246 console.error(
247 `${fmtTime(seconds)} at nx = ${nx} is ${capacity.toLocaleString()} samples of ` +
248 `trace — more GPU memory than is sensible. Lower --seconds or --nx.`,
249 );
250 process.exit(1);
251}
253const t0 = performance.now();
254const session = await StudioSession.create({
255 device,
256 spec: file.spec,
257 params: file.params,
258 sceneParams: file.sceneParams,
259 modelSource: file.sources?.model,
260 sceneSource: file.sources?.scene,
261 nx,
262 traceCapacity: capacity,
263}).catch((e: unknown) => {
264 console.error(`compile failed: ${errMsg(e)}`);
265 process.exit(1);
266});
267const tCompiled = performance.now();
269const { air } = session;
270const fmax = C_AIR / (POOR_RESOLUTION * air.h);
271const objects = file.spec.objects
272 .map((o) => {
273 const g = session.grids[o.id];
274 return `${o.id} (${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`})`;
275 })
276 .join(', ');
277console.log(
278 `air ${air.nx}×${air.ny}×${air.nz} (${(air.npts / 1e6).toFixed(2)} M cells, ` +
279 `honest to ~${(fmax / 1000).toFixed(1)} kHz) · ${objects}\n` +
280 `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz) · ` +
281 `compiled in ${((tCompiled - t0) / 1000).toFixed(1)} s`,
282);
284session.setMic(mic.x, mic.y, mic.z);
285session.excite();
287const total = Math.ceil(seconds / session.dt);
288if (total > session.recorder.capacity) {
289 console.error(
290 `the scene's medium lowered dt below the estimate; only ` +
291 `${fmtTime(session.recorder.capacity * session.dt)} of trace fits. Rendering that.`,
292 );
293}
294const steps = Math.min(total, session.recorder.capacity);
295const batch = 2048;
296let done = 0;
297let lastReport = -1;
298while (done < steps) {
299 const n = Math.min(batch, steps - done);
300 session.step(n);
301 done += n;
302 await session.sync();
303 const pct = Math.floor((10 * done) / steps);
304 if (pct > lastReport) {
305 lastReport = pct;
306 const dt = (performance.now() - tCompiled) / 1000;
307 const rate = done / Math.max(dt, 1e-9);
308 process.stderr.write(
309 ` ${String(10 * pct).padStart(3)}% ${(rate / 1000).toFixed(0)}k steps/s ` +
310 `${fmtTime(done * session.dt)} of audio\n`,
311 );
312 }
313}
314const tRun = performance.now();
316const trace = await session.recorder.read();
317let peak = 0;
318for (const v of trace) peak = Math.max(peak, Math.abs(v));
319if (!(peak > 0)) {
320 console.error('warning: the microphone recorded silence — is every gain zero, or the mic inside a wall?');
321}
322const wav = traceToWav(trace, session.dt);
323await writeFile(out, Buffer.from(await wav.arrayBuffer()));
325const wall = (tRun - tCompiled) / 1000;
326console.log(
327 `${fmtTime(trace.length * session.dt)} of audio in ${wall.toFixed(1)} s ` +
328 `(${(trace.length * session.dt / wall).toFixed(2)}× real time) -> ` +
329 `${out} (${WAV_RATE / 1000} kHz, ${(trace.length * session.dt).toFixed(2)} s)`,
330);
331session.destroy();
332process.exit(0);