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 ...
4// steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP_ID] [--force]
5//
6// Parameter keys are the fields of PhysParams in SI units.
8import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9import { join } from "node:path";
10import { runSimulation, type RunResult } from "./run.ts";
11import { encodeWavBytes } from "../audio/wav.ts";
12import { Spectrogram, spectrogramPixels } from "../audio/spectrogram.ts";
13import { encodePng } from "./png.ts";
14import { clipMetrics, type ClipMetrics } from "./metrics.ts";
15import { defaultParams, type PhysParams } from "../params.ts";
16import { peakDbfs } from "../audio/level.ts";
18const args = process.argv.slice(2);
19const command = args.shift();
20const opts: Record<string, string> = {};
21const positional: string[] = [];
22const overrides: Record<string, unknown> = {};
23for (let i = 0; i < args.length; i++) {
24 const a = args[i];
25 if (a.startsWith("--")) {
26 const key = a.slice(2);
27 if (key === "force") opts[key] = "1";
28 else opts[key] = args[++i];
29 } else if (a.includes("=")) {
30 const [k, v] = a.split("=");
31 overrides[k] = parseValue(k, v);
32 } else positional.push(a);
33}
35function parseValue(key: string, v: string): unknown {
36 const d = (defaultParams as unknown as Record<string, unknown>)[key];
37 if (typeof d === "number") return parseFloat(v);
38 return v;
39}
41const SPEC_OPTS = { windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false };
42const SPEC_W = 640, SPEC_H = 200;
44function spectrogramPng(audio: Float32Array, fs: number, duration: number, peakRef?: number): { png: Uint8Array; peakDb: number } {
45 const spec = new Spectrogram(SPEC_OPTS);
46 spec.update(audio, audio.length);
47 const { columns, peakDb } = spec.data;
48 const px = spectrogramPixels(columns, peakDb, SPEC_OPTS, fs, duration, SPEC_W, SPEC_H, peakRef);
49 return { png: encodePng(px, SPEC_W, SPEC_H), peakDb };
50}
52interface ClipRecord {
53 id: string;
54 label: string;
55 overrides: Record<string, unknown>;
56 metrics: ClipMetrics;
57 gainDb: number;
58 peakDbfs: number;
59 run: { steps: number; seconds: number; nx: number; ny: number; dt: number; maxContactForce: number };
60}
62function writeClip(dir: string, id: string, label: string, ov: Record<string, unknown>, r: RunResult, gainDb: number, peakRef?: number): ClipRecord {
63 const metrics = clipMetrics(r.audio, r.fs);
64 writeFileSync(join(dir, `${id}.wav`), encodeWavBytes(r.audio, r.fs, "absolute", gainDb));
65 writeFileSync(join(dir, `${id}.png`), spectrogramPng(r.audio, r.fs, r.params.duration, peakRef).png);
66 const rec: ClipRecord = {
67 id, label, overrides: ov, metrics, gainDb,
68 peakDbfs: peakDbfs(r.audio, 10 ** (gainDb / 20)),
69 run: { steps: r.steps, seconds: r.seconds, nx: r.grid.nx, ny: r.grid.ny, dt: r.dt, maxContactForce: r.maxContactForce },
70 };
71 writeFileSync(join(dir, `${id}.json`), JSON.stringify(rec, null, 2));
72 return rec;
73}
75async function render(): Promise<void> {
76 const dir = opts.out ?? ".";
77 mkdirSync(dir, { recursive: true });
78 const id = opts.name ?? "clip";
79 const gainDb = parseFloat(opts.gain ?? "20");
80 console.log(`Rendering ${id}: ${JSON.stringify(overrides)}`);
81 const r = await runSimulation(overrides as Partial<PhysParams>, console.log);
82 const rec = writeClip(dir, id, id, overrides, r, gainDb);
83 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)`);
84 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`);
85}
87// ---------- gallery ----------
89interface ManifestClip { label: string; params: Record<string, unknown> }
90interface ManifestSweep {
91 id: string;
92 title: string;
93 text: string;
94 base?: Record<string, unknown>;
95 param?: string;
96 unit?: string;
97 scale?: number;
98 values?: (number | ManifestClip)[];
99 clips?: ManifestClip[];
100 gainDb?: number | "auto";
101}
102interface Manifest { title: string; intro: string; sweeps: ManifestSweep[] }
104function sweepClips(sw: ManifestSweep): ManifestClip[] {
105 if (sw.clips) return sw.clips;
106 const scale = sw.scale ?? 1;
107 return (sw.values ?? []).map((v) => {
108 if (typeof v === "number") return { label: `${+(v / scale).toPrecision(4)} ${sw.unit ?? ""}`.trim(), params: { [sw.param!]: v } };
109 return v;
110 });
111}
113async function gallery(): Promise<void> {
114 const manifestPath = positional[0];
115 if (!manifestPath) throw new Error("gallery needs a manifest path");
116 const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
117 const dir = opts.out ?? "docs/gallery";
118 const clipsDir = join(dir, "clips");
119 const rawDir = join(dir, "raw");
120 mkdirSync(clipsDir, { recursive: true });
121 mkdirSync(rawDir, { recursive: true });
122 const only = opts.only;
123 for (const sw of manifest.sweeps) {
124 if (only && sw.id !== only) continue;
125 console.log(`== ${sw.id}: ${sw.title}`);
126 const clips = sweepClips(sw);
127 const results: { clip: ManifestClip; id: string; ov: Record<string, unknown>; r: RunResult }[] = [];
128 for (let k = 0; k < clips.length; k++) {
129 const clip = clips[k];
130 const id = `${sw.id}-${k}`;
131 const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip
132 const rawPath = join(rawDir, `${id}.f32`);
133 const metaPath = join(rawDir, `${id}.meta.json`);
134 let r: RunResult | null = null;
135 if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) {
136 const meta = JSON.parse(readFileSync(metaPath, "utf8"));
137 if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) {
138 const bytes = readFileSync(rawPath);
139 r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() };
140 console.log(` ${id} (${clip.label}): cached`);
141 }
142 }
143 if (!r) {
144 console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`);
145 r = await runSimulation(ov as Partial<PhysParams>, console.log);
146 writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
147 const { audio: _a, params: _p, ...run } = r;
148 writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2));
149 console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`);
150 }
151 results.push({ clip, id, ov, r });
152 }
153 // One gain per sweep so that loudness differences within it are preserved.
154 let gainDb: number;
155 if (sw.gainDb === undefined || sw.gainDb === "auto") {
156 const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
157 gainDb = Math.round(3 - loudest);
158 } else gainDb = sw.gainDb;
159 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; }));
160 const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
161 writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
162 writeHtml(manifest, dir);
163 console.log(` written (gain ${gainDb} dB)`);
164 }
165 writeHtml(manifest, dir);
166}
168function esc(s: string): string {
169 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
170}
172function fmtNum(v: number, digits = 2): string {
173 if (!isFinite(v)) return "∞";
174 return v.toFixed(digits);
175}
177function writeHtml(manifest: Manifest, dir: string): void {
178 const sections: string[] = [];
179 for (const sw of manifest.sweeps) {
180 const path = join(dir, `${sw.id}.json`);
181 if (!existsSync(path)) continue;
182 const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
183 const cards = data.clips.map((c) => {
184 const q = new URLSearchParams();
185 for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
186 q.set("autostart", "1");
187 const m = c.metrics;
188 const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
189 return `<div class="card">
190 <div class="label">${esc(c.label)}</div>
191 <audio controls preload="none" src="clips/${c.id}.wav"></audio>
192 <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
193 <div class="metrics">
194 <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
195 <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
196 <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
197 <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
198 ${force}
199 <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
200 </div>
201 <a href="../../?${q.toString()}">Open in simulator</a>
202</div>`;
203 });
204 sections.push(`<section id="${sw.id}">
205<h2>${esc(sw.title)}</h2>
206<p>${esc(sw.text)}</p>
207<p class="dim">All clips in this section share the same playback gain (${data.gainDb >= 0 ? "+" : ""}${data.gainDb} dB relative to 1 Pa = 0 dBFS), so their loudness differences are real. Spectrograms: 0 to 5 kHz over the clip, 80 dB range, shared color scale.</p>
208<div class="cards">${cards.join("\n")}</div>
209</section>`);
210 }
211 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("");
212 const html = `<!doctype html>
213<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
214<title>${esc(manifest.title)}</title>
215<style>
216body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
217h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
218p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
219.cards { display: flex; flex-wrap: wrap; gap: 14px; }
220.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
221.card img { width: 100%; height: auto; border-radius: 4px; }
222.card audio { width: 100%; } .label { font-weight: 600; }
223.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
224ul.toc { columns: 2; }
225</style></head><body>
226<h1>${esc(manifest.title)}</h1>
227<p>${esc(manifest.intro)}</p>
228<ul class="toc">${toc}</ul>
229${sections.join("\n")}
230<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
231</body></html>`;
232 writeFileSync(join(dir, "index.html"), html);
233}
235const commands: Record<string, () => Promise<void>> = { render, gallery };
236if (!command || !(command in commands)) {
237 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
238 process.exit(2);
239}
240commands[command]().then(() => process.exit(0), (e) => { console.error(e); process.exit(1); });