/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
Command-line renderer (Node WebGPU via Dawn) and gallery generator
steelsim render writes WAV, spectrogram PNG, and a JSON summary for one parameter set; steelsim gallery renders a manifest of parameter sweeps into docs/gallery/index.html with audio players, spectrograms, and metrics. Built with Vite in SSR mode so the WGSL imports work in Node.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit c8442c511cad parent 38f6b3e Browse files
12 changed files+822−32
.gitignoremodified+2−0View file
@@ -1,2 +1,4 @@
11 node_modules/
22 dist/
3+dist-cli/
4+docs/gallery/raw/
docs/gallery/manifest.jsonadded+290−0View file
@@ -0,0 +1,290 @@
1+{
2+ "title": "Steel sheet gong: how the parameters shape the sound",
3+ "intro": "Each section varies one property of the simulated sheet or of the excitation while everything else stays at the simulator defaults: a 12 by 18 inch, 22 gauge (0.79 mm) stainless steel sheet with a 3 mm bow across its width, heard 1 m in front of its center, recorded for 4 s. Click a player to hear a clip. Within a section the clips share one playback gain, so the loudness differences you hear are the ones the model predicts; between sections the gain differs. The numbers under each clip are the peak pressure and sound pressure level at the listener, the RMS pressure over the clip, the spectral centroid of the sound between 0.2 and 1.2 s (a measure of brightness), and fitted 60 dB decay times of a low and a high band. 'Open in simulator' runs the same case interactively.",
4+ "sweeps": [
5+ {
6+ "id": "strike-speed",
7+ "title": "Drumstick: stick speed",
8+ "text": "A wooden stick tip (30 g effective mass) strikes the sheet at 70 percent of its width and 35 percent of its height. Speed sets both the loudness and the character: the contact force grows with speed, the contact shortens, and more energy reaches the high modes. The sound grows brighter as well as louder.",
9+ "base": {
10+ "excitation": "strike"
11+ },
12+ "param": "strikeVelocity",
13+ "unit": "m/s",
14+ "values": [
15+ 0.5,
16+ 1,
17+ 2,
18+ 3,
19+ 4.5,
20+ 6
21+ ]
22+ },
23+ {
24+ "id": "strike-mass",
25+ "title": "Drumstick: stick mass",
26+ "text": "The same speed with a lighter or heavier stick. A heavier stick carries more momentum, stays in contact longer, and pushes the sheet further, which favours the low modes; a light stick gives a short, bright tap.",
27+ "base": {
28+ "excitation": "strike"
29+ },
30+ "param": "stickMass",
31+ "unit": "g",
32+ "scale": 0.001,
33+ "values": [
34+ 0.01,
35+ 0.03,
36+ 0.06,
37+ 0.12
38+ ]
39+ },
40+ {
41+ "id": "strike-stiffness",
42+ "title": "Drumstick: tip hardness",
43+ "text": "The Hertz contact stiffness K stands for the hardness of the tip: a soft (rubber or felt) tip has a low K and a long, gentle contact that excites mainly the low modes; a hard tip (dense wood, or a metal beater) has a high K and a sharp click.",
44+ "base": {
45+ "excitation": "strike"
46+ },
47+ "param": "contactStiffness",
48+ "unit": "N/m^1.5",
49+ "values": [
50+ 5000000.0,
51+ 50000000.0,
52+ 500000000.0,
53+ 5000000000.0
54+ ]
55+ },
56+ {
57+ "id": "strike-position",
58+ "title": "Drumstick: strike position",
59+ "text": "Where the sheet is struck decides which modes are excited: a strike at the center excites only modes that are symmetric about both axes, while a strike near an edge or a corner excites the twisting and edge modes as well.",
60+ "base": {
61+ "excitation": "strike"
62+ },
63+ "clips": [
64+ {
65+ "label": "center",
66+ "params": {
67+ "strikeFracX": 0.5,
68+ "strikeFracY": 0.5
69+ }
70+ },
71+ {
72+ "label": "off-center (default)",
73+ "params": {
74+ "strikeFracX": 0.7,
75+ "strikeFracY": 0.35
76+ }
77+ },
78+ {
79+ "label": "near the right edge",
80+ "params": {
81+ "strikeFracX": 0.95,
82+ "strikeFracY": 0.5
83+ }
84+ },
85+ {
86+ "label": "near a corner",
87+ "params": {
88+ "strikeFracX": 0.92,
89+ "strikeFracY": 0.08
90+ }
91+ }
92+ ]
93+ },
94+ {
95+ "id": "push-amount",
96+ "title": "Push and release: push amount",
97+ "text": "The corner is pushed by a given amount, held until the sheet settles, and released over 20 ms. A larger push stores more energy and, because the stiffness rises with deflection, produces a stronger pitch glide and a stronger transfer of energy to the higher partials.",
98+ "param": "pushAmount",
99+ "unit": "mm",
100+ "scale": 0.001,
101+ "values": [
102+ 0.002,
103+ 0.005,
104+ 0.01,
105+ 0.02
106+ ]
107+ },
108+ {
109+ "id": "push-sign",
110+ "title": "Push and release: with or against the bow",
111+ "text": "The sheet has a 3 mm bow across its width. Pushing the corner in the direction of the bow and against it are not equivalent: the shell stiffness is asymmetric, so the two cases have different low-mode frequencies and a different wobble.",
112+ "clips": [
113+ {
114+ "label": "+10 mm (with the bow)",
115+ "params": {
116+ "pushAmount": 0.01
117+ }
118+ },
119+ {
120+ "label": "-10 mm (against the bow)",
121+ "params": {
122+ "pushAmount": -0.01
123+ }
124+ }
125+ ]
126+ },
127+ {
128+ "id": "push-release",
129+ "title": "Push and release: release time",
130+ "text": "How fast the finger lets go. An instantaneous release is a step in force and excites every mode, giving a click; a release over tens of milliseconds excites only the lowest modes, as a fingertip does.",
131+ "param": "releaseTime",
132+ "unit": "ms",
133+ "scale": 0.001,
134+ "values": [
135+ 0,
136+ 0.005,
137+ 0.02,
138+ 0.05
139+ ]
140+ },
141+ {
142+ "id": "bow-amplitude",
143+ "title": "Sheet: initial bow",
144+ "text": "Real sheet metal has a bow of a few millimetres, which makes it a shallow shell with an asymmetric stiffness. A flat plate has a symmetric, purely hardening nonlinearity; the bowed sheet moves energy out of the inaudible sub-50 Hz range into the audible partials and produces the slow wobble of a real sheet.",
145+ "param": "bowAmplitude",
146+ "unit": "mm",
147+ "scale": 0.001,
148+ "values": [
149+ 0,
150+ 0.0015,
151+ 0.003,
152+ 0.006
153+ ]
154+ },
155+ {
156+ "id": "bow-shape",
157+ "title": "Sheet: shape of the bow",
158+ "text": "The same 3 mm sag as a bow across the width, across the height, or as a dish curved both ways.",
159+ "clips": [
160+ {
161+ "label": "across the width",
162+ "params": {
163+ "bowShape": "width"
164+ }
165+ },
166+ {
167+ "label": "across the height",
168+ "params": {
169+ "bowShape": "height"
170+ }
171+ },
172+ {
173+ "label": "dish",
174+ "params": {
175+ "bowShape": "dish"
176+ }
177+ }
178+ ]
179+ },
180+ {
181+ "id": "thickness",
182+ "title": "Sheet: thickness",
183+ "text": "Bending stiffness grows with the cube of the thickness while mass grows linearly, so a thicker sheet has higher mode frequencies, and the same push is a smaller multiple of the thickness, which weakens the nonlinear effects. Gauges 24, 22, 18 and 14.",
184+ "param": "h",
185+ "unit": "mm",
186+ "scale": 0.001,
187+ "values": [
188+ 0.0006,
189+ 0.00079,
190+ 0.0012,
191+ 0.002
192+ ]
193+ },
194+ {
195+ "id": "size",
196+ "title": "Sheet: size",
197+ "text": "Plate dimensions at the same thickness. Mode frequencies scale with the inverse square of the size, so a larger sheet is lower and slower; it also radiates more efficiently.",
198+ "clips": [
199+ {
200+ "label": "8 x 12 in",
201+ "params": {
202+ "Lx": 0.3048,
203+ "Ly": 0.2032
204+ }
205+ },
206+ {
207+ "label": "12 x 18 in (default)",
208+ "params": {}
209+ },
210+ {
211+ "label": "18 x 24 in",
212+ "params": {
213+ "Lx": 0.6096,
214+ "Ly": 0.4572
215+ }
216+ },
217+ {
218+ "label": "24 x 36 in",
219+ "params": {
220+ "Lx": 0.9144,
221+ "Ly": 0.6096
222+ }
223+ }
224+ ]
225+ },
226+ {
227+ "id": "material",
228+ "title": "Sheet: material",
229+ "text": "Stainless steel, aluminium, and brass at the same thickness. The mode frequencies scale with the square root of E over rho, which differs little between these metals; the loss parameters are kept fixed, although real materials differ there too.",
230+ "clips": [
231+ {
232+ "label": "stainless steel",
233+ "params": {}
234+ },
235+ {
236+ "label": "aluminium",
237+ "params": {
238+ "E": 69000000000.0,
239+ "nu": 0.33,
240+ "rho": 2700
241+ }
242+ },
243+ {
244+ "label": "brass",
245+ "params": {
246+ "E": 100000000000.0,
247+ "nu": 0.34,
248+ "rho": 8500
249+ }
250+ }
251+ ]
252+ },
253+ {
254+ "id": "damping-sigma0",
255+ "title": "Damping: frequency-independent loss",
256+ "text": "sigma0 sets the decay of the lowest modes (T60 = 6.9 / sigma0). It stands in for everything that damps the sheet uniformly: the suspension, air drag, the material.",
257+ "param": "sigma0",
258+ "unit": "1/s",
259+ "values": [
260+ 0.4,
261+ 1.15,
262+ 3.5
263+ ]
264+ },
265+ {
266+ "id": "damping-sigma1",
267+ "title": "Damping: frequency-dependent loss",
268+ "text": "sigma1 makes the high modes decay faster, in proportion to the square of their wavenumber; it stands mainly for radiation into the air. Too little and the highs ring like a cymbal; too much and the sheet sounds dull.",
269+ "param": "sigma1",
270+ "unit": "m^2/s",
271+ "values": [
272+ 0.0001,
273+ 0.0005,
274+ 0.0025
275+ ]
276+ },
277+ {
278+ "id": "damping-inplane",
279+ "title": "Damping: in-plane loss",
280+ "text": "The stretching modes of the sheet lie at several kilohertz and, once the sheet is curved, couple to transverse motion. Without their own loss they ring for the whole low-frequency decay.",
281+ "param": "sigmaIn",
282+ "unit": "1/s",
283+ "values": [
284+ 0,
285+ 20,
286+ 100
287+ ]
288+ }
289+ ]
290+}
\ No newline at end of file
package-lock.jsonmodified+57−1View file
@@ -8,9 +8,11 @@
88 "name": "steelsim21",
99 "version": "0.1.0",
1010 "devDependencies": {
11+ "@types/node": "^26.5.0",
1112 "@webgpu/types": "^0.1.60",
1213 "typescript": "^5.6.0",
13- "vite": "^6.0.0"
14+ "vite": "^6.0.0",
15+ "webgpu": "^0.6.0"
1416 }
1517 },
1618 "node_modules/@esbuild/aix-ppc64": {
@@ -871,6 +873,16 @@
871873 "dev": true,
872874 "license": "MIT"
873875 },
876+ "node_modules/@types/node": {
877+ "version": "26.5.0",
878+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz",
879+ "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
880+ "dev": true,
881+ "license": "MIT",
882+ "dependencies": {
883+ "undici-types": "~8.9.0"
884+ }
885+ },
874886 "node_modules/@webgpu/types": {
875887 "version": "0.1.72",
876888 "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
@@ -878,6 +890,24 @@
878890 "dev": true,
879891 "license": "BSD-3-Clause"
880892 },
893+ "node_modules/debug": {
894+ "version": "4.4.3",
895+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
896+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
897+ "dev": true,
898+ "license": "MIT",
899+ "dependencies": {
900+ "ms": "^2.1.3"
901+ },
902+ "engines": {
903+ "node": ">=6.0"
904+ },
905+ "peerDependenciesMeta": {
906+ "supports-color": {
907+ "optional": true
908+ }
909+ }
910+ },
881911 "node_modules/esbuild": {
882912 "version": "0.25.12",
883913 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -953,6 +983,13 @@
953983 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
954984 }
955985 },
986+ "node_modules/ms": {
987+ "version": "2.1.3",
988+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
989+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
990+ "dev": true,
991+ "license": "MIT"
992+ },
956993 "node_modules/nanoid": {
957994 "version": "3.3.18",
958995 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -1108,6 +1145,13 @@
11081145 "node": ">=14.17"
11091146 }
11101147 },
1148+ "node_modules/undici-types": {
1149+ "version": "8.9.0",
1150+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
1151+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
1152+ "dev": true,
1153+ "license": "MIT"
1154+ },
11111155 "node_modules/vite": {
11121156 "version": "6.4.3",
11131157 "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -1182,6 +1226,18 @@
11821226 "optional": true
11831227 }
11841228 }
1229+ },
1230+ "node_modules/webgpu": {
1231+ "version": "0.6.0",
1232+ "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.6.0.tgz",
1233+ "integrity": "sha512-z44ZU/+ypPELgQrv+um3/5Eqcq7gOtVZAmaRfwujJMwGMRSa6+CmjIAvEWw7O68Y95cnEsywhbYlcChQTX+IQg==",
1234+ "dev": true,
1235+ "hasInstallScript": true,
1236+ "license": "MIT",
1237+ "dependencies": {
1238+ "@webgpu/types": "^0.1.72",
1239+ "debug": "^4.4.0"
1240+ }
11851241 }
11861242 }
11871243 }
package.jsonmodified+6−2View file
@@ -8,11 +8,15 @@
88 "dev": "vite",
99 "build": "tsc --noEmit && vite build",
1010 "preview": "vite preview",
11- "test": "node --test tests/*.test.ts"
11+ "test": "node --test tests/*.test.ts",
12+ "build:cli": "vite build --config vite.cli.config.ts",
13+ "steelsim": "node dist-cli/steelsim.js"
1214 },
1315 "devDependencies": {
16+ "@types/node": "^26.5.0",
1417 "@webgpu/types": "^0.1.60",
1518 "typescript": "^5.6.0",
16- "vite": "^6.0.0"
19+ "vite": "^6.0.0",
20+ "webgpu": "^0.6.0"
1721 }
1822 }
src/audio/spectrogram.tsmodified+38−26View file
@@ -131,36 +131,16 @@ export class Spectrogram {
131131 }
132132
133133 private renderImage(ctx: CanvasRenderingContext2D, pw: number, ph: number, fs: number, duration: number): ImageData {
134- const { windowSize, hop, fmax, dbFloor, logFrequency } = this.opts;
135- const binHz = fs / windowSize;
136- const fmin = logFrequency ? 20 : 0;
137134 const img = ctx.createImageData(pw, ph);
138- const data = img.data;
139- const cols = this.columns.length;
140- const colPerPx = (duration * fs) / hop / pw;
141- const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
142- for (let py = 0; py < ph; py++) {
143- const frac = 1 - py / ph;
144- const f = logFrequency ? Math.exp(logMin + frac * (logMax - logMin)) : fmin + frac * (fmax - fmin);
145- const bin = Math.min(windowSize / 2 - 1, Math.max(0, Math.round(f / binHz)));
146- for (let px = 0; px < pw; px++) {
147- const c0 = Math.floor(px * colPerPx);
148- const c1 = Math.max(c0 + 1, Math.floor((px + 1) * colPerPx));
149- let v = -Infinity;
150- for (let c = c0; c < c1 && c < cols; c++) v = Math.max(v, this.columns[c][bin]);
151- const o = (py * pw + px) * 4;
152- if (c0 >= cols) {
153- data[o] = 28; data[o + 1] = 30; data[o + 2] = 34; data[o + 3] = 255;
154- continue;
155- }
156- const t = this.peakDb > -200 ? Math.max(0, Math.min(1, (v - this.peakDb + dbFloor) / dbFloor)) : 0;
157- const [r, g, b] = inferno(t);
158- data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
159- }
160- }
135+ img.data.set(spectrogramPixels(this.columns, this.peakDb, this.opts, fs, duration, pw, ph));
161136 return img;
162137 }
163138
139+ /** STFT columns (dB) and their peak, for renderers outside the browser. */
140+ get data(): { columns: Float32Array[]; peakDb: number } {
141+ return { columns: this.columns, peakDb: this.peakDb };
142+ }
143+
164144 private drawAxes(ctx: CanvasRenderingContext2D, dpr: number, left: number, top: number, pw: number, ph: number, fmin: number, fmax: number, logMin: number, logMax: number, logFrequency: boolean, duration: number): void {
165145 ctx.fillStyle = "#c8cbd0";
166146 ctx.font = `${11 * dpr}px system-ui, sans-serif`;
@@ -189,6 +169,38 @@ export class Spectrogram {
189169 }
190170 }
191171
172+/** RGBA pixels (row-major, top row first) of the spectrogram plot area. */
173+export function spectrogramPixels(columns: Float32Array[], peakDb: number, opts: SpectrogramOptions, fs: number, duration: number, pw: number, ph: number, peakRef?: number): Uint8ClampedArray {
174+ const { windowSize, hop, fmax, dbFloor, logFrequency } = opts;
175+ const binHz = fs / windowSize;
176+ const fmin = logFrequency ? 20 : 0;
177+ const data = new Uint8ClampedArray(pw * ph * 4);
178+ const cols = columns.length;
179+ const colPerPx = (duration * fs) / hop / pw;
180+ const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
181+ const ref = peakRef ?? peakDb;
182+ for (let py = 0; py < ph; py++) {
183+ const frac = 1 - py / ph;
184+ const f = logFrequency ? Math.exp(logMin + frac * (logMax - logMin)) : fmin + frac * (fmax - fmin);
185+ const bin = Math.min(windowSize / 2 - 1, Math.max(0, Math.round(f / binHz)));
186+ for (let px = 0; px < pw; px++) {
187+ const c0 = Math.floor(px * colPerPx);
188+ const c1 = Math.max(c0 + 1, Math.floor((px + 1) * colPerPx));
189+ let v = -Infinity;
190+ for (let c = c0; c < c1 && c < cols; c++) v = Math.max(v, columns[c][bin]);
191+ const o = (py * pw + px) * 4;
192+ if (c0 >= cols) {
193+ data[o] = 28; data[o + 1] = 30; data[o + 2] = 34; data[o + 3] = 255;
194+ continue;
195+ }
196+ const t = ref > -200 ? Math.max(0, Math.min(1, (v - ref + dbFloor) / dbFloor)) : 0;
197+ const [r, g, b] = inferno(t);
198+ data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
199+ }
200+ }
201+ return data;
202+}
203+
192204 function niceTicks(lo: number, hi: number, count: number): number[] {
193205 const span = hi - lo;
194206 if (span <= 0) return [lo];
src/audio/wav.tsmodified+7−2View file
@@ -2,7 +2,8 @@
22
33 import { normalize, type LevelMode } from "./level.ts";
44
5-export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Blob {
5+/** Encodes the scaled signal as WAV bytes. */
6+export function encodeWavBytes(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Uint8Array<ArrayBuffer> {
67 const scaled = normalize(samples, fs, mode, gainDb, 0.99);
78 const buf = new ArrayBuffer(44 + samples.length * 2);
89 const v = new DataView(buf);
@@ -24,5 +25,9 @@ export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "
2425 const s = Math.max(-1, Math.min(1, scaled[k]));
2526 v.setInt16(44 + 2 * k, Math.round(s * 32767), true);
2627 }
27- return new Blob([buf], { type: "audio/wav" });
28+ return new Uint8Array(buf);
29+}
30+
31+export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Blob {
32+ return new Blob([encodeWavBytes(samples, fs, mode, gainDb)], { type: "audio/wav" });
2833 }
src/cli/main.tsadded+240−0View file
@@ -0,0 +1,240 @@
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]
5+//
6+// Parameter keys are the fields of PhysParams in SI units.
7+
8+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9+import { join } from "node:path";
10+import { runSimulation, type RunResult } from "./run.ts";
11+import { encodeWavBytes } from "../audio/wav.ts";
12+import { Spectrogram, spectrogramPixels } from "../audio/spectrogram.ts";
13+import { encodePng } from "./png.ts";
14+import { clipMetrics, type ClipMetrics } from "./metrics.ts";
15+import { defaultParams, type PhysParams } from "../params.ts";
16+import { peakDbfs } from "../audio/level.ts";
17+
18+const args = process.argv.slice(2);
19+const command = args.shift();
20+const opts: Record<string, string> = {};
21+const positional: string[] = [];
22+const overrides: Record<string, unknown> = {};
23+for (let i = 0; i < args.length; i++) {
24+ const a = args[i];
25+ if (a.startsWith("--")) {
26+ const key = a.slice(2);
27+ if (key === "force") opts[key] = "1";
28+ else opts[key] = args[++i];
29+ } else if (a.includes("=")) {
30+ const [k, v] = a.split("=");
31+ overrides[k] = parseValue(k, v);
32+ } else positional.push(a);
33+}
34+
35+function parseValue(key: string, v: string): unknown {
36+ const d = (defaultParams as unknown as Record<string, unknown>)[key];
37+ if (typeof d === "number") return parseFloat(v);
38+ return v;
39+}
40+
41+const SPEC_OPTS = { windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false };
42+const SPEC_W = 640, SPEC_H = 200;
43+
44+function spectrogramPng(audio: Float32Array, fs: number, duration: number, peakRef?: number): { png: Uint8Array; peakDb: number } {
45+ const spec = new Spectrogram(SPEC_OPTS);
46+ spec.update(audio, audio.length);
47+ const { columns, peakDb } = spec.data;
48+ const px = spectrogramPixels(columns, peakDb, SPEC_OPTS, fs, duration, SPEC_W, SPEC_H, peakRef);
49+ return { png: encodePng(px, SPEC_W, SPEC_H), peakDb };
50+}
51+
52+interface ClipRecord {
53+ id: string;
54+ label: string;
55+ overrides: Record<string, unknown>;
56+ metrics: ClipMetrics;
57+ gainDb: number;
58+ peakDbfs: number;
59+ run: { steps: number; seconds: number; nx: number; ny: number; dt: number; maxContactForce: number };
60+}
61+
62+function writeClip(dir: string, id: string, label: string, ov: Record<string, unknown>, r: RunResult, gainDb: number, peakRef?: number): ClipRecord {
63+ const metrics = clipMetrics(r.audio, r.fs);
64+ writeFileSync(join(dir, `${id}.wav`), encodeWavBytes(r.audio, r.fs, "absolute", gainDb));
65+ writeFileSync(join(dir, `${id}.png`), spectrogramPng(r.audio, r.fs, r.params.duration, peakRef).png);
66+ const rec: ClipRecord = {
67+ id, label, overrides: ov, metrics, gainDb,
68+ peakDbfs: peakDbfs(r.audio, 10 ** (gainDb / 20)),
69+ run: { steps: r.steps, seconds: r.seconds, nx: r.grid.nx, ny: r.grid.ny, dt: r.dt, maxContactForce: r.maxContactForce },
70+ };
71+ writeFileSync(join(dir, `${id}.json`), JSON.stringify(rec, null, 2));
72+ return rec;
73+}
74+
75+async function render(): Promise<void> {
76+ const dir = opts.out ?? ".";
77+ mkdirSync(dir, { recursive: true });
78+ const id = opts.name ?? "clip";
79+ const gainDb = parseFloat(opts.gain ?? "20");
80+ console.log(`Rendering ${id}: ${JSON.stringify(overrides)}`);
81+ const r = await runSimulation(overrides as Partial<PhysParams>, console.log);
82+ const rec = writeClip(dir, id, id, overrides, r, gainDb);
83+ 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)`);
84+ 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`);
85+}
86+
87+// ---------- gallery ----------
88+
89+interface ManifestClip { label: string; params: Record<string, unknown> }
90+interface ManifestSweep {
91+ id: string;
92+ title: string;
93+ text: string;
94+ base?: Record<string, unknown>;
95+ param?: string;
96+ unit?: string;
97+ scale?: number;
98+ values?: (number | ManifestClip)[];
99+ clips?: ManifestClip[];
100+ gainDb?: number | "auto";
101+}
102+interface Manifest { title: string; intro: string; sweeps: ManifestSweep[] }
103+
104+function sweepClips(sw: ManifestSweep): ManifestClip[] {
105+ if (sw.clips) return sw.clips;
106+ const scale = sw.scale ?? 1;
107+ return (sw.values ?? []).map((v) => {
108+ if (typeof v === "number") return { label: `${+(v / scale).toPrecision(4)} ${sw.unit ?? ""}`.trim(), params: { [sw.param!]: v } };
109+ return v;
110+ });
111+}
112+
113+async function gallery(): Promise<void> {
114+ const manifestPath = positional[0];
115+ if (!manifestPath) throw new Error("gallery needs a manifest path");
116+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
117+ const dir = opts.out ?? "docs/gallery";
118+ const clipsDir = join(dir, "clips");
119+ const rawDir = join(dir, "raw");
120+ mkdirSync(clipsDir, { recursive: true });
121+ mkdirSync(rawDir, { recursive: true });
122+ const only = opts.only;
123+ for (const sw of manifest.sweeps) {
124+ if (only && sw.id !== only) continue;
125+ console.log(`== ${sw.id}: ${sw.title}`);
126+ const clips = sweepClips(sw);
127+ const results: { clip: ManifestClip; id: string; ov: Record<string, unknown>; r: RunResult }[] = [];
128+ for (let k = 0; k < clips.length; k++) {
129+ const clip = clips[k];
130+ const id = `${sw.id}-${k}`;
131+ const ov = { ...(sw.base ?? {}), ...clip.params, ...overrides }; // command-line key=value applies to every clip
132+ const rawPath = join(rawDir, `${id}.f32`);
133+ const metaPath = join(rawDir, `${id}.meta.json`);
134+ let r: RunResult | null = null;
135+ if (!opts.force && existsSync(rawPath) && existsSync(metaPath)) {
136+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
137+ if (JSON.stringify(meta.overrides) === JSON.stringify(ov)) {
138+ const bytes = readFileSync(rawPath);
139+ r = { ...meta.run, params: { ...defaultParams, ...ov } as PhysParams, audio: new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4).slice() };
140+ console.log(` ${id} (${clip.label}): cached`);
141+ }
142+ }
143+ if (!r) {
144+ console.log(` ${id} (${clip.label}): ${JSON.stringify(ov)}`);
145+ r = await runSimulation(ov as Partial<PhysParams>, console.log);
146+ writeFileSync(rawPath, Buffer.from(r.audio.buffer, r.audio.byteOffset, r.audio.byteLength));
147+ const { audio: _a, params: _p, ...run } = r;
148+ writeFileSync(metaPath, JSON.stringify({ overrides: ov, run }, null, 2));
149+ console.log(` ${r.grid.nx}x${r.grid.ny}, ${(r.steps / r.seconds / 1e3).toFixed(0)} k steps/s, ${r.seconds.toFixed(0)} s`);
150+ }
151+ results.push({ clip, id, ov, r });
152+ }
153+ // One gain per sweep so that loudness differences within it are preserved.
154+ let gainDb: number;
155+ if (sw.gainDb === undefined || sw.gainDb === "auto") {
156+ const loudest = Math.max(...results.map((x) => peakDbfs(x.r.audio, 1)));
157+ gainDb = Math.round(3 - loudest);
158+ } else gainDb = sw.gainDb;
159+ 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; }));
160+ const records = results.map((x) => writeClip(clipsDir, x.id, x.clip.label, x.ov, x.r, gainDb, peakRef));
161+ writeFileSync(join(dir, `${sw.id}.json`), JSON.stringify({ sweep: sw, gainDb, clips: records }, null, 2));
162+ writeHtml(manifest, dir);
163+ console.log(` written (gain ${gainDb} dB)`);
164+ }
165+ writeHtml(manifest, dir);
166+}
167+
168+function esc(s: string): string {
169+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
170+}
171+
172+function fmtNum(v: number, digits = 2): string {
173+ if (!isFinite(v)) return "∞";
174+ return v.toFixed(digits);
175+}
176+
177+function writeHtml(manifest: Manifest, dir: string): void {
178+ const sections: string[] = [];
179+ for (const sw of manifest.sweeps) {
180+ const path = join(dir, `${sw.id}.json`);
181+ if (!existsSync(path)) continue;
182+ const data = JSON.parse(readFileSync(path, "utf8")) as { sweep: ManifestSweep; gainDb: number; clips: ClipRecord[] };
183+ const cards = data.clips.map((c) => {
184+ const q = new URLSearchParams();
185+ for (const [k, v] of Object.entries(c.overrides)) q.set(k, String(v));
186+ q.set("autostart", "1");
187+ const m = c.metrics;
188+ const force = c.run.maxContactForce > 0 ? `<div>contact force ${fmtNum(c.run.maxContactForce, 0)} N</div>` : "";
189+ return `<div class="card">
190+ <div class="label">${esc(c.label)}</div>
191+ <audio controls preload="none" src="clips/${c.id}.wav"></audio>
192+ <img src="clips/${c.id}.png" alt="spectrogram" width="${SPEC_W}" height="${SPEC_H}">
193+ <div class="metrics">
194+ <div>peak ${fmtNum(m.peakPa, 3)} Pa (${fmtNum(m.peakDbSpl, 0)} dB SPL)</div>
195+ <div>RMS ${fmtNum(m.rmsPa * 1e3, 1)} mPa</div>
196+ <div>centroid ${fmtNum(m.centroidHz, 0)} Hz</div>
197+ <div>T60 100-500 Hz ${fmtNum(m.t60["100-500"], 1)} s, 2-8 kHz ${fmtNum(m.t60["2k-8k"], 1)} s</div>
198+ ${force}
199+ <div class="dim">wav peak ${fmtNum(c.peakDbfs, 1)} dBFS${c.peakDbfs > 0 ? " (soft-clipped)" : ""}</div>
200+ </div>
201+ <a href="../../?${q.toString()}">Open in simulator</a>
202+</div>`;
203+ });
204+ sections.push(`<section id="${sw.id}">
205+<h2>${esc(sw.title)}</h2>
206+<p>${esc(sw.text)}</p>
207+<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>
208+<div class="cards">${cards.join("\n")}</div>
209+</section>`);
210+ }
211+ 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("");
212+ const html = `<!doctype html>
213+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
214+<title>${esc(manifest.title)}</title>
215+<style>
216+body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #15171a; color: #dfe2e6; max-width: 1400px; margin: 0 auto; line-height: 1.4; }
217+h1 { font-size: 24px; } h2 { font-size: 18px; margin-top: 40px; border-top: 1px solid #2c3036; padding-top: 16px; }
218+p { max-width: 900px; } .dim { color: #9aa0a8; font-size: 13px; } a { color: #6fa8ea; }
219+.cards { display: flex; flex-wrap: wrap; gap: 14px; }
220+.card { background: #1c1e22; border: 1px solid #2c3036; border-radius: 8px; padding: 10px; width: 320px; display: flex; flex-direction: column; gap: 6px; }
221+.card img { width: 100%; height: auto; border-radius: 4px; }
222+.card audio { width: 100%; } .label { font-weight: 600; }
223+.metrics { font-size: 12px; color: #c8cbd0; } .metrics div { padding: 1px 0; }
224+ul.toc { columns: 2; }
225+</style></head><body>
226+<h1>${esc(manifest.title)}</h1>
227+<p>${esc(manifest.intro)}</p>
228+<ul class="toc">${toc}</ul>
229+${sections.join("\n")}
230+<p class="dim">Generated by <code>steelsim gallery</code>; parameters not listed take the simulator defaults.</p>
231+</body></html>`;
232+ writeFileSync(join(dir, "index.html"), html);
233+}
234+
235+const commands: Record<string, () => Promise<void>> = { render, gallery };
236+if (!command || !(command in commands)) {
237+ console.error("usage: steelsim render [--out DIR] [--name ID] [--gain DB] key=value ...\n steelsim gallery MANIFEST.json [--out DIR] [--only SWEEP] [--force]");
238+ process.exit(2);
239+}
240+commands[command]().then(() => process.exit(0), (e) => { console.error(e); process.exit(1); });
src/cli/metrics.tsadded+66−0View file
@@ -0,0 +1,66 @@
1+// Summary numbers of a rendered clip.
2+
3+import { FFT } from "../audio/fft.ts";
4+
5+export interface ClipMetrics {
6+ peakPa: number;
7+ peakDbSpl: number;
8+ rmsPa: number;
9+ /** Spectral centroid [Hz] of the sound between 0.2 s and 1.2 s. */
10+ centroidHz: number;
11+ /** Fitted 60 dB decay times [s] per band, from 0.2 s to the end (Infinity when not decaying). */
12+ t60: Record<string, number>;
13+ /** Band levels [dB] relative to the 100-500 Hz band, at 0.1 s. */
14+ bandLevelsDb: Record<string, number>;
15+}
16+
17+const BANDS: [string, number, number][] = [["20-100", 20, 100], ["100-500", 100, 500], ["500-2k", 500, 2000], ["2k-8k", 2000, 8000]];
18+
19+export function clipMetrics(a: Float32Array, fs: number): ClipMetrics {
20+ let peak = 0, sum = 0;
21+ for (let k = 0; k < a.length; k++) { peak = Math.max(peak, Math.abs(a[k])); sum += a[k] * a[k]; }
22+ const rms = Math.sqrt(sum / Math.max(1, a.length));
23+ const win = Math.round(0.1 * fs);
24+ const n = Math.floor(a.length / win);
25+ const N = 1 << Math.ceil(Math.log2(win));
26+ const fft = new FFT(N);
27+ const re = new Float32Array(N), im = new Float32Array(N);
28+ const rows: number[][] = [];
29+ for (let i = 0; i < n; i++) {
30+ re.fill(0); im.fill(0);
31+ for (let k = 0; k < win; k++) re[k] = a[i * win + k] * (0.5 - 0.5 * Math.cos((2 * Math.PI * k) / win));
32+ fft.transform(re, im);
33+ const row: number[] = [];
34+ for (const [, lo, hi] of BANDS) {
35+ let e = 0;
36+ for (let b = Math.floor((lo * N) / fs); b < Math.min(N / 2, Math.ceil((hi * N) / fs)); b++) e += re[b] * re[b] + im[b] * im[b];
37+ row.push(10 * Math.log10(e + 1e-20));
38+ }
39+ rows.push(row);
40+ }
41+ const t60: Record<string, number> = {};
42+ const bandLevelsDb: Record<string, number> = {};
43+ BANDS.forEach(([name], k) => {
44+ const pts = rows.slice(2).map((r, i) => [(i + 2) * 0.1, r[k]]);
45+ let slope = 0;
46+ if (pts.length >= 3) {
47+ const mx = pts.reduce((s, p) => s + p[0], 0) / pts.length, my = pts.reduce((s, p) => s + p[1], 0) / pts.length;
48+ slope = pts.reduce((s, p) => s + (p[0] - mx) * (p[1] - my), 0) / pts.reduce((s, p) => s + (p[0] - mx) ** 2, 0);
49+ }
50+ t60[name] = slope < 0 ? 60 / -slope : Infinity;
51+ bandLevelsDb[name] = rows.length > 1 ? rows[1][k] - rows[1][1] : 0;
52+ });
53+ // Spectral centroid, 0.2 to 1.2 s.
54+ const seg = a.subarray(Math.round(0.2 * fs), Math.min(a.length, Math.round(1.2 * fs)));
55+ const M = 1 << Math.ceil(Math.log2(Math.max(2, seg.length)));
56+ const fft2 = new FFT(M);
57+ const r2 = new Float32Array(M), i2 = new Float32Array(M);
58+ r2.set(seg);
59+ fft2.transform(r2, i2);
60+ let num = 0, den = 0;
61+ for (let b = 1; b < M / 2; b++) {
62+ const p = r2[b] * r2[b] + i2[b] * i2[b];
63+ num += ((b * fs) / M) * p; den += p;
64+ }
65+ return { peakPa: peak, peakDbSpl: peak > 0 ? 20 * Math.log10(peak / 2e-5) : -Infinity, rmsPa: rms, centroidHz: den > 0 ? num / den : 0, t60, bandLevelsDb };
66+}
src/cli/png.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+// Minimal PNG encoder (RGBA, 8-bit) using Node's zlib.
2+
3+import { deflateSync } from "node:zlib";
4+
5+function crc32(buf: Uint8Array): number {
6+ let c = ~0;
7+ for (let k = 0; k < buf.length; k++) {
8+ c ^= buf[k];
9+ for (let b = 0; b < 8; b++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
10+ }
11+ return ~c >>> 0;
12+}
13+
14+function chunk(type: string, data: Uint8Array): Uint8Array {
15+ const out = new Uint8Array(12 + data.length);
16+ const v = new DataView(out.buffer);
17+ v.setUint32(0, data.length);
18+ for (let k = 0; k < 4; k++) out[4 + k] = type.charCodeAt(k);
19+ out.set(data, 8);
20+ v.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
21+ return out;
22+}
23+
24+export function encodePng(rgba: Uint8ClampedArray, width: number, height: number): Uint8Array {
25+ const raw = new Uint8Array((width * 4 + 1) * height);
26+ for (let y = 0; y < height; y++) {
27+ raw[y * (width * 4 + 1)] = 0;
28+ raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
29+ }
30+ const ihdr = new Uint8Array(13);
31+ const v = new DataView(ihdr.buffer);
32+ v.setUint32(0, width); v.setUint32(4, height);
33+ ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
34+ const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", new Uint8Array(deflateSync(raw))), chunk("IEND", new Uint8Array(0))];
35+ const total = parts.reduce((n, p) => n + p.length, 0);
36+ const out = new Uint8Array(total);
37+ let o = 0;
38+ for (const p of parts) { out.set(p, o); o += p.length; }
39+ return out;
40+}
src/cli/run.tsadded+63−0View file
@@ -0,0 +1,63 @@
1+// Runs one simulation to completion in Node (Dawn WebGPU) and returns the pressure signal.
2+
3+import { create, globals } from "webgpu";
4+import { defaultParams, type PhysParams } from "../params.ts";
5+import { Simulation } from "../sim/simulation.ts";
6+
7+let devicePromise: Promise<GPUDevice> | null = null;
8+
9+export function getDevice(): Promise<GPUDevice> {
10+ if (!devicePromise) {
11+ Object.assign(globalThis, globals);
12+ devicePromise = (async () => {
13+ const gpu = create([]);
14+ const adapter = await gpu.requestAdapter({ powerPreference: "high-performance" });
15+ if (!adapter) throw new Error("No WebGPU adapter");
16+ return adapter.requestDevice();
17+ })();
18+ }
19+ return devicePromise;
20+}
21+
22+export interface RunResult {
23+ params: PhysParams;
24+ audio: Float32Array;
25+ fs: number;
26+ steps: number;
27+ seconds: number;
28+ grid: { nx: number; ny: number };
29+ dt: number;
30+ maxContactForce: number;
31+}
32+
33+export async function runSimulation(overrides: Partial<PhysParams>, log: (s: string) => void = () => {}): Promise<RunResult> {
34+ const params: PhysParams = { ...defaultParams, ...overrides };
35+ const device = await getDevice();
36+ const sim = new Simulation(device, params);
37+ const t0 = performance.now();
38+ let lastLog = t0;
39+ let maxContactForce = 0;
40+ while (sim.phase !== "done") {
41+ await sim.runBatch();
42+ const st = sim.status();
43+ if (st.stick) maxContactForce = Math.max(maxContactForce, st.stick.maxForce);
44+ const now = performance.now();
45+ if (now - lastLog > 5000) {
46+ lastLog = now;
47+ log(` ${st.phase} t=${st.t.toFixed(2)} s, ${(sim.totalSteps / ((now - t0) / 1000) / 1e3).toFixed(0)} k steps/s`);
48+ }
49+ }
50+ const seconds = (performance.now() - t0) / 1000;
51+ const result: RunResult = {
52+ params,
53+ audio: sim.processedAudio().slice(),
54+ fs: sim.fs,
55+ steps: sim.totalSteps,
56+ seconds,
57+ grid: { nx: sim.grid.nx, ny: sim.grid.ny },
58+ dt: sim.dt,
59+ maxContactForce,
60+ };
61+ sim.destroy();
62+ return result;
63+}
tsconfig.jsonmodified+1−1View file
@@ -13,7 +13,7 @@
1313 "allowImportingTsExtensions": true,
1414 "noEmit": true,
1515 "lib": ["ES2022", "DOM", "DOM.Iterable"],
16- "types": ["@webgpu/types", "vite/client"]
16+ "types": ["@webgpu/types", "vite/client", "node"]
1717 },
1818 "include": ["src"]
1919 }
vite.cli.config.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+import { defineConfig } from "vite";
2+
3+// Node build of the command-line renderer (WGSL imports are inlined by Vite).
4+export default defineConfig({
5+ build: {
6+ ssr: "src/cli/main.ts",
7+ outDir: "dist-cli",
8+ target: "node20",
9+ rollupOptions: { output: { entryFileNames: "steelsim.js" } },
10+ },
11+ ssr: { target: "node", noExternal: [] },
12+});
↑↓ move↵openescclose