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] [key=value ...]
5//
6// Common options: --engine chrome|dawn (default chrome), --chrome PATH (Chrome
7// executable; Playwright's Chromium when omitted), --dist DIR (built app).
8// Parameter keys are the fields of PhysParams in SI units.
10import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11import { join } from "node:path";
12import type { RunResult } from "./run.ts";
13import type { PhysParams as _PP } from "../params.ts";
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);
30 if (key === "force") opts[key] = "1";
31 else if (key === "chrome" && (i + 1 >= args.length || args[i + 1].startsWith("--"))) opts[key] = "";
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}
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}
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);
101 console.log(" simulation finished");
102 const rec = writeClip(dir, id, id, overrides, r, gainDb);
103 console.log(" files written");
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}
123interface Manifest { title: string; intro: string; 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;
144 for (const sw of manifest.sweeps) {
145 if (only && sw.id !== only) continue;
146 console.log(`== ${sw.id}: ${sw.title}`);
147 const clips = sweepClips(sw);
148 const results: { clip: ManifestClip; id: string; ov: Record<string, unknown>; r: RunResult }[] = [];
149 for (let k = 0; k < clips.length; k++) {
150 const clip = clips[k];
151 const id = `${sw.id}-${k}`;
152 const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip
153 const rawPath = join(rawDir, `${id}.f32`);
154 const metaPath = join(rawDir, `${id}.meta.json`);
155 let r: RunResult | null = null;
156 if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) {
157 const meta = JSON.parse(readFileSync(metaPath, "utf8"));
158 if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) {
159 const bytes = readFileSync(rawPath);
160 r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() };
161 console.log(` ${id} (${clip.label}): cached`);
162 }
163 }
164 if (!r) {
165 console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`);
166 r = await runSimulation(ov as Partial<PhysParams>, console.log);
167 writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
168 const { audio: _a, params: _p, ...run } = r;
169 writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2));
170 console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`);
171 }
172 results.push({ clip, id, ov, r });
173 }
174 // One gain per sweep so that loudness differences within it are preserved.
175 let gainDb: number;
176 if (sw.gainDb === undefined || sw.gainDb === "auto") {
177 const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
178 gainDb = Math.round(3 - loudest);
179 } else gainDb = sw.gainDb;
180 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; }));
181 const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
182 writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
183 writeHtml(manifest, dir);
184 console.log(` written (gain ${gainDb} dB)`);
185 }
186 writeHtml(manifest, dir);
187}
189function esc(s: string): string {
190 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
191}
193function fmtNum(v: number | null, digits = 2): string {
194 if (v === null || !isFinite(v)) return "∞";
195 return v.toFixed(digits);
196}
198function writeHtml(manifest: Manifest, dir: string): void {
199 const sections: string[] = [];
200 for (const sw of manifest.sweeps) {
201 const path = join(dir, `${sw.id}.json`);
202 if (!existsSync(path)) continue;
203 const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
204 const cards = data.clips.map((c) => {
205 const q = new URLSearchParams();
206 for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
207 q.set("autostart", "1");
208 const m = c.metrics;
209 const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
210 return `<div class="card">
211 <div class="label">${esc(c.label)}</div>
212 <audio controls preload="none" src="clips/${c.id}.wav"></audio>
213 <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
214 <div class="metrics">
215 <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
216 <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
217 <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
218 <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
219 ${force}
220 <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
221 </div>
222 <a href="../../?${q.toString()}">Open in simulator</a>
223</div>`;
224 });
225 sections.push(`<section id="${sw.id}">
226<h2>${esc(sw.title)}</h2>
227<p>${esc(sw.text)}</p>
228<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>
229<div class="cards">${cards.join("\n")}</div>
230</section>`);
231 }
232 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("");
233 const html = `<!doctype html>
234<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
235<title>${esc(manifest.title)}</title>
236<style>
237body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
238h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
239p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
240.cards { display: flex; flex-wrap: wrap; gap: 14px; }
241.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
242.card img { width: 100%; height: auto; border-radius: 4px; }
243.card audio { width: 100%; } .label { font-weight: 600; }
244.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
245ul.toc { columns: 2; }
246</style></head><body>
247<h1>${esc(manifest.title)}</h1>
248<p>${esc(manifest.intro)}</p>
249<ul class="toc">${toc}</ul>
250${sections.join("\n")}
251<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
252</body></html>`;
253 writeFileSync(join(dir, "index.html"), html);
254}
256const commands: Record<string, () => Promise<void>> = { render, gallery };
257if (!command || !(command in commands)) {
258 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
259 process.exit(2);
260}
261commands[command]().then(
262 async () => { await shutdown(); process.exit(0); },
263 async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); },
264);