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