/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / main.ts
738 lines · 28.9 KBCodeBlameHistory
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 1// UI wiring: parameters, run loop, live and movie views, playback, downloads.
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 3import { countInside, defaultParams, makeGrid, makeMaterial, t60, type BowShape, type Excitation, type PhysParams, type PlateShape } 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";
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 9import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
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
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 {
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 38 title: "Excitation: push and release",
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 39 open: true,
40 fields: [
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 41 { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5 },
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 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: "Excitation: drumstick strike",
52 open: false,
53 fields: [
54 { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
55 { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
56 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
57 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
58 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
59 { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
60 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
61 ],
62 },
64 title: "Initial shape",
65 open: true,
66 fields: [{ key: "bowAmplitude", label: "Bow (center vs edges)", unit: "mm", scale: 1e-3, step: 0.5 }],
67 },
69 title: "Damping",
70 open: true,
71 fields: [
72 { key: "sigma0", label: "σ₀ (freq. independent)", unit: "1/s", scale: 1, step: 0.1, live: true },
73 { 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 74 { key: "sigmaIn", label: "In-plane loss", unit: "1/s", scale: 1, step: 5, live: true },
76 },
77 {
78 title: "Listener",
79 open: true,
80 fields: [
81 { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true },
82 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true },
83 { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true },
84 ],
85 },
86 {
87 title: "Numerics",
88 open: false,
89 fields: [
90 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
91 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
92 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
93 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
94 ],
95 },
96];
98const inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
99let params: PhysParams = { ...defaultParams };
101function buildForm(): void {
102 const root = $("params");
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 103 const exRow = document.createElement("div");
104 exRow.className = "field";
105 const exLabel = document.createElement("label");
106 exLabel.textContent = "Excitation";
107 const exSel = document.createElement("select");
108 for (const [v, t] of [["push", "Push corner and release"], ["strike", "Drumstick strike"]]) {
109 const o = document.createElement("option");
110 o.value = v; o.textContent = t;
111 exSel.appendChild(o);
112 }
113 exSel.value = params.excitation;
114 exSel.addEventListener("change", () => { params.excitation = exSel.value as Excitation; });
115 exRow.append(exLabel, exSel, document.createElement("span"));
116 root.appendChild(exRow);
117 inputs.set("excitation", exSel);
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 118 for (const g of groups) {
119 const det = document.createElement("details");
120 det.open = g.open;
121 const sum = document.createElement("summary");
122 sum.textContent = g.title;
123 det.appendChild(sum);
124 for (const f of g.fields) {
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 125 if (f.key === "Lx") {
126 const r0 = document.createElement("div");
127 r0.className = "field";
128 const l0 = document.createElement("label");
129 l0.textContent = "Shape";
130 const sel = document.createElement("select");
131 for (const [v, t] of [["rectangle", "Rectangle"], ["ellipse", "Ellipse (inscribed)"]]) {
132 const o = document.createElement("option");
133 o.value = v; o.textContent = t;
134 sel.appendChild(o);
135 }
136 sel.value = params.shape;
137 sel.addEventListener("change", () => { params.shape = sel.value as PlateShape; updateGridNote(); });
138 r0.append(l0, sel, document.createElement("span"));
139 det.appendChild(r0);
140 inputs.set("shape", sel);
141 }
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 142 const row = document.createElement("div");
143 row.className = "field";
144 const label = document.createElement("label");
145 label.textContent = f.label;
146 const inp = document.createElement("input");
147 inp.type = "number";
148 inp.step = String(f.step ?? 1);
149 inp.value = fmt((params[f.key] as number) / f.scale);
150 inp.addEventListener("change", () => onFieldChange(f, inp));
151 const unit = document.createElement("span");
152 unit.className = "unit";
153 unit.textContent = f.unit;
154 row.append(label, inp, unit);
155 det.appendChild(row);
156 inputs.set(f.key, inp);
157 if (f.key === "listenerElevation") {
158 const r2 = document.createElement("div");
159 r2.className = "field";
160 const l2 = document.createElement("label");
161 l2.textContent = "Radiation model";
162 const sel = document.createElement("select");
163 for (const [v, t] of [["rayleigh", "Rayleigh (baffled)"], ["dipole", "Dipole (unbaffled, approx.)"]]) {
164 const o = document.createElement("option");
165 o.value = v; o.textContent = t;
166 sel.appendChild(o);
167 }
168 sel.value = params.radiation;
169 sel.addEventListener("change", () => {
170 params.radiation = sel.value as PhysParams["radiation"];
171 applyListener();
172 });
173 r2.append(l2, sel, document.createElement("span"));
174 det.appendChild(r2);
175 inputs.set("radiation", sel);
176 }
8dcc11fInitial-shape imperfection (Marguerre shallow-shell strains)Jeremy Magland 177 if (f.key === "bowAmplitude") {
178 const r2 = document.createElement("div");
179 r2.className = "field";
180 const l2 = document.createElement("label");
181 l2.textContent = "Bow shape";
182 const sel = document.createElement("select");
183 for (const [v, t] of [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (doubly curved)"], ["none", "Flat"]]) {
184 const o = document.createElement("option");
185 o.value = v; o.textContent = t;
186 sel.appendChild(o);
187 }
188 sel.value = params.bowShape;
189 sel.addEventListener("change", () => { params.bowShape = sel.value as BowShape; });
190 r2.append(l2, sel, document.createElement("span"));
191 det.appendChild(r2);
192 inputs.set("bowShape", sel);
193 const note = document.createElement("p");
194 note.className = "sub";
195 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.";
196 det.appendChild(note);
197 }
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 199 const note = document.createElement("p");
200 note.className = "sub";
201 note.id = "dampNote";
202 det.appendChild(note);
203 }
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 204 if (f.key === "rho") {
205 const note = document.createElement("p");
206 note.className = "sub";
207 note.id = "gridNote";
208 det.appendChild(note);
209 }
211 root.appendChild(det);
212 }
213 updateDampingNote();
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 214 updateGridNote();
217function updateGridNote(): void {
218 const g = makeGrid(params);
219 const inside = countInside(g);
220 const shape = params.shape === "ellipse" ? `ellipse of ${(params.Lx / 0.0254).toFixed(1)} by ${(params.Ly / 0.0254).toFixed(1)} in, edge stepped in whole cells` : "rectangle";
221 $("gridNote").textContent = `${shape}; ${g.nx} × ${g.ny} grid, ${inside} nodes in the sheet.`;
224function fmt(v: number): string {
225 if (v === 0) return "0";
226 const a = Math.abs(v);
227 if (a >= 1e5 || a < 1e-3) return v.toExponential(3);
228 return String(+v.toPrecision(6));
231function onFieldChange(f: FieldSpec, inp: HTMLInputElement): void {
232 const v = parseFloat(inp.value);
233 if (!isFinite(v)) return;
234 (params as unknown as Record<string, number>)[f.key] = v * f.scale;
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 235 if (f.key === "Lx" || f.key === "Ly" || f.key === "spacing") updateGridNote();
bfde5e4High-frequency ringing: in-plane loss, band-limited listener sum, damping defaultsJeremy Magland 236 if (f.key === "sigma0" || f.key === "sigma1" || f.key === "sigmaIn") {
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 237 updateDampingNote();
bfde5e4High-frequency ringing: in-plane loss, band-limited listener sum, damping defaultsJeremy Magland 238 sim?.setDamping(params.sigma0, params.sigma1, params.sigmaIn);
240 if (f.key.startsWith("listener")) applyListener();
243function applyListener(): void {
244 sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation);
247function updateDampingNote(): void {
248 const m = makeMaterial(params);
249 const f = (hz: number) => t60(m, params, 2 * Math.PI * hz).toFixed(2);
250 $("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`;
253function setFormEnabled(enabled: boolean): void {
254 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 255 inputs.get("bowShape")!.disabled = !enabled;
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 256 inputs.get("excitation")!.disabled = !enabled;
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 257 inputs.get("shape")!.disabled = !enabled;
260// ---------- State ----------
262let device: GPUDevice | null = null;
263let sim: Simulation | null = null;
264let renderer: Renderer | null = null;
265let running = false;
266let paused = false;
267let live = true;
268let scrubFrame = 0;
269let movieBuf: GPUBuffer | null = null;
270let movieBufFrame = -1;
6f03b39Spectrogram default range 5 kHzJeremy Magland 271let spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 272const player = new Player();
273let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
274let duration = params.duration;
276// ---------- Controls ----------
278const btnStart = mkButton("Start", "primary");
279const btnPause = mkButton("Pause");
176c8fbStop button: end a run and unlock the parameter fieldsJeremy Magland 280const btnStop = mkButton("Stop");
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 281const btnContinue = mkButton("Continue");
282const durInput = document.createElement("input");
284function mkButton(text: string, cls = ""): HTMLButtonElement {
285 const b = document.createElement("button");
286 b.textContent = text;
287 b.className = cls;
288 return b;
291function buildControls(): void {
292 const c = $("controls");
293 const durRow = document.createElement("div");
294 durRow.className = "field";
295 durRow.style.width = "100%";
296 const l = document.createElement("label");
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 297 l.textContent = "Recorded duration";
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 298 durInput.type = "number";
299 durInput.step = "0.5";
300 durInput.min = "0.1";
301 durInput.value = String(duration);
302 durInput.addEventListener("change", () => {
303 const v = parseFloat(durInput.value);
304 if (isFinite(v) && v > 0) {
305 duration = v;
306 params.duration = v;
307 sim?.setDuration(v);
308 updateButtons();
309 }
310 });
311 const u = document.createElement("span");
312 u.className = "unit";
313 u.textContent = "s";
314 durRow.append(l, durInput, u);
176c8fbStop button: end a run and unlock the parameter fieldsJeremy Magland 315 c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
316 btnStop.addEventListener("click", () => {
317 if (!sim) return;
318 sim.stop();
319 paused = false;
320 btnPause.textContent = "Pause";
321 if (!running) {
322 setFormEnabled(true);
323 updateButtons();
324 }
325 });
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 326 btnStart.addEventListener("click", () => void start());
327 btnPause.addEventListener("click", () => {
328 paused = !paused;
329 btnPause.textContent = paused ? "Resume" : "Pause";
330 if (!paused) void runLoop();
331 });
332 btnContinue.addEventListener("click", () => {
333 if (!sim) return;
334 sim.setDuration(duration);
335 if (sim.phase !== "done") void runLoop();
336 updateButtons();
337 });
338 updateButtons();
341function updateButtons(): void {
342 const s = sim?.status();
6c4aa55Fix Resume button being disabled after PauseJeremy Magland 343 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
176c8fbStop button: end a run and unlock the parameter fieldsJeremy Magland 344 btnStop.disabled = !sim || s?.phase === "done";
345 btnContinue.disabled = !sim || running || !sim.canContinue();
348// ---------- View toolbars ----------
350const viewMode = { value: "2d" as ViewMode };
351const scrub = document.createElement("input");
352const timeLabel = document.createElement("span");
353const btnLive = mkButton("Live");
354const btnPlay = mkButton("Play");
355const btnWav = mkButton("Download .wav");
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 356const levelSel = document.createElement("select");
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 357const gainSlider = document.createElement("input");
358const gainLabel = document.createElement("span");
359const peakLabel = document.createElement("span");
361function gainDb(): number {
362 return parseFloat(gainSlider.value) || 0;
365function updateLevelReadout(): void {
366 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
367 gainSlider.disabled = levelSel.value !== "absolute";
368 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
369 const audio = sim.processedAudio();
370 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelSel.value as LevelMode, gainDb()));
371 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS` : "";
372 peakLabel.className = db > 0 ? "warn" : "sub";
373 peakLabel.style.margin = "0";
374 if (db > 0) peakLabel.textContent += " (clipped)";
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 376let exagInput: HTMLInputElement;
377let rangeInput: HTMLInputElement;
8dcc11fInitial-shape imperfection (Marguerre shallow-shell strains)Jeremy Magland 378let shapeCheck: HTMLInputElement;
380function buildViewToolbar(): void {
381 const tb = $("viewToolbar");
382 const seg = document.createElement("span");
383 seg.className = "seg";
384 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
385 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
386 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
387 seg.append(b2, b3);
388 exagInput = numInput(4, 0.5);
389 rangeInput = numInput(0, 0.5);
390 const hint = document.createElement("span");
391 hint.className = "sub";
392 hint.style.margin = "0";
393 hint.textContent = "3D: drag to orbit, wheel to zoom";
8dcc11fInitial-shape imperfection (Marguerre shallow-shell strains)Jeremy Magland 394 shapeCheck = document.createElement("input");
395 shapeCheck.type = "checkbox";
396 tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("3D: add initial shape", shapeCheck), hint);
399function buildMovieToolbar(): void {
400 const tb = $("movieToolbar");
401 scrub.type = "range";
402 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
403 scrub.addEventListener("input", () => {
404 live = false;
405 scrubFrame = parseInt(scrub.value);
406 if (player.playing) player.stop();
407 updateLiveButton();
408 });
409 timeLabel.className = "time";
410 btnLive.addEventListener("click", () => { live = true; updateLiveButton(); });
38f6b3eReturn to live view when playback endsJeremy Magland 411 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
412 let liveBeforePlay = true;
413 const endPlayback = () => {
414 btnPlay.textContent = "Play";
415 if (liveBeforePlay) {
416 live = true;
417 updateLiveButton();
418 }
419 };
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 420 btnPlay.addEventListener("click", () => {
38f6b3eReturn to live view when playback endsJeremy Magland 421 if (player.playing) { player.stop(); endPlayback(); return; }
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 422 if (!sim || sim.samples === 0) return;
38f6b3eReturn to live view when playback endsJeremy Magland 423 liveBeforePlay = live;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 424 const offset = live ? 0 : scrubFrame / sim.fps;
425 live = false;
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 426 player.play(sim.processedAudio(), sim.fs, offset, levelSel.value as LevelMode, gainDb());
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 427 btnPlay.textContent = "Stop";
428 updateLiveButton();
429 });
38f6b3eReturn to live view when playback endsJeremy Magland 430 player.onEnded = endPlayback;
431 scrub.addEventListener("input", () => { liveBeforePlay = false; });
432 btnLive.addEventListener("click", () => { liveBeforePlay = true; });
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 433 btnWav.addEventListener("click", () => {
434 if (!sim || sim.samples === 0) return;
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 435 const blob = encodeWav(sim.processedAudio(), sim.fs, levelSel.value as LevelMode, gainDb());
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 436 const a = document.createElement("a");
437 a.href = URL.createObjectURL(blob);
438 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
439 a.click();
440 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
441 });
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 442 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
b9a5a7eSelf-equilibrated release force and loudness normalizationJeremy Magland 443 const o = document.createElement("option");
444 o.value = v; o.textContent = t;
445 levelSel.appendChild(o);
446 }
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 447 gainSlider.type = "range";
448 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
449 gainSlider.style.width = "120px";
450 gainSlider.style.flex = "none";
451 const restart = () => {
452 updateLevelReadout();
453 const pos = player.position();
454 if (pos !== null && sim) player.play(sim.processedAudio(), sim.fs, pos, levelSel.value as LevelMode, gainDb());
455 };
456 gainSlider.addEventListener("input", restart);
457 levelSel.addEventListener("change", restart);
458 tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
459 updateLevelReadout();
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 460 updateLiveButton();
463function updateLiveButton(): void {
464 btnLive.classList.toggle("on", live);
465 btnLive.disabled = live;
468function buildSpecToolbar(): void {
469 const tb = $("specToolbar");
470 const win = document.createElement("select");
471 for (const n of [512, 1024, 2048, 4096, 8192]) {
472 const o = document.createElement("option");
473 o.value = String(n); o.textContent = String(n);
474 win.appendChild(o);
475 }
476 win.value = "2048";
477 const hop = numInput(512, 64);
6f03b39Spectrogram default range 5 kHzJeremy Magland 478 const fmax = numInput(5000, 500);
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 479 const floor = numInput(80, 5);
480 const logf = document.createElement("input");
481 logf.type = "checkbox";
482 const apply = () => {
483 if (!sim) return;
484 spec.configure(
485 { 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 },
486 sim.processedAudio(), sim.samples,
487 );
488 };
489 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
490 tb.append(
491 document.createTextNode("Spectrogram"),
492 labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf),
493 );
496function numInput(v: number, step: number): HTMLInputElement {
497 const i = document.createElement("input");
498 i.type = "number"; i.value = String(v); i.step = String(step);
499 return i;
501function labeled(text: string, el: HTMLElement): HTMLLabelElement {
502 const l = document.createElement("label");
503 l.append(text, el);
504 return l;
507// ---------- Simulation control ----------
509async function start(): Promise<void> {
510 if (!device) return;
511 player.stop();
512 running = false;
513 await new Promise((r) => setTimeout(r, 0));
514 sim?.destroy();
515 spec.reset();
516 live = true;
517 paused = false;
518 btnPause.textContent = "Pause";
519 params.duration = duration;
520 sim = new Simulation(device, params);
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 521 renderer = new Renderer(device, $("view"), sim.grid, params, sim.w0, sim.nw);
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 522 movieBuf?.destroy();
523 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
524 movieBufFrame = -1;
525 scrub.max = "0"; scrub.value = "0";
526 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
527 setFormEnabled(false);
528 updateLiveButton();
529 void runLoop();
532async function runLoop(): Promise<void> {
533 if (!sim || running) return;
534 running = true;
535 updateButtons();
536 try {
537 while (sim.phase !== "done" && !paused) {
538 await sim.runBatch();
539 const audio = sim.processedAudio();
540 spec.update(audio, sim.samples);
541 scrub.max = String(Math.max(0, sim.frames.length - 1));
542 if (live) scrub.value = scrub.max;
de24345Absolute playback level with gain slider and peak readoutJeremy Magland 543 updateLevelReadout();
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 544 const now = performance.now();
545 if (now - lastStatsTime > 500) {
546 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
547 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
548 }
549 }
550 } catch (e) {
551 console.error(e);
552 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
553 }
554 running = false;
555 if (sim.phase === "done") setFormEnabled(true);
556 updateButtons();
559// ---------- Render loop ----------
561let frameErrorShown = false;
562function frame(): void {
563 requestAnimationFrame(frame);
564 try {
565 frameBody();
566 } catch (e) {
567 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
568 }
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 571let lastRenderKey = "";
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 572function frameBody(): void {
573 if (!device || !sim || !renderer) return;
574 const dev = device;
575 renderer.mode = viewMode.value;
576 renderer.exaggeration = parseFloat(exagInput.value) || 1;
577 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
8dcc11fInitial-shape imperfection (Marguerre shallow-shell strains)Jeremy Magland 578 renderer.showShape = shapeCheck.checked;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 579 const pos = player.position();
580 if (pos !== null) {
581 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
582 scrub.value = String(scrubFrame);
583 }
584 const st = sim.status();
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 585 // Render only when something visible changed, so an idle or paused page does no work.
586 const canvas = $<HTMLCanvasElement>("view");
587 const cam = renderer.camera;
588 const renderKey = [
589 renderer.mode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
590 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
591 live, scrubFrame, sim.frames.length, st.totalSteps, st.phase,
592 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
593 ].join("|");
594 if (renderKey !== lastRenderKey) {
595 lastRenderKey = renderKey;
596 renderView(dev, st);
597 }
598 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
599 updateInfo(st);
602function renderView(dev: GPUDevice, st: ReturnType<Simulation["status"]>): void {
603 if (!sim || !renderer) return;
604 const enc = dev.createCommandEncoder();
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 605 if (live || sim.frames.length === 0) {
606 sim.encodeDisplay(enc);
607 renderer.render(enc, sim.displayW, st.wmax);
608 timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`;
609 } else {
610 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
611 if (movieBufFrame !== f) {
612 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
613 movieBufFrame = f;
614 }
615 let wmax = 0;
616 const fr = sim.frames[f];
617 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
618 renderer.render(enc, movieBuf!, wmax);
619 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
620 }
621 dev.pushErrorScope("validation");
622 dev.queue.submit([enc.finish()]);
623 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
626function updateInfo(st: ReturnType<Simulation["status"]>): void {
627 if (!sim) return;
628 const g = sim.grid;
629 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
630 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 631 const stickHook = (window as unknown as { steelsim?: { stick?: unknown } }).steelsim;
632 if (stickHook) stickHook.stick = st.stick;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 633 const rows: [string, string][] = [
634 ["Phase", phase],
635 ["Time", `${st.t.toFixed(3)} s`],
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 636 ["Grid", `${g.nx} × ${g.ny} nodes${g.mask ? ` (${countInside(g)} in the sheet)` : ""}, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 637 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
638 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
639 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
640 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
641 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
642 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
643 ];
645 rows.push(["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`]);
646 rows.push(["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`]);
647 }
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 648 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
649 if (st.delayClamped) html += `<div class="warn">Listener is so close that some propagation delays were clamped.</div>`;
650 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 651 if (html !== lastInfoHtml) {
652 lastInfoHtml = html;
653 $("info").innerHTML = html;
654 }
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 656let lastInfoHtml = "";
658// ---------- Init ----------
660async function init(): Promise<void> {
661 buildForm();
662 buildControls();
663 buildViewToolbar();
664 buildMovieToolbar();
665 buildSpecToolbar();
666 if (!("gpu" in navigator)) {
667 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge (or Firefox / Safari with WebGPU enabled).");
668 return;
669 }
b640535CLI: Chrome engine (Playwright) as default, Dawn optional; gallery fixesJeremy Magland 670 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 671 if (!adapter) {
672 showNoGpu("No WebGPU adapter was found.");
673 return;
674 }
675 device = await adapter.requestDevice();
676 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
677 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
678 const g = makeGrid(params);
679 $("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>`;
680 requestAnimationFrame(frame);
681 applyUrlParams();
684/** URL parameters: any PhysParams key in SI units, plus autostart=1. Exposes the simulation on window for tests. */
685function applyUrlParams(): void {
686 const q = new URLSearchParams(location.search);
687 for (const [k, v] of q) {
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 688 if (k in params && k !== "radiation" && k !== "bowShape" && k !== "excitation" && k !== "shape") {
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 689 const num = parseFloat(v);
690 if (isFinite(num)) {
691 (params as unknown as Record<string, number>)[k] = num;
692 const inp = inputs.get(k as keyof PhysParams);
693 const spec = groups.flatMap((g) => g.fields).find((f) => f.key === k);
694 if (inp && spec) (inp as HTMLInputElement).value = fmt(num / spec.scale);
695 }
696 }
697 if (k === "radiation" && (v === "rayleigh" || v === "dipole")) params.radiation = v;
10998acDrumstick strike excitation (Hertz contact with a free mass)Jeremy Magland 698 if (k === "excitation" && (v === "push" || v === "strike")) {
699 params.excitation = v;
700 (inputs.get("excitation") as HTMLSelectElement).value = v;
701 }
8dcc11fInitial-shape imperfection (Marguerre shallow-shell strains)Jeremy Magland 702 if (k === "bowShape" && ["none", "width", "height", "dish"].includes(v)) {
703 params.bowShape = v as BowShape;
704 (inputs.get("bowShape") as HTMLSelectElement).value = v;
705 }
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 706 if (k === "shape" && (v === "rectangle" || v === "ellipse")) {
707 params.shape = v;
708 (inputs.get("shape") as HTMLSelectElement).value = v;
709 }
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 710 if (k === "duration") {
711 duration = params.duration;
712 durInput.value = String(duration);
713 }
714 }
715 updateDampingNote();
c63c76aAdd elliptical sheet shape on a masked gridJeremy Magland 716 updateGridNote();
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 717 (window as unknown as { steelsim: unknown }).steelsim = {
718 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
719 audio: () => (sim ? Array.from(sim.processedAudio()) : null),
720 frame: (k: number) => (sim ? Array.from(sim.frames[k] ?? []) : null),
721 frames: () => sim?.frames.length ?? 0,
722 state: async () => { if (!sim) return null; const st = await sim.readState(); return { pos: Array.from(st.pos), vel: Array.from(st.vel) }; },
723 pause: () => { paused = true; },
724 canvasInfo: () => { const c = $<HTMLCanvasElement>("view"); return { cw: c.clientWidth, ch: c.clientHeight, w: c.width, h: c.height, mode: viewMode.value }; },
725 setView: (m: ViewMode) => { viewMode.value = m; },
726 scrub: (k: number) => { live = false; scrubFrame = k; scrub.value = String(k); updateLiveButton(); },
727 };
728 if (q.get("autostart") === "1") void start();
731function showNoGpu(msg: string): void {
732 const el = $("nogpu");
733 el.hidden = false;
734 el.textContent = msg;
735 btnStart.disabled = true;
738void init();
↑↓ move↵openescclose