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 */
30import { readFile, writeFile } from 'node:fs/promises';
31import { basename } from 'node:path';
32import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
33import { requestAcousticDevice } from '../src/device.ts';
34import { StudioSession } from '../src/studio/session.ts';
35import { parseSceneFile, makeSceneFile, type SceneFile } from '../src/studio/scenefile.ts';
36import { PRESETS, cloneSpec, presetByKey } from '../src/studio/presets.ts';
37import { specParams } from '../src/studio/session.ts';
38import { stableDt } from '../src/grids.ts';
39import { traceToWav, WAV_RATE } from '../src/audio/wav.ts';
40import { C_AIR, CFL, DOMAIN_X, POOR_RESOLUTION, fmtTime } from '../src/units.ts';
42function 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}
51/* ---------------------------------------------------------------- args --- */
53const args = process.argv.slice(2);
54let scenePath: string | null = null;
55let presetKey: string | null = null;
56let seconds = 5;
57let nxOverride: number | null = null;
58let micOverride: { x: number; y: number; z: number } | null = null;
59let outPath: string | null = null;
61for (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}
83if ((scenePath === null) === (presetKey === null)) usage(); // exactly one
84if (!(seconds > 0) || seconds > 600) {
85 console.error('--seconds must be between 0 and 600');
86 process.exit(2);
87}
89/* --------------------------------------------------------------- scene --- */
91let file: SceneFile;
92let name: string;
93if (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}
118const nx = nxOverride ?? file.nx;
119if (!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}
123const mic = micOverride ?? file.mic;
124const out = outPath ?? `${name}.wav`;
126/* -------------------------------------------------------------- render --- */
128let device: GPUDevice;
129try {
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}
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.
142const dtEstimate = stableDt(DOMAIN_X / nx, C_AIR, CFL);
143const capacity = Math.ceil((1.05 * seconds) / dtEstimate);
144if (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}
152const t0 = performance.now();
153const 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});
166const tCompiled = performance.now();
168const { air } = session;
169const fmax = C_AIR / (POOR_RESOLUTION * air.h);
170const 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(', ');
176console.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);
183session.setMic(mic.x, mic.y, mic.z);
184session.excite();
186const total = Math.ceil(seconds / session.dt);
187if (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}
193const steps = Math.min(total, session.recorder.capacity);
194const batch = 2048;
195let done = 0;
196let lastReport = -1;
197while (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}
213const tRun = performance.now();
215const trace = await session.recorder.read();
216let peak = 0;
217for (const v of trace) peak = Math.max(peak, Math.abs(v));
218if (!(peak > 0)) {
219 console.error('warning: the microphone recorded silence — is every gain zero, or the mic inside a wall?');
220}
221const wav = traceToWav(trace, session.dt);
222await writeFile(out, Buffer.from(await wav.arrayBuffer()));
224const wall = (tRun - tCompiled) / 1000;
225console.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);
230session.destroy();
231process.exit(0);