// Command-line renderer. // // steelsim render [--out DIR] [--name ID] [--gain DB] key=value ... // steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP_ID] [--force] [--html-only] [key=value ...] // // Common options: --engine chrome|dawn (default chrome), --chrome PATH (Chrome // executable; Playwright's Chromium when omitted), --dist DIR (built app). // Parameter keys are the fields of PhysParams in SI units. import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { RunResult } from "./run.ts"; import type { PhysParams as _PP } from "../params.ts"; import { encodeWavBytes } from "../audio/wav.ts"; import { Spectrogram, spectrogramPixels } from "../audio/spectrogram.ts"; import { encodePng } from "./png.ts"; import { clipMetrics, type ClipMetrics } from "./metrics.ts"; import { defaultParams, type PhysParams } from "../params.ts"; import { peakDbfs } from "../audio/level.ts"; const args = process.argv.slice(2); const command = args.shift(); const opts: Record = {}; const positional: string[] = []; const overrides: Record = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const key = a.slice(2); if (key === "force" || key === "html-only") opts[key] = "1"; else if (key === "chrome" && (i + 1 >= args.length || args[i + 1].startsWith("--"))) opts[key] = ""; else opts[key] = args[++i]; } else if (a.includes("=")) { const [k, v] = a.split("="); overrides[k] = parseValue(k, v); } else positional.push(a); } function parseValue(key: string, v: string): unknown { const d = (defaultParams as unknown as Record)[key]; if (typeof d === "number") return parseFloat(v); return v; } /** Runs one simulation with the selected engine. */ async function runSimulation(ov: Partial<_PP>, log: (s: string) => void): Promise { if ((opts.engine ?? "chrome") === "dawn") { const { runSimulation: dawn } = await import("./run.ts"); return dawn(ov, log); } const { runSimulationChrome } = await import("./chrome.ts"); return runSimulationChrome(ov, log, { chromePath: opts.chrome, distDir: opts.dist }); } async function shutdown(): Promise { const { closeChrome } = await import("./chrome.ts"); await closeChrome(); } const SPEC_OPTS = { windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false }; const SPEC_W = 640, SPEC_H = 200; function spectrogramPng(audio: Float32Array, fs: number, duration: number, peakRef?: number): { png: Uint8Array; peakDb: number } { const spec = new Spectrogram(SPEC_OPTS); spec.update(audio, audio.length); const { columns, peakDb } = spec.data; const px = spectrogramPixels(columns, peakDb, SPEC_OPTS, fs, duration, SPEC_W, SPEC_H, peakRef); return { png: encodePng(px, SPEC_W, SPEC_H), peakDb }; } interface ClipRecord { id: string; label: string; overrides: Record; metrics: ClipMetrics; gainDb: number; peakDbfs: number; run: { steps: number; seconds: number; nx: number; ny: number; dt: number; maxContactForce: number }; } function writeClip(dir: string, id: string, label: string, ov: Record, r: RunResult, gainDb: number, peakRef?: number): ClipRecord { const metrics = clipMetrics(r.audio, r.fs); writeFileSync(join(dir, `${id}.wav`), encodeWavBytes(r.audio, r.fs, "absolute", gainDb)); writeFileSync(join(dir, `${id}.png`), spectrogramPng(r.audio, r.fs, r.params.duration, peakRef).png); const rec: ClipRecord = { id, label, overrides: ov, metrics, gainDb, peakDbfs: peakDbfs(r.audio, 10 ** (gainDb / 20)), run: { steps: r.steps, seconds: r.seconds, nx: r.grid.nx, ny: r.grid.ny, dt: r.dt, maxContactForce: r.maxContactForce }, }; writeFileSync(join(dir, `${id}.json`), JSON.stringify(rec, null, 2)); return rec; } async function render(): Promise { const dir = opts.out ?? "."; mkdirSync(dir, { recursive: true }); const id = opts.name ?? "clip"; const gainDb = parseFloat(opts.gain ?? "20"); console.log(`Rendering ${id}: ${JSON.stringify(overrides)}`); const r = await runSimulation(overrides as Partial, console.log); console.log(" simulation finished"); const rec = writeClip(dir, id, id, overrides, r, gainDb); console.log(" files written"); 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)`); 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`); } // ---------- gallery ---------- interface ManifestClip { label: string; params: Record } interface ManifestSweep { id: string; title: string; text: string; base?: Record; param?: string; unit?: string; scale?: number; values?: (number | ManifestClip)[]; clips?: ManifestClip[]; gainDb?: number | "auto"; } interface Manifest { title: string; intro: string; gainDb?: number | "auto"; sweeps: ManifestSweep[] } function sweepClips(sw: ManifestSweep): ManifestClip[] { if (sw.clips) return sw.clips; const scale = sw.scale ?? 1; return (sw.values ?? []).map((v) => { if (typeof v === "number") return { label: `${+(v / scale).toPrecision(4)} ${sw.unit ?? ""}`.trim(), params: { [sw.param!]: v } }; return v; }); } async function gallery(): Promise { const manifestPath = positional[0]; if (!manifestPath) throw new Error("gallery needs a manifest path"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; const dir = opts.out ?? "docs/gallery"; const clipsDir = join(dir, "clips"); const rawDir = join(dir, "raw"); mkdirSync(clipsDir, { recursive: true }); mkdirSync(rawDir, { recursive: true }); const only = opts.only; if (opts["html-only"]) { writeHtml(manifest, dir); console.log("index.html regenerated from the existing sweep files"); return; } for (const sw of manifest.sweeps) { if (only && sw.id !== only) continue; console.log(`== ${sw.id}: ${sw.title}`); const clips = sweepClips(sw); const results: { clip: ManifestClip; id: string; ov: Record; r: RunResult }[] = []; for (let k = 0; k < clips.length; k++) { const clip = clips[k]; const id = `${sw.id}-${k}`; const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip const rawPath = join(rawDir, `${id}.f32`); const metaPath = join(rawDir, `${id}.meta.json`); let r: RunResult | null = null; if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) { const meta = JSON.parse(readFileSync(metaPath, "utf8")); if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) { const bytes = readFileSync(rawPath); r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() }; console.log(` ${id} (${clip.label}): cached`); } } if (!r) { console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`); r = await runSimulation(ov as Partial, console.log); writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength)); const { audio: _a, params: _p, ...run } = r; writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2)); console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`); } results.push({ clip, id, ov, r }); } // Playback gain: fixed (comparable across sweeps and with the app) or "auto" // (the loudest clip of the sweep peaks at +3 dBFS before the soft clipper). let gainDb: number; const gainSetting = sw.gainDb ?? manifest.gainDb ?? "auto"; if (gainSetting === "auto") { const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1))); gainDb = Math.round(3 - loudest); } else gainDb = gainSetting; 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; })); const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef)); writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2)); writeHtml(manifest, dir); console.log(` written (gain ${gainDb} dB)`); } writeHtml(manifest, dir); } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function fmtNum(v: number | null, digits = 2): string { if (v === null || !isFinite(v)) return "∞"; return v.toFixed(digits); } function writeHtml(manifest: Manifest, dir: string): void { const sections: string[] = []; for (const sw of manifest.sweeps) { const path = join(dir, `${sw.id}.json`); if (!existsSync(path)) continue; const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] }; const cards = data.clips.map((c) => { const q = new URLSearchParams(); for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v)); q.set("autostart", "1"); const m = c.metrics; const force = c.run.maxContactForce > 0 ? `
contact force ${fmtNum(c.run.maxContactForce, 0)} N
` : ""; return `
${esc(c.label)}
spectrogram
peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)
RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa
centroid ${fmtNum(m.centroidHz, 0)} Hz
T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s
${force}
wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}
Open in simulator
`; }); sections.push(`

${esc(sw.title)}

${esc(sw.text)}

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.

${cards.join("\n")}
`); } const toc = manifest.sweeps.filter((sw) => existsSync(join(dir, `${sw.id}.json`))).map((sw) => `
  • ${esc(sw.title)}
  • `).join(""); const html = ` ${esc(manifest.title)}

    ${esc(manifest.title)}

    ${esc(manifest.intro)}

      ${toc}
    ${sections.join("\n")}

    Generated by steelsim gallery; parameters not listed take the simulator defaults.

    `; writeFileSync(join(dir, "index.html"), html); } const commands: Record Promise> = { render, gallery }; if (!command || !(command in commands)) { console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]"); process.exit(2); } commands[command]().then( async () => { await shutdown(); process.exit(0); }, async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); }, );