/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / cli / main.ts
269 lines · 12.4 KBBlameHistoryRaw
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] [--html-only] [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" || key === "html-only") 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);
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;
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 });
55async function shutdown(): Promise<void> {
56 const { closeChrome } = await import("./chrome.ts");
57 await closeChrome();
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 };
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 };
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;
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`);
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";
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 });
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 if (opts["html-only"]) {
145 writeHtml(manifest, dir);
146 console.log("index.html regenerated from the existing sweep files");
147 return;
148 }
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 }
179 // One gain per sweep so that loudness differences within it are preserved.
180 let gainDb: number;
181 if (sw.gainDb === undefined || sw.gainDb === "auto") {
182 const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
183 gainDb = Math.round(3 - loudest);
184 } else gainDb = sw.gainDb;
185 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; }));
186 const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
187 writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
188 writeHtml(manifest, dir);
189 console.log(` written (gain ${gainDb} dB)`);
190 }
191 writeHtml(manifest, dir);
194function esc(s: string): string {
195 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
198function fmtNum(v: number | null, digits = 2): string {
199 if (v === null || !isFinite(v)) return "∞";
200 return v.toFixed(digits);
203function writeHtml(manifest: Manifest, dir: string): void {
204 const sections: string[] = [];
205 for (const sw of manifest.sweeps) {
206 const path = join(dir, `${sw.id}.json`);
207 if (!existsSync(path)) continue;
208 const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
209 const cards = data.clips.map((c) => {
210 const q = new URLSearchParams();
211 for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
212 q.set("autostart", "1");
213 const m = c.metrics;
214 const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
215 return `<div class="card">
216 <div class="label">${esc(c.label)}</div>
217 <audio controls preload="none" src="clips/${c.id}.wav"></audio>
218 <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
219 <div class="metrics">
220 <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
221 <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
222 <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
223 <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
224 ${force}
225 <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
226 </div>
227 <a href="../../?${q.toString()}">Open in simulator</a>
228</div>`;
229 });
230 sections.push(`<section id="${sw.id}">
231<h2>${esc(sw.title)}</h2>
232<p>${esc(sw.text)}</p>
233<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>
234<div class="cards">${cards.join("\n")}</div>
235</section>`);
236 }
237 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("");
238 const html = `<!doctype html>
239<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
240<title>${esc(manifest.title)}</title>
241<style>
242body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
243h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
244p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
245.cards { display: flex; flex-wrap: wrap; gap: 14px; }
246.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
247.card img { width: 100%; height: auto; border-radius: 4px; }
248.card audio { width: 100%; } .label { font-weight: 600; }
249.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
250ul.toc { columns: 2; }
251</style></head><body>
252<h1>${esc(manifest.title)}</h1>
253<p>${esc(manifest.intro)}</p>
254<ul class="toc">${toc}</ul>
255${sections.join("\n")}
256<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
257</body></html>`;
258 writeFileSync(join(dir, "index.html"), html);
261const commands: Record<string, () => Promise<void>> = { render, gallery };
262if (!command || !(command in commands)) {
263 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
264 process.exit(2);
266commands[command]().then(
267 async () => { await shutdown(); process.exit(0); },
268 async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); },
269);
↑↓ move↵openescclose