CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixes
The prebuilt Dawn binary needs glibc 2.38 and crashes intermittently under
this workload, so the renderer drives the built app in headless Chromium
by default (same code path as the interactive app). Adapter fallback in
the app when a high-performance adapter is refused.
6 changed files+191−16
package-lock.jsonmodified+38−6View file
@@ -10,8 +10,11 @@
1010 "devDependencies": {
1111 "@types/node": "^26.5.0",
1212 "@webgpu/types": "^0.1.60",
13+ "playwright": "^1.63.0",
1314 "typescript": "^5.6.0",
14- "vite": "^6.0.0",
15+ "vite": "^6.0.0"
16+ },
17+ "optionalDependencies": {
1518 "webgpu": "^0.6.0"
1619 }
1720 },
@@ -887,15 +890,15 @@
887890 "version": "0.1.72",
888891 "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
889892 "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
890- "dev": true,
893+ "devOptional": true,
891894 "license": "BSD-3-Clause"
892895 },
893896 "node_modules/debug": {
894897 "version": "4.4.3",
895898 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
896899 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
897- "dev": true,
898900 "license": "MIT",
901+ "optional": true,
899902 "dependencies": {
900903 "ms": "^2.1.3"
901904 },
@@ -987,8 +990,8 @@
987990 "version": "2.1.3",
988991 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
989992 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
990- "dev": true,
991- "license": "MIT"
993+ "license": "MIT",
994+ "optional": true
992995 },
993996 "node_modules/nanoid": {
994997 "version": "3.3.18",
@@ -1029,6 +1032,35 @@
10291032 "url": "https://github.com/sponsors/jonschlinkert"
10301033 }
10311034 },
1035+ "node_modules/playwright": {
1036+ "version": "1.63.0",
1037+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
1038+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
1039+ "dev": true,
1040+ "license": "Apache-2.0",
1041+ "dependencies": {
1042+ "playwright-core": "1.63.0"
1043+ },
1044+ "bin": {
1045+ "playwright": "cli.js"
1046+ },
1047+ "engines": {
1048+ "node": ">=20"
1049+ }
1050+ },
1051+ "node_modules/playwright-core": {
1052+ "version": "1.63.0",
1053+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
1054+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
1055+ "dev": true,
1056+ "license": "Apache-2.0",
1057+ "bin": {
1058+ "playwright-core": "cli.js"
1059+ },
1060+ "engines": {
1061+ "node": ">=20"
1062+ }
1063+ },
10321064 "node_modules/postcss": {
10331065 "version": "8.5.28",
10341066 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -1231,9 +1263,9 @@
12311263 "version": "0.6.0",
12321264 "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.6.0.tgz",
12331265 "integrity": "sha512-z44ZU/+ypPELgQrv+um3/5Eqcq7gOtVZAmaRfwujJMwGMRSa6+CmjIAvEWw7O68Y95cnEsywhbYlcChQTX+IQg==",
1234- "dev": true,
12351266 "hasInstallScript": true,
12361267 "license": "MIT",
1268+ "optional": true,
12371269 "dependencies": {
12381270 "@webgpu/types": "^0.1.72",
12391271 "debug": "^4.4.0"
package.jsonmodified+4−1View file
@@ -15,8 +15,11 @@
1515 "devDependencies": {
1616 "@types/node": "^26.5.0",
1717 "@webgpu/types": "^0.1.60",
18+ "playwright": "^1.63.0",
1819 "typescript": "^5.6.0",
19- "vite": "^6.0.0",
20+ "vite": "^6.0.0"
21+ },
22+ "optionalDependencies": {
2023 "webgpu": "^0.6.0"
2124 }
2225 }
src/cli/chrome.tsadded+111−0View file
@@ -0,0 +1,111 @@
1+// Runs the simulation in a headless Chromium through Playwright: a small static
2+// server serves the built app (dist/), the page is opened with the parameters
3+// in the URL, and the audio is read back through the app's test hook.
4+
5+import { createServer, type Server } from "node:http";
6+import { existsSync, readFileSync, statSync } from "node:fs";
7+import { extname, join, normalize } from "node:path";
8+import { defaultParams, type PhysParams } from "../params.ts";
9+import type { RunResult } from "./run.ts";
10+
11+const MIME: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".json": "application/json" };
12+
13+function serveStatic(root: string): Promise<{ server: Server; port: number }> {
14+ const server = createServer((req, res) => {
15+ const url = new URL(req.url ?? "/", "http://localhost");
16+ let path = normalize(decodeURIComponent(url.pathname));
17+ if (path.endsWith("/")) path += "index.html";
18+ const file = join(root, path);
19+ if (!file.startsWith(root) || !existsSync(file) || !statSync(file).isFile()) {
20+ res.writeHead(404);
21+ res.end();
22+ return;
23+ }
24+ res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
25+ res.end(readFileSync(file));
26+ });
27+ return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, port: (server.address() as { port: number }).port })));
28+}
29+
30+type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
31+type Page = {
32+ goto(url: string): Promise<unknown>;
33+ evaluate<T>(fn: string | ((...a: never[]) => T)): Promise<T>;
34+ waitForTimeout(ms: number): Promise<void>;
35+ on(event: string, cb: (arg: { message: string }) => void): void;
36+ close(): Promise<void>;
37+};
38+
39+let browser: Browser | null = null;
40+let served: { server: Server; port: number } | null = null;
41+
42+async function getBrowser(chromePath: string | undefined): Promise<Browser> {
43+ if (browser) return browser;
44+ // Playwright is imported lazily so that the Dawn engine does not need it.
45+ const mod = (await import("playwright")) as unknown as { chromium: { launch(o: object): Promise<Browser> } };
46+ browser = await mod.chromium.launch({
47+ headless: true,
48+ executablePath: chromePath || undefined,
49+ args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"],
50+ });
51+ return browser;
52+}
53+
54+export interface ChromeOptions {
55+ /** Path of the Chrome or Chromium executable; Playwright's bundled Chromium when omitted. */
56+ chromePath?: string;
57+ /** Directory holding the built app (index.html); default dist/. */
58+ distDir?: string;
59+}
60+
61+export async function runSimulationChrome(overrides: Partial<PhysParams>, log: (s: string) => void, options: ChromeOptions = {}): Promise<RunResult> {
62+ const dist = options.distDir ?? join(process.cwd(), "dist");
63+ if (!existsSync(join(dist, "index.html"))) throw new Error(`Built app not found at ${dist}; run "npm run build" first`);
64+ if (!served) served = await serveStatic(dist);
65+ const b = await getBrowser(options.chromePath);
66+ const page = await b.newPage();
67+ page.on("pageerror", (e) => log(` page error: ${e.message}`));
68+ const q = new URLSearchParams();
69+ for (const [k, v] of Object.entries(overrides)) q.set(k, String(v));
70+ q.set("autostart", "1");
71+ await page.goto(`http://127.0.0.1:${served.port}/?${q.toString()}`);
72+ const t0 = performance.now();
73+ let lastLog = t0;
74+ type Status = { phase: string; t: number; stepsPerSec: number; dt: number; nx: number; ny: number; totalSteps: number; stick: { maxForce: number } | null } | null;
75+ let st: Status = null;
76+ let maxContactForce = 0;
77+ for (;;) {
78+ await page.waitForTimeout(500);
79+ const noGpu = await page.evaluate<string | null>("(() => { const e = document.getElementById('nogpu'); return e && !e.hidden ? e.textContent : null; })()");
80+ if (noGpu) throw new Error(`Chrome has no WebGPU: ${noGpu}`);
81+ st = await page.evaluate<Status>("window.steelsim ? window.steelsim.status() : null");
82+ if (st?.stick) maxContactForce = Math.max(maxContactForce, st.stick.maxForce);
83+ if (st && st.phase === "done") break;
84+ const now = performance.now();
85+ if (now - lastLog > 5000 && st) {
86+ lastLog = now;
87+ log(` ${st.phase} t=${st.t.toFixed(2)} s, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s`);
88+ }
89+ if (now - t0 > 3600e3) throw new Error("simulation timed out");
90+ }
91+ const audio = new Float32Array(await page.evaluate<number[]>("window.steelsim.audio()"));
92+ const seconds = (performance.now() - t0) / 1000;
93+ await page.close();
94+ return {
95+ params: { ...defaultParams, ...overrides },
96+ audio,
97+ fs: ({ ...defaultParams, ...overrides }).fs,
98+ steps: st!.totalSteps,
99+ seconds,
100+ grid: { nx: st!.nx, ny: st!.ny },
101+ dt: st!.dt,
102+ maxContactForce,
103+ };
104+}
105+
106+export async function closeChrome(): Promise<void> {
107+ await browser?.close();
108+ browser = null;
109+ served?.server.close();
110+ served = null;
111+}
src/cli/main.tsmodified+29−5View file
@@ -1,13 +1,16 @@
11 // Command-line renderer.
22 //
33 // steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...
4-// steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP_ID] [--force]
4+// steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP_ID] [--force] [key=value ...]
55 //
6+// Common options: --engine chrome|dawn (default chrome), --chrome PATH (Chrome
7+// executable; Playwright's Chromium when omitted), --dist DIR (built app).
68 // Parameter keys are the fields of PhysParams in SI units.
79
810 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
911 import { join } from "node:path";
10-import { runSimulation, type RunResult } from "./run.ts";
12+import type { RunResult } from "./run.ts";
13+import type { PhysParams as _PP } from "../params.ts";
1114 import { encodeWavBytes } from "../audio/wav.ts";
1215 import { Spectrogram, spectrogramPixels } from "../audio/spectrogram.ts";
1316 import { encodePng } from "./png.ts";
@@ -25,6 +28,7 @@ for (let i = 0; i < args.length; i++) {
2528 if (a.startsWith("--")) {
2629 const key = a.slice(2);
2730 if (key === "force") opts[key] = "1";
31+ else if (key === "chrome" && (i + 1 >= args.length || args[i + 1].startsWith("--"))) opts[key] = "";
2832 else opts[key] = args[++i];
2933 } else if (a.includes("=")) {
3034 const [k, v] = a.split("=");
@@ -38,6 +42,21 @@ function parseValue(key: string, v: string): unknown {
3842 return v;
3943 }
4044
45+/** Runs one simulation with the selected engine. */
46+async 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+}
54+
55+async function shutdown(): Promise<void> {
56+ const { closeChrome } = await import("./chrome.ts");
57+ await closeChrome();
58+}
59+
4160 const SPEC_OPTS = { windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false };
4261 const SPEC_W = 640, SPEC_H = 200;
4362
@@ -79,7 +98,9 @@ async function render(): Promise<void> {
7998 const gainDb = parseFloat(opts.gain ?? "20");
8099 console.log(`Rendering ${id}: ${JSON.stringify(overrides)}`);
81100 const r = await runSimulation(overrides as Partial<PhysParams>, console.log);
101+ console.log(" simulation finished");
82102 const rec = writeClip(dir, id, id, overrides, r, gainDb);
103+ console.log(" files written");
83104 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)`);
84105 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`);
85106 }
@@ -169,8 +190,8 @@ function esc(s: string): string {
169190 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
170191 }
171192
172-function fmtNum(v: number, digits = 2): string {
173- if (!isFinite(v)) return "∞";
193+function fmtNum(v: number | null, digits = 2): string {
194+ if (v === null || !isFinite(v)) return "∞";
174195 return v.toFixed(digits);
175196 }
176197
@@ -237,4 +258,7 @@ if (!command || !(command in commands)) {
237258 console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
238259 process.exit(2);
239260 }
240-commands[command]().then(() => process.exit(0), (e) => { console.error(e); process.exit(1); });
261+commands[command]().then(
262+ async () => { await shutdown(); process.exit(0); },
263+ async (e) => { console.error(e); await shutdown().catch(() => {}); process.exit(1); },
264+);
src/cli/run.tsmodified+8−3View file
@@ -11,9 +11,13 @@ export function getDevice(): Promise<GPUDevice> {
1111 Object.assign(globalThis, globals);
1212 devicePromise = (async () => {
1313 const gpu = create([]);
14- const adapter = await gpu.requestAdapter({ powerPreference: "high-performance" });
14+ const adapter = await gpu.requestAdapter();
1515 if (!adapter) throw new Error("No WebGPU adapter");
16- return adapter.requestDevice();
16+ const device = await adapter.requestDevice();
17+ device.addEventListener("uncapturederror", (ev) => {
18+ console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message);
19+ });
20+ return device;
1721 })();
1822 }
1923 return devicePromise;
@@ -47,6 +51,7 @@ export async function runSimulation(overrides: Partial<PhysParams>, log: (s: str
4751 log(` ${st.phase} t=${st.t.toFixed(2)} s, ${(sim.totalSteps / ((now - t0) / 1000) / 1e3).toFixed(0)} k steps/s`);
4852 }
4953 }
54+ log(" loop finished");
5055 const seconds = (performance.now() - t0) / 1000;
5156 const result: RunResult = {
5257 params,
@@ -58,6 +63,6 @@ export async function runSimulation(overrides: Partial<PhysParams>, log: (s: str
5863 dt: sim.dt,
5964 maxContactForce,
6065 };
61- sim.destroy();
66+ // Buffers are left to the garbage collector: explicit destroy() aborts inside the Dawn bindings.
6267 return result;
6368 }
src/main.tsmodified+1−1View file
@@ -634,7 +634,7 @@ async function init(): Promise<void> {
634634 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge (or Firefox / Safari with WebGPU enabled).");
635635 return;
636636 }
637- const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
637+ const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
638638 if (!adapter) {
639639 showNoGpu("No WebGPU adapter was found.");
640640 return;