c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 1// Command-line renderer.
2//
3// steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...
ea89863gallery --html-only: regenerate the page without simulatingJeremy Magland 4// steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP_ID] [--force] [--html-only] [key=value ...]
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 6// Common options: --engine chrome|dawn (default chrome), --chrome PATH (Chrome
7// executable; Playwright's Chromium when omitted), --dist DIR (built app).
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 8// Parameter keys are the fields of PhysParams in SI units.
10import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11import { join } from "node:path";
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 12import type { RunResult } from "./run.ts";
13import type { PhysParams as _PP } from "../params.ts";
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 14import { encodeWavBytes } from "../audio/wav.ts";
15import { Spectrogram, spectrogramPixels } from "../audio/spectrogram.ts";
16import { encodePng } from "./png.ts";
17import { clipMetrics, type ClipMetrics } from "./metrics.ts";
18import { defaultParams, type PhysParams } from "../params.ts";
19import { peakDbfs } from "../audio/level.ts";
21const args = process.argv.slice(2);
22const command = args.shift();
23const opts: Record<string, string> = {};
24const positional: string[] = [];
25const overrides: Record<string, unknown> = {};
26for (let i = 0; i < args.length; i++) {
27 const a = args[i];
28 if (a.startsWith("--")) {
29 const key = a.slice(2);
ea89863gallery --html-only: regenerate the page without simulatingJeremy Magland 30 if (key === "force" || key === "html-only") opts[key] = "1";
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 31 else if (key === "chrome" && (i + 1 >= args.length || args[i + 1].startsWith("--"))) opts[key] = "";
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 32 else opts[key] = args[++i];
33 } else if (a.includes("=")) {
34 const [k, v] = a.split("=");
35 overrides[k] = parseValue(k, v);
36 } else positional.push(a);
37}
39function parseValue(key: string, v: string): unknown {
40 const d = (defaultParams as unknown as Record<string, unknown>)[key];
41 if (typeof d === "number") return parseFloat(v);
42 return v;
43}
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 45/** Runs one simulation with the selected engine. */
46async function runSimulation(ov: Partial<_PP>, log: (s: string) => void): Promise<RunResult> {
47 if ((opts.engine ?? "chrome") === "dawn") {
48 const { runSimulation: dawn } = await import("./run.ts");
49 return dawn(ov, log);
50 }
51 const { runSimulationChrome } = await import("./chrome.ts");
52 return runSimulationChrome(ov, log, { chromePath: opts.chrome, distDir: opts.dist });
53}
55async function shutdown(): Promise<void> {
56 const { closeChrome } = await import("./chrome.ts");
57 await closeChrome();
58}
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 60const SPEC_OPTS = { windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false };
61const SPEC_W = 640, SPEC_H = 200;
63function spectrogramPng(audio: Float32Array, fs: number, duration: number, peakRef?: number): { png: Uint8Array; peakDb: number } {
64 const spec = new Spectrogram(SPEC_OPTS);
65 spec.update(audio, audio.length);
66 const { columns, peakDb } = spec.data;
67 const px = spectrogramPixels(columns, peakDb, SPEC_OPTS, fs, duration, SPEC_W, SPEC_H, peakRef);
68 return { png: encodePng(px, SPEC_W, SPEC_H), peakDb };
69}
71interface ClipRecord {
72 id: string;
73 label: string;
74 overrides: Record<string, unknown>;
75 metrics: ClipMetrics;
76 gainDb: number;
77 peakDbfs: number;
78 run: { steps: number; seconds: number; nx: number; ny: number; dt: number; maxContactForce: number };
79}
81function writeClip(dir: string, id: string, label: string, ov: Record<string, unknown>, r: RunResult, gainDb: number, peakRef?: number): ClipRecord {
82 const metrics = clipMetrics(r.audio, r.fs);
83 writeFileSync(join(dir, `${id}.wav`), encodeWavBytes(r.audio, r.fs, "absolute", gainDb));
84 writeFileSync(join(dir, `${id}.png`), spectrogramPng(r.audio, r.fs, r.params.duration, peakRef).png);
85 const rec: ClipRecord = {
86 id, label, overrides: ov, metrics, gainDb,
87 peakDbfs: peakDbfs(r.audio, 10 ** (gainDb / 20)),
88 run: { steps: r.steps, seconds: r.seconds, nx: r.grid.nx, ny: r.grid.ny, dt: r.dt, maxContactForce: r.maxContactForce },
89 };
90 writeFileSync(join(dir, `${id}.json`), JSON.stringify(rec, null, 2));
91 return rec;
92}
94async function render(): Promise<void> {
95 const dir = opts.out ?? ".";
96 mkdirSync(dir, { recursive: true });
97 const id = opts.name ?? "clip";
98 const gainDb = parseFloat(opts.gain ?? "20");
99 console.log(`Rendering ${id}: ${JSON.stringify(overrides)}`);
100 const r = await runSimulation(overrides as Partial<PhysParams>, console.log);
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 101 console.log(" simulation finished");
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 102 const rec = writeClip(dir, id, id, overrides, r, gainDb);
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 103 console.log(" files written");
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 104 console.log(` ${r.grid.nx}x${r.grid.ny} nodes, ${r.steps} steps in ${r.seconds.toFixed(1)} s (${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s)`);
105 console.log(` peak ${rec.metrics.peakPa.toFixed(3)} Pa (${rec.metrics.peakDbSpl.toFixed(1)} dB SPL), centroid ${rec.metrics.centroidHz.toFixed(0)} Hz, wav peak ${rec.peakDbfs.toFixed(1)} dBFS`);
106}
108// ---------- gallery ----------
110interface ManifestClip { label: string; params: Record<string, unknown> }
111interface ManifestSweep {
112 id: string;
113 title: string;
114 text: string;
115 base?: Record<string, unknown>;
116 param?: string;
117 unit?: string;
118 scale?: number;
119 values?: (number | ManifestClip)[];
120 clips?: ManifestClip[];
121 gainDb?: number | "auto";
122}
25dba3dGallery: fixed +20 dB playback gain matching the appJeremy Magland 123interface Manifest { title: string; intro: string; gainDb?: number | "auto"; sweeps: ManifestSweep[] }
125function sweepClips(sw: ManifestSweep): ManifestClip[] {
126 if (sw.clips) return sw.clips;
127 const scale = sw.scale ?? 1;
128 return (sw.values ?? []).map((v) => {
129 if (typeof v === "number") return { label: `${+(v / scale).toPrecision(4)} ${sw.unit ?? ""}`.trim(), params: { [sw.param!]: v } };
130 return v;
131 });
132}
134async function gallery(): Promise<void> {
135 const manifestPath = positional[0];
136 if (!manifestPath) throw new Error("gallery needs a manifest path");
137 const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
138 const dir = opts.out ?? "docs/gallery";
139 const clipsDir = join(dir, "clips");
140 const rawDir = join(dir, "raw");
141 mkdirSync(clipsDir, { recursive: true });
142 mkdirSync(rawDir, { recursive: true });
143 const only = opts.only;
ea89863gallery --html-only: regenerate the page without simulatingJeremy Magland 144 if (opts["html-only"]) {
145 writeHtml(manifest, dir);
146 console.log("index.html regenerated from the existing sweep files");
147 return;
148 }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 149 for (const sw of manifest.sweeps) {
150 if (only && sw.id !== only) continue;
151 console.log(`== ${sw.id}: ${sw.title}`);
152 const clips = sweepClips(sw);
153 const results: { clip: ManifestClip; id: string; ov: Record<string, unknown>; r: RunResult }[] = [];
154 for (let k = 0; k < clips.length; k++) {
155 const clip = clips[k];
156 const id = `${sw.id}-${k}`;
157 const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip
158 const rawPath = join(rawDir, `${id}.f32`);
159 const metaPath = join(rawDir, `${id}.meta.json`);
160 let r: RunResult | null = null;
161 if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) {
162 const meta = JSON.parse(readFileSync(metaPath, "utf8"));
163 if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) {
164 const bytes = readFileSync(rawPath);
165 r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() };
166 console.log(` ${id} (${clip.label}): cached`);
167 }
168 }
169 if (!r) {
170 console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`);
171 r = await runSimulation(ov as Partial<PhysParams>, console.log);
172 writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
173 const { audio: _a, params: _p, ...run } = r;
174 writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2));
175 console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`);
176 }
177 results.push({ clip, id, ov, r });
178 }
25dba3dGallery: fixed +20 dB playback gain matching the appJeremy Magland 179 // Playback gain: fixed (comparable across sweeps and with the app) or "auto"
180 // (the loudest clip of the sweep peaks at +3 dBFS before the soft clipper).
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 181 let gainDb: number;
25dba3dGallery: fixed +20 dB playback gain matching the appJeremy Magland 182 const gainSetting = sw.gainDb ?? manifest.gainDb ?? "auto";
183 if (gainSetting === "auto") {
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 184 const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
185 gainDb = Math.round(3 - loudest);
25dba3dGallery: fixed +20 dB playback gain matching the appJeremy Magland 186 } else gainDb = gainSetting;
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 187 const peakRef = Math.max(...results.map((x) => { const s = new Spectrogram(SPEC_OPTS); s.update(x.r.audio, x.r.audio.length); return s.data.peakDb; }));
188 const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
189 writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
190 writeHtml(manifest, dir);
191 console.log(` written (gain ${gainDb} dB)`);
192 }
193 writeHtml(manifest, dir);
194}
196function esc(s: string): string {
197 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
198}
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 200function fmtNum(v: number | null, digits = 2): string {
201 if (v === null || !isFinite(v)) return "∞";
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 202 return v.toFixed(digits);
203}
6dc8e3bGallery: setup diagram at the top of the pageJeremy Magland 205/** Inline SVG of the physical setup, drawn from the default parameters. */
206function setupDiagram(): string {
207 const p = defaultParams;
208 const k = 0.5; // px per mm
209 const x0 = 60, y0 = 80, W = p.Lx * 1e3 * k, H = p.Ly * 1e3 * k;
210 const hole = [x0 + p.holeInset * 1e3 * k + 4, y0 + p.holeInset * 1e3 * k + 4];
211 const push = [x0 + W, y0 + H];
212 const strike = [x0 + p.strikeFracX * W, y0 + (1 - p.strikeFracY) * H];
213 const gray = "#c8cbd0", dim = "#9aa0a8", accent = "#f0b429", blue = "#6fa8ea";
214 const tx = 430, ty = y0 + 10, tw = W, sag = Math.max(10, p.bowAmplitude * 1e3 * k * 6);
215 const listenerY = ty + 200;
216 const width = 880, height = y0 + H + 50;
217 return `<svg class="setup" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Physical setup">
218 <defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${accent}"/></marker></defs>
219 <g font-family="system-ui, sans-serif" font-size="12" fill="${gray}">
220 <text x="${x0}" y="${y0 - 42}" font-weight="600">Front view</text>
221 <text x="${x0}" y="${y0 - 24}" fill="${dim}">${(p.Lx / 0.0254).toFixed(0)} x ${(p.Ly / 0.0254).toFixed(0)} in, ${(p.h * 1e3).toFixed(2)} mm stainless steel</text>
222 <!-- thread and hanging sheet -->
223 <line x1="${hole[0]}" y1="${y0 - 8}" x2="${hole[0]}" y2="${hole[1]}" stroke="${gray}" stroke-width="1" stroke-dasharray="3 2"/>
224 <rect x="${x0}" y="${y0}" width="${W}" height="${H}" fill="#2a3340" stroke="${gray}" stroke-width="1.5"/>
225 <circle cx="${hole[0]}" cy="${hole[1]}" r="3" fill="#15171a" stroke="${gray}"/>
226 <text x="${hole[0] + 10}" y="${hole[1] + 16}" fill="${dim}">hangs from a thread through a hole</text>
227 <!-- strike point -->
228 <circle cx="${strike[0]}" cy="${strike[1]}" r="6" fill="none" stroke="${blue}" stroke-width="2"/>
229 <line x1="${strike[0] + 7}" y1="${strike[1] - 7}" x2="${strike[0] + 58}" y2="${strike[1] - 58}" stroke="#b08050" stroke-width="5" stroke-linecap="round"/>
230 <text x="${strike[0] + 68}" y="${strike[1] - 60}" fill="${blue}">drumstick strike</text>
231 <text x="${strike[0] + 68}" y="${strike[1] - 46}" fill="${dim}">${(p.strikeFracX * 100).toFixed(0)}% of width, ${(p.strikeFracY * 100).toFixed(0)}% of height</text>
232 <!-- pushed corner -->
233 <circle cx="${push[0]}" cy="${push[1]}" r="7" fill="none" stroke="${accent}" stroke-width="2"/>
234 <text x="${x0}" y="${push[1] + 24}" fill="${accent}">finger pushes this corner ${(p.pushAmount * 1e3).toFixed(0)} mm toward the listener,</text>
235 <text x="${x0}" y="${push[1] + 38}" fill="${dim}">holds it until the sheet settles, then lets go</text>
236 <!-- top view -->
237 <text x="${tx}" y="${y0 - 42}" font-weight="600">Top view</text>
238 <text x="${tx}" y="${y0 - 24}" fill="${dim}">the sheet seen from above</text>
239 <path d="M ${tx} ${ty} Q ${tx + tw / 2} ${ty + 2 * sag} ${tx + tw} ${ty}" fill="none" stroke="${gray}" stroke-width="3"/>
240 <text x="${tx + tw + 12}" y="${ty + 4}" fill="${dim}">bow of ${(p.bowAmplitude * 1e3).toFixed(0)} mm across the width</text>
241 <text x="${tx + tw + 12}" y="${ty + 18}" fill="${dim}">(exaggerated), bulging toward the listener</text>
242 <line x1="${tx + tw}" y1="${ty + 6}" x2="${tx + tw}" y2="${ty + 44}" stroke="${accent}" stroke-width="2" marker-end="url(#arrow)"/>
243 <text x="${tx + tw + 12}" y="${ty + 46}" fill="${accent}">push</text>
244 <line x1="${tx + tw / 2}" y1="${ty + sag + 8}" x2="${tx + tw / 2}" y2="${listenerY - 14}" stroke="${dim}" stroke-width="1" stroke-dasharray="4 3"/>
245 <text x="${tx + tw / 2 + 8}" y="${(ty + listenerY) / 2 + 10}" fill="${dim}">${p.listenerDist.toFixed(0)} m</text>
246 <circle cx="${tx + tw / 2}" cy="${listenerY}" r="8" fill="none" stroke="${blue}" stroke-width="2"/>
247 <circle cx="${tx + tw / 2}" cy="${listenerY}" r="2" fill="${blue}"/>
248 <text x="${tx + tw / 2 + 16}" y="${listenerY - 4}" fill="${blue}">listener</text>
249 <text x="${tx + tw / 2 + 16}" y="${listenerY + 12}" fill="${dim}">on the normal through the center</text>
250 </g>
251</svg>`;
252}
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 254function writeHtml(manifest: Manifest, dir: string): void {
255 const sections: string[] = [];
256 for (const sw of manifest.sweeps) {
257 const path = join(dir, `${sw.id}.json`);
258 if (!existsSync(path)) continue;
259 const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
260 const cards = data.clips.map((c) => {
261 const q = new URLSearchParams();
262 for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
263 const m = c.metrics;
264 const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
265 return `<div class="card">
266 <div class="label">${esc(c.label)}</div>
267 <audio controls preload="none" src="clips/${c.id}.wav"></audio>
268 <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
269 <div class="metrics">
270 <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
271 <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
272 <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
273 <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
274 ${force}
275 <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
276 </div>
277 <a href="../../?${q.toString()}">Open in simulator</a>
278</div>`;
279 });
280 sections.push(`<section id="${sw.id}">
281<h2>${esc(sw.title)}</h2>
282<p>${esc(sw.text)}</p>
25dba3dGallery: fixed +20 dB playback gain matching the appJeremy Magland 283<p class="dim">Playback gain ${data.gainDb >= 0 ? "+" : ""}${data.gainDb} dB relative to 1 Pa = 0 dBFS (the simulator's default is +20 dB); loud onsets are soft-clipped. Spectrograms: 0 to 5 kHz over the clip, 80 dB range, one color scale per section.</p>
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 284<div class="cards">${cards.join("\n")}</div>
285</section>`);
286 }
287 const toc = manifest.sweeps.filter((sw) => existsSync(join(dir, `${sw.id}.json`))).map((sw) => `<li><a href="#${sw.id}">${esc(sw.title)}</a></li>`).join("");
288 const html = `<!doctype html>
289<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
290<title>${esc(manifest.title)}</title>
291<style>
292body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
293h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
294p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
295.cards { display: flex; flex-wrap: wrap; gap: 14px; }
296.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
297.card img { width: 100%; height: auto; border-radius: 4px; }
298.card audio { width: 100%; } .label { font-weight: 600; }
299.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
300ul.toc { columns: 2; }
6dc8e3bGallery: setup diagram at the top of the pageJeremy Magland 301.setup-wrap { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 8px; display: inline-block; max-width: 100%; overflow-x: auto; }
302svg.setup { display: block; max-width: 100%; height: auto; }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 303</style></head><body>
304<h1>${esc(manifest.title)}</h1>
6dc8e3bGallery: setup diagram at the top of the pageJeremy Magland 305<div class="setup-wrap">${setupDiagram()}</div>
306<p class="dim">The setup: the sheet hangs from a thread through a hole near one corner. It is either pushed at the opposite corner by a fingertip, held until it settles, and released, or struck with a wooden drumstick. It has a slight bow, and the sound is computed 1 m in front of its center.</p>
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 307<p>${esc(manifest.intro)}</p>
308<ul class="toc">${toc}</ul>
309${sections.join("\n")}
310<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
311</body></html>`;
312 writeFileSync(join(dir, "index.html"), html);
313}
315const commands: Record<string, () => Promise<void>> = { render, gallery };
316if (!command || !(command in commands)) {
317 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
318 process.exit(2);
319}
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 320commands[command]().then(
321 async () => { await shutdown(); process.exit(0); },
322 async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); },
323);