/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / cli / main.ts
330 lines · 17.7 KBBlameHistoryRaw
1// Command-line renderer.
2//
3// steelsim render [--out DIR] [--name ID] [--gain DB] key=value ... (also writes ID.f32, the raw pressure)
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, holePoint, pushPoint, 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 // Raw listener pressure [Pa] as little-endian float32, for post-processing without the 16-bit floor.
104 writeFileSync(join(dir, `${id}.f32`), Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
105 console.log(" files written");
106 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)`);
107 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`);
110// ---------- gallery ----------
112interface ManifestClip { label: string; params: Record<string, unknown> }
113interface ManifestSweep {
114 id: string;
115 title: string;
116 text: string;
117 base?: Record<string, unknown>;
118 param?: string;
119 unit?: string;
120 scale?: number;
121 values?: (number | ManifestClip)[];
122 clips?: ManifestClip[];
123 gainDb?: number | "auto";
125interface Manifest { title: string; intro: string; gainDb?: number | "auto"; sweeps: ManifestSweep[] }
127function sweepClips(sw: ManifestSweep): ManifestClip[] {
128 if (sw.clips) return sw.clips;
129 const scale = sw.scale ?? 1;
130 return (sw.values ?? []).map((v) => {
131 if (typeof v === "number") return { label: `${+(v / scale).toPrecision(4)} ${sw.unit ?? ""}`.trim(), params: { [sw.param!]: v } };
132 return v;
133 });
136async function gallery(): Promise<void> {
137 const manifestPath = positional[0];
138 if (!manifestPath) throw new Error("gallery needs a manifest path");
139 const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
140 const dir = opts.out ?? "docs/gallery";
141 const clipsDir = join(dir, "clips");
142 const rawDir = join(dir, "raw");
143 mkdirSync(clipsDir, { recursive: true });
144 mkdirSync(rawDir, { recursive: true });
145 const only = opts.only;
146 if (opts["html-only"]) {
147 writeHtml(manifest, dir);
148 console.log("index.html regenerated from the existing sweep files");
149 return;
150 }
151 for (const sw of manifest.sweeps) {
152 if (only && sw.id !== only) continue;
153 console.log(`== ${sw.id}: ${sw.title}`);
154 const clips = sweepClips(sw);
155 const results: { clip: ManifestClip; id: string; ov: Record<string, unknown>; r: RunResult }[] = [];
156 for (let k = 0; k < clips.length; k++) {
157 const clip = clips[k];
158 const id = `${sw.id}-${k}`;
159 const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip
160 const rawPath = join(rawDir, `${id}.f32`);
161 const metaPath = join(rawDir, `${id}.meta.json`);
162 let r: RunResult | null = null;
163 if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) {
164 const meta = JSON.parse(readFileSync(metaPath, "utf8"));
165 if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) {
166 const bytes = readFileSync(rawPath);
167 r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() };
168 console.log(` ${id} (${clip.label}): cached`);
169 }
170 }
171 if (!r) {
172 console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`);
173 r = await runSimulation(ov as Partial<PhysParams>, console.log);
174 writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
175 const { audio: _a, params: _p, ...run } = r;
176 writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2));
177 console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`);
178 }
179 results.push({ clip, id, ov, r });
180 }
181 // Playback gain: fixed (comparable across sweeps and with the app) or "auto"
182 // (the loudest clip of the sweep peaks at +3 dBFS before the soft clipper).
183 let gainDb: number;
184 const gainSetting = sw.gainDb ?? manifest.gainDb ?? "auto";
185 if (gainSetting === "auto") {
186 const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
187 gainDb = Math.round(3 - loudest);
188 } else gainDb = gainSetting;
189 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; }));
190 const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
191 writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
192 writeHtml(manifest, dir);
193 console.log(` written (gain ${gainDb} dB)`);
194 }
195 writeHtml(manifest, dir);
198function esc(s: string): string {
199 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
202function fmtNum(v: number | null, digits = 2): string {
203 if (v === null || !isFinite(v)) return "∞";
204 return v.toFixed(digits);
207/** Inline SVG of the physical setup, drawn from the default parameters. */
208function setupDiagram(): string {
209 const p = defaultParams;
210 const k = 0.5; // px per mm
211 const x0 = 60, y0 = 80, W = p.Lx * 1e3 * k, H = p.Ly * 1e3 * k;
212 // Plate coordinates (m, origin at the center) to SVG pixels.
213 const toSvg = ([X, Y]: [number, number]) => [x0 + (X + p.Lx / 2) * 1e3 * k, y0 + (p.Ly / 2 - Y) * 1e3 * k];
214 const hole = toSvg(holePoint(p));
215 const push = toSvg(pushPoint(p));
216 const strike = [x0 + p.strikeFracX * W, y0 + (1 - p.strikeFracY) * H];
217 const outline = p.shape === "ellipse"
218 ? `<ellipse cx="${x0 + W / 2}" cy="${y0 + H / 2}" rx="${W / 2}" ry="${H / 2}" fill="#2a3340" stroke="${"#c8cbd0"}" stroke-width="1.5"/>`
219 : `<rect x="${x0}" y="${y0}" width="${W}" height="${H}" fill="#2a3340" stroke="${"#c8cbd0"}" stroke-width="1.5"/>`;
220 const gray = "#c8cbd0", dim = "#9aa0a8", accent = "#f0b429", blue = "#6fa8ea";
221 const tx = 430, ty = y0 + 10, tw = W, sag = Math.max(10, p.bowAmplitude * 1e3 * k * 6);
222 const listenerY = ty + 200;
223 const width = 880, height = y0 + H + 50;
224 return `<svg class="setup" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Physical setup">
225 <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>
226 <g font-family="system-ui, sans-serif" font-size="12" fill="${gray}">
227 <text x="${x0}" y="${y0 - 42}" font-weight="600">Front view</text>
228 <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>
229 <!-- thread and hanging sheet -->
230 <line x1="${hole[0]}" y1="${y0 - 8}" x2="${hole[0]}" y2="${hole[1]}" stroke="${gray}" stroke-width="1" stroke-dasharray="3 2"/>
231 ${outline}
232 <circle cx="${hole[0]}" cy="${hole[1]}" r="3" fill="#15171a" stroke="${gray}"/>
233 <text x="${hole[0] + 10}" y="${hole[1] + 16}" fill="${dim}">hangs from a thread through a hole</text>
234 <!-- strike point -->
235 <circle cx="${strike[0]}" cy="${strike[1]}" r="6" fill="none" stroke="${blue}" stroke-width="2"/>
236 <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"/>
237 <text x="${strike[0] + 68}" y="${strike[1] - 60}" fill="${blue}">drumstick strike</text>
238 <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>
239 <!-- pushed corner -->
240 <circle cx="${push[0]}" cy="${push[1]}" r="7" fill="none" stroke="${accent}" stroke-width="2"/>
241 <text x="${x0}" y="${y0 + H + 24}" fill="${accent}">finger pushes this ${p.shape === "ellipse" ? "edge point" : "corner"} ${(p.pushAmount * 1e3).toFixed(0)} mm toward the listener,</text>
242 <text x="${x0}" y="${y0 + H + 38}" fill="${dim}">holds it until the sheet settles, then lets go</text>
243 <!-- top view -->
244 <text x="${tx}" y="${y0 - 42}" font-weight="600">Top view</text>
245 <text x="${tx}" y="${y0 - 24}" fill="${dim}">the sheet seen from above</text>
246 <path d="M ${tx} ${ty} Q ${tx + tw / 2} ${ty + 2 * sag} ${tx + tw} ${ty}" fill="none" stroke="${gray}" stroke-width="3"/>
247 <text x="${tx + tw + 12}" y="${ty + 4}" fill="${dim}">bow of ${(p.bowAmplitude * 1e3).toFixed(0)} mm across the width</text>
248 <text x="${tx + tw + 12}" y="${ty + 18}" fill="${dim}">(exaggerated), bulging toward the listener</text>
249 <line x1="${tx + tw}" y1="${ty + 6}" x2="${tx + tw}" y2="${ty + 44}" stroke="${accent}" stroke-width="2" marker-end="url(#arrow)"/>
250 <text x="${tx + tw + 12}" y="${ty + 46}" fill="${accent}">push</text>
251 <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"/>
252 <text x="${tx + tw / 2 + 8}" y="${(ty + listenerY) / 2 + 10}" fill="${dim}">${p.listenerDist.toFixed(0)} m</text>
253 <circle cx="${tx + tw / 2}" cy="${listenerY}" r="8" fill="none" stroke="${blue}" stroke-width="2"/>
254 <circle cx="${tx + tw / 2}" cy="${listenerY}" r="2" fill="${blue}"/>
255 <text x="${tx + tw / 2 + 16}" y="${listenerY - 4}" fill="${blue}">listener</text>
256 <text x="${tx + tw / 2 + 16}" y="${listenerY + 12}" fill="${dim}">on the normal through the center</text>
257 </g>
258</svg>`;
261function writeHtml(manifest: Manifest, dir: string): void {
262 const sections: string[] = [];
263 for (const sw of manifest.sweeps) {
264 const path = join(dir, `${sw.id}.json`);
265 if (!existsSync(path)) continue;
266 const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
267 const cards = data.clips.map((c) => {
268 const q = new URLSearchParams();
269 for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
270 const m = c.metrics;
271 const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
272 return `<div class="card">
273 <div class="label">${esc(c.label)}</div>
274 <audio controls preload="none" src="clips/${c.id}.wav"></audio>
275 <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
276 <div class="metrics">
277 <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
278 <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
279 <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
280 <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
281 ${force}
282 <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
283 </div>
284 <a href="../../?${q.toString()}">Open in simulator</a>
285</div>`;
286 });
287 sections.push(`<section id="${sw.id}">
288<h2>${esc(sw.title)}</h2>
289<p>${esc(sw.text)}</p>
290<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>
291<div class="cards">${cards.join("\n")}</div>
292</section>`);
293 }
294 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("");
295 const html = `<!doctype html>
296<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
297<title>${esc(manifest.title)}</title>
298<style>
299body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
300h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
301p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
302.cards { display: flex; flex-wrap: wrap; gap: 14px; }
303.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
304.card img { width: 100%; height: auto; border-radius: 4px; }
305.card audio { width: 100%; } .label { font-weight: 600; }
306.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
307ul.toc { columns: 2; }
308.setup-wrap { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 8px; display: inline-block; max-width: 100%; overflow-x: auto; }
309svg.setup { display: block; max-width: 100%; height: auto; }
310</style></head><body>
311<h1>${esc(manifest.title)}</h1>
312<div class="setup-wrap">${setupDiagram()}</div>
313<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>
314<p>${esc(manifest.intro)}</p>
315<ul class="toc">${toc}</ul>
316${sections.join("\n")}
317<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
318</body></html>`;
319 writeFileSync(join(dir, "index.html"), html);
322const commands: Record<string, () => Promise<void>> = { render, gallery };
323if (!command || !(command in commands)) {
324 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
325 process.exit(2);
327commands[command]().then(
328 async () => { await shutdown(); process.exit(0); },
329 async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); },
330);
↑↓ move↵openescclose