f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 1// UI wiring: parameters, run loop, live and movie views, playback, downloads.
3import { defaultParams, makeGrid, makeMaterial, t60, type PhysParams } from "./params.ts";
4import { Simulation } from "./sim/simulation.ts";
5import { Renderer, type ViewMode } from "./render/renderer.ts";
6import { Spectrogram } from "./audio/spectrogram.ts";
7import { Player } from "./audio/player.ts";
8import { encodeWav } from "./audio/wav.ts";
10const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
12// ---------- Parameter form ----------
14interface FieldSpec {
15 key: keyof PhysParams;
16 label: string;
17 unit: string;
18 scale: number; // UI value = SI value / scale
19 step?: number;
20 live?: boolean; // may be changed while a simulation runs
21}
23const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [
24 {
25 title: "Plate",
26 open: true,
27 fields: [
28 { key: "Lx", label: "Width", unit: "in", scale: 0.0254, step: 0.5 },
29 { key: "Ly", label: "Height", unit: "in", scale: 0.0254, step: 0.5 },
30 { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 },
31 { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 },
32 { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 },
33 { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 },
34 ],
35 },
36 {
37 title: "Excitation",
38 open: true,
39 fields: [
40 { key: "pushAmount", label: "Push at corner", unit: "mm", scale: 1e-3, step: 0.5 },
41 { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 },
42 { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 },
43 { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 },
44 { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 },
45 { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 },
46 ],
47 },
48 {
49 title: "Damping",
50 open: true,
51 fields: [
52 { key: "sigma0", label: "σ₀ (freq. independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
53 { key: "sigma1", label: "σ₁ (freq. dependent)", unit: "m²/s", scale: 1, step: 1e-5, live: true },
54 ],
55 },
56 {
57 title: "Listener",
58 open: true,
59 fields: [
60 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
61 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
62 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
63 ],
64 },
65 {
66 title: "Numerics",
67 open: false,
68 fields: [
69 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
70 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
71 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
72 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
73 ],
74 },
75];
77const inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
78let params: PhysParams = { ...defaultParams };
80function buildForm(): void {
81 const root = $("params");
82 for (const g of groups) {
83 const det = document.createElement("details");
84 det.open = g.open;
85 const sum = document.createElement("summary");
86 sum.textContent = g.title;
87 det.appendChild(sum);
88 for (const f of g.fields) {
89 const row = document.createElement("div");
90 row.className = "field";
91 const label = document.createElement("label");
92 label.textContent = f.label;
93 const inp = document.createElement("input");
94 inp.type = "number";
95 inp.step = String(f.step ?? 1);
96 inp.value = fmt((params[f.key] as number) / f.scale);
97 inp.addEventListener("change", () => onFieldChange(f, inp));
98 const unit = document.createElement("span");
99 unit.className = "unit";
100 unit.textContent = f.unit;
101 row.append(label, inp, unit);
102 det.appendChild(row);
103 inputs.set(f.key, inp);
104 if (f.key === "listenerElevation") {
105 const r2 = document.createElement("div");
106 r2.className = "field";
107 const l2 = document.createElement("label");
108 l2.textContent = "Radiation model";
109 const sel = document.createElement("select");
110 for (const [v, t] of [["rayleigh", "Rayleigh (baffled)"], ["dipole", "Dipole (unbaffled, approx.)"]]) {
111 const o = document.createElement("option");
112 o.value = v; o.textContent = t;
113 sel.appendChild(o);
114 }
115 sel.value = params.radiation;
116 sel.addEventListener("change", () => {
117 params.radiation = sel.value as PhysParams["radiation"];
118 applyListener();
119 });
120 r2.append(l2, sel, document.createElement("span"));
121 det.appendChild(r2);
122 inputs.set("radiation", sel);
123 }
124 if (f.key === "sigma1") {
125 const note = document.createElement("p");
126 note.className = "sub";
127 note.id = "dampNote";
128 det.appendChild(note);
129 }
130 }
131 root.appendChild(det);
132 }
133 updateDampingNote();
134}
136function fmt(v: number): string {
137 if (v === 0) return "0";
138 const a = Math.abs(v);
139 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
140 return String(+v.toPrecision(6));
141}
143function onFieldChange(f: FieldSpec, inp: HTMLInputElement): void {
144 const v = parseFloat(inp.value);
145 if (!isFinite(v)) return;
146 (params as unknown as Record<string, number>)[f.key] = v * f.scale;
147 if (f.key === "sigma0" || f.key === "sigma1") {
148 updateDampingNote();
149 sim?.setDamping(params.sigma0, params.sigma1);
150 }
151 if (f.key.startsWith("listener")) applyListener();
152}
154function applyListener(): void {
155 sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation);
156}
158function updateDampingNote(): void {
159 const m = makeMaterial(params);
160 const f = (hz: number) => t60(m, params, 2 * Math.PI * hz).toFixed(2);
161 $("dampNote").textContent = `T60: ${f(100)} s at 100 Hz, ${f(1000)} s at 1 kHz, ${f(5000)} s at 5 kHz, ${f(10000)} s at 10 kHz`;
162}
164function setFormEnabled(enabled: boolean): void {
165 for (const g of groups) for (const f of g.fields) if (!f.live) inputs.get(f.key)!.disabled = !enabled;
166}
168// ---------- State ----------
170let device: GPUDevice | null = null;
171let sim: Simulation | null = null;
172let renderer: Renderer | null = null;
173let running = false;
174let paused = false;
175let live = true;
176let scrubFrame = 0;
177let movieBuf: GPUBuffer | null = null;
178let movieBufFrame = -1;
179let spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 10000, dbFloor: 80, logFrequency: false });
180const player = new Player();
181let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
182let duration = params.duration;
184// ---------- Controls ----------
186const btnStart = mkButton("Start", "primary");
187const btnPause = mkButton("Pause");
188const btnContinue = mkButton("Continue");
189const durInput = document.createElement("input");
191function mkButton(text: string, cls = ""): HTMLButtonElement {
192 const b = document.createElement("button");
193 b.textContent = text;
194 b.className = cls;
195 return b;
196}
198function buildControls(): void {
199 const c = $("controls");
200 const durRow = document.createElement("div");
201 durRow.className = "field";
202 durRow.style.width = "100%";
203 const l = document.createElement("label");
204 l.textContent = "Duration after release";
205 durInput.type = "number";
206 durInput.step = "0.5";
207 durInput.min = "0.1";
208 durInput.value = String(duration);
209 durInput.addEventListener("change", () => {
210 const v = parseFloat(durInput.value);
211 if (isFinite(v) && v > 0) {
212 duration = v;
213 params.duration = v;
214 sim?.setDuration(v);
215 updateButtons();
216 }
217 });
218 const u = document.createElement("span");
219 u.className = "unit";
220 u.textContent = "s";
221 durRow.append(l, durInput, u);
222 c.append(durRow, btnStart, btnPause, btnContinue);
223 btnStart.addEventListener("click", () => void start());
224 btnPause.addEventListener("click", () => {
225 paused = !paused;
226 btnPause.textContent = paused ? "Resume" : "Pause";
227 if (!paused) void runLoop();
228 });
229 btnContinue.addEventListener("click", () => {
230 if (!sim) return;
231 sim.setDuration(duration);
232 if (sim.phase !== "done") void runLoop();
233 updateButtons();
234 });
235 updateButtons();
236}
238function updateButtons(): void {
239 const s = sim?.status();
240 btnPause.disabled = !sim || !running || s?.phase === "done";
241 btnContinue.disabled = !sim || running || !s || s.phase !== "done" || s.samples >= Math.round(duration * params.fs) - 1;
242 if (sim && sim.phase === "done" && sim.samples < sim.targetSamples()) btnContinue.disabled = false;
243}
245// ---------- View toolbars ----------
247const viewMode = { value: "2d" as ViewMode };
248const scrub = document.createElement("input");
249const timeLabel = document.createElement("span");
250const btnLive = mkButton("Live");
251const btnPlay = mkButton("Play");
252const btnWav = mkButton("Download .wav");
253let exagInput: HTMLInputElement;
254let rangeInput: HTMLInputElement;
256function buildViewToolbar(): void {
257 const tb = $("viewToolbar");
258 const seg = document.createElement("span");
259 seg.className = "seg";
260 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
261 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
262 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
263 seg.append(b2, b3);
264 exagInput = numInput(4, 0.5);
265 rangeInput = numInput(0, 0.5);
266 const hint = document.createElement("span");
267 hint.className = "sub";
268 hint.style.margin = "0";
269 hint.textContent = "3D: drag to orbit, wheel to zoom";
270 tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), hint);
271}
273function buildMovieToolbar(): void {
274 const tb = $("movieToolbar");
275 scrub.type = "range";
276 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
277 scrub.addEventListener("input", () => {
278 live = false;
279 scrubFrame = parseInt(scrub.value);
280 if (player.playing) player.stop();
281 updateLiveButton();
282 });
283 timeLabel.className = "time";
284 btnLive.addEventListener("click", () => { live = true; updateLiveButton(); });
285 btnPlay.addEventListener("click", () => {
286 if (player.playing) { player.stop(); btnPlay.textContent = "Play"; return; }
287 if (!sim || sim.samples === 0) return;
288 const offset = live ? 0 : scrubFrame / sim.fps;
289 live = false;
290 player.play(sim.processedAudio(), sim.fs, offset);
291 btnPlay.textContent = "Stop";
292 updateLiveButton();
293 });
294 player.onEnded = () => { btnPlay.textContent = "Play"; };
295 btnWav.addEventListener("click", () => {
296 if (!sim || sim.samples === 0) return;
297 const blob = encodeWav(sim.processedAudio(), sim.fs);
298 const a = document.createElement("a");
299 a.href = URL.createObjectURL(blob);
300 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
301 a.click();
302 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
303 });
304 tb.append(btnPlay, btnLive, scrub, timeLabel, btnWav);
305 updateLiveButton();
306}
308function updateLiveButton(): void {
309 btnLive.classList.toggle("on", live);
310 btnLive.disabled = live;
311}
313function buildSpecToolbar(): void {
314 const tb = $("specToolbar");
315 const win = document.createElement("select");
316 for (const n of [512, 1024, 2048, 4096, 8192]) {
317 const o = document.createElement("option");
318 o.value = String(n); o.textContent = String(n);
319 win.appendChild(o);
320 }
321 win.value = "2048";
322 const hop = numInput(512, 64);
323 const fmax = numInput(10000, 500);
324 const floor = numInput(80, 5);
325 const logf = document.createElement("input");
326 logf.type = "checkbox";
327 const apply = () => {
328 if (!sim) return;
329 spec.configure(
330 { windowSize: parseInt(win.value), hop: Math.max(16, parseInt(hop.value)), fmax: Math.min(sim.fs / 2, parseFloat(fmax.value)), dbFloor: parseFloat(floor.value), logFrequency: logf.checked },
331 sim.processedAudio(), sim.samples,
332 );
333 };
334 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
335 tb.append(
336 document.createTextNode("Spectrogram"),
337 labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf),
338 );
339}
341function numInput(v: number, step: number): HTMLInputElement {
342 const i = document.createElement("input");
343 i.type = "number"; i.value = String(v); i.step = String(step);
344 return i;
345}
346function labeled(text: string, el: HTMLElement): HTMLLabelElement {
347 const l = document.createElement("label");
348 l.append(text, el);
349 return l;
350}
352// ---------- Simulation control ----------
354async function start(): Promise<void> {
355 if (!device) return;
356 player.stop();
357 running = false;
358 await new Promise((r) => setTimeout(r, 0));
359 sim?.destroy();
360 spec.reset();
361 live = true;
362 paused = false;
363 btnPause.textContent = "Pause";
364 params.duration = duration;
365 sim = new Simulation(device, params);
366 renderer = new Renderer(device, $("view"), sim.grid, params);
367 movieBuf?.destroy();
368 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
369 movieBufFrame = -1;
370 scrub.max = "0"; scrub.value = "0";
371 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
372 setFormEnabled(false);
373 updateLiveButton();
374 void runLoop();
375}
377async function runLoop(): Promise<void> {
378 if (!sim || running) return;
379 running = true;
380 updateButtons();
381 try {
382 while (sim.phase !== "done" && !paused) {
383 await sim.runBatch();
384 const audio = sim.processedAudio();
385 spec.update(audio, sim.samples);
386 scrub.max = String(Math.max(0, sim.frames.length - 1));
387 if (live) scrub.value = scrub.max;
388 const now = performance.now();
389 if (now - lastStatsTime > 500) {
390 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
391 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
392 }
393 }
394 } catch (e) {
395 console.error(e);
396 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
397 }
398 running = false;
399 setFormEnabled(sim.phase === "done" && !paused ? true : false);
400 if (sim.phase === "done") setFormEnabled(true);
401 updateButtons();
402}
404// ---------- Render loop ----------
406let frameErrorShown = false;
407function frame(): void {
408 requestAnimationFrame(frame);
409 try {
410 frameBody();
411 } catch (e) {
412 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
413 }
414}
416function frameBody(): void {
417 if (!device || !sim || !renderer) return;
418 const dev = device;
419 renderer.mode = viewMode.value;
420 renderer.exaggeration = parseFloat(exagInput.value) || 1;
421 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
422 const pos = player.position();
423 if (pos !== null) {
424 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
425 scrub.value = String(scrubFrame);
426 }
427 const enc = dev.createCommandEncoder();
428 const st = sim.status();
429 if (live || sim.frames.length === 0) {
430 sim.encodeDisplay(enc);
431 renderer.render(enc, sim.displayW, st.wmax);
432 timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`;
433 } else {
434 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
435 if (movieBufFrame !== f) {
436 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
437 movieBufFrame = f;
438 }
439 let wmax = 0;
440 const fr = sim.frames[f];
441 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
442 renderer.render(enc, movieBuf!, wmax);
443 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
444 }
445 dev.pushErrorScope("validation");
446 dev.queue.submit([enc.finish()]);
447 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
448 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
449 updateInfo(st);
450}
452function updateInfo(st: ReturnType<Simulation["status"]>): void {
453 if (!sim) return;
454 const g = sim.grid;
455 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
456 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
457 const rows: [string, string][] = [
458 ["Phase", phase],
459 ["Time", `${st.t.toFixed(3)} s`],
460 ["Grid", `${g.nx} × ${g.ny} nodes, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
461 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
462 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
463 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
464 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
465 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
466 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
467 ];
468 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
469 if (st.delayClamped) html += `<div class="warn">Listener is so close that some propagation delays were clamped.</div>`;
470 if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Reduce the time step by increasing the grid spacing, or reduce the push.</div>`;
471 $("info").innerHTML = html;
472}
474// ---------- Init ----------
476async function init(): Promise<void> {
477 buildForm();
478 buildControls();
479 buildViewToolbar();
480 buildMovieToolbar();
481 buildSpecToolbar();
482 if (!("gpu" in navigator)) {
483 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge (or Firefox / Safari with WebGPU enabled).");
484 return;
485 }
486 const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
487 if (!adapter) {
488 showNoGpu("No WebGPU adapter was found.");
489 return;
490 }
491 device = await adapter.requestDevice();
492 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
493 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
494 const g = makeGrid(params);
495 $("info").innerHTML = `<div class="row"><span>Grid (default)</span><span>${g.nx} × ${g.ny} nodes</span></div><p class="sub">Press Start to run.</p>`;
496 requestAnimationFrame(frame);
497 applyUrlParams();
498}
500/** URL parameters: any PhysParams key in SI units, plus autostart=1. Exposes the simulation on window for tests. */
501function applyUrlParams(): void {
502 const q = new URLSearchParams(location.search);
503 for (const [k, v] of q) {
504 if (k in params && k !== "radiation") {
505 const num = parseFloat(v);
506 if (isFinite(num)) {
507 (params as unknown as Record<string, number>)[k] = num;
508 const inp = inputs.get(k as keyof PhysParams);
509 const spec = groups.flatMap((g) => g.fields).find((f) => f.key === k);
510 if (inp && spec) (inp as HTMLInputElement).value = fmt(num / spec.scale);
511 }
512 }
513 if (k === "radiation" && (v === "rayleigh" || v === "dipole")) params.radiation = v;
514 if (k === "duration") {
515 duration = params.duration;
516 durInput.value = String(duration);
517 }
518 }
519 updateDampingNote();
520 (window as unknown as { steelsim: unknown }).steelsim = {
521 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
522 audio: () => (sim ? Array.from(sim.processedAudio()) : null),
523 frame: (k: number) => (sim ? Array.from(sim.frames[k] ?? []) : null),
524 frames: () => sim?.frames.length ?? 0,
525 state: async () => { if (!sim) return null; const st = await sim.readState(); return { pos: Array.from(st.pos), vel: Array.from(st.vel) }; },
526 pause: () => { paused = true; },
527 canvasInfo: () => { const c = $<HTMLCanvasElement>("view"); return { cw: c.clientWidth, ch: c.clientHeight, w: c.width, h: c.height, mode: viewMode.value }; },
528 setView: (m: ViewMode) => { viewMode.value = m; },
529 scrub: (k: number) => { live = false; scrubFrame = k; scrub.value = String(k); updateLiveButton(); },
530 };
531 if (q.get("autostart") === "1") void start();
532}
534function showNoGpu(msg: string): void {
535 const el = $("nogpu");
536 el.hidden = false;
537 el.textContent = msg;
538 btnStart.disabled = true;
539}
541void init();