d8f9e54Clean rebuild of the steel sheet simulatorJeremy Magland 1// Application: parameter form, run loop, live and movie views, spectrogram,
2// playback, WAV download, and the test hooks on window.steelsim.
4import { defaultParams, type PhysParams } from "./params.ts";
5import { Simulation, type SimStatus } from "./sim/simulation.ts";
6import { Renderer, type ViewMode } from "./render/renderer.ts";
7import { Spectrogram } from "./audio/spectrogram.ts";
8import { Player } from "./audio/player.ts";
9import { encodeWav } from "./audio/wav.ts";
10import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
11import { ParamForm } from "./ui/form.ts";
12import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts";
14const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
16// ---------- State ----------
18const params: PhysParams = { ...defaultParams };
19let form: ParamForm;
20let device: GPUDevice | null = null;
21let sim: Simulation | null = null;
22let renderer: Renderer | null = null;
23let running = false;
24let paused = false;
25let live = true;
26let scrubFrame = 0;
27let movieBuf: GPUBuffer | null = null;
28let movieBufFrame = -1;
29const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
30const player = new Player();
31let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
32let duration = params.duration;
34// ---------- Controls ----------
36const btnStart = mkButton("Start", "primary");
37const btnPause = mkButton("Pause");
38const btnStop = mkButton("Stop");
39const btnContinue = mkButton("Continue");
40const durInput = document.createElement("input");
42function mkButton(text: string, cls = ""): HTMLButtonElement {
43 const b = document.createElement("button");
44 b.textContent = text;
45 b.className = cls;
46 return b;
47}
49function buildControls(): void {
50 const c = $("controls");
51 const durRow = document.createElement("div");
52 durRow.className = "field";
53 durRow.style.width = "100%";
54 const l = document.createElement("label");
55 l.textContent = "Recorded duration";
56 durInput.type = "number";
57 durInput.step = "0.5";
58 durInput.min = "0.1";
59 durInput.value = String(duration);
60 durInput.addEventListener("change", () => {
61 const v = parseFloat(durInput.value);
62 if (isFinite(v) && v > 0) {
63 duration = v;
64 params.duration = v;
65 sim?.setDuration(v);
66 updateButtons();
67 }
68 });
69 const u = document.createElement("span");
70 u.className = "unit";
71 u.textContent = "s";
72 durRow.append(l, durInput, u);
73 c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
74 btnStart.addEventListener("click", () => void start());
75 btnPause.addEventListener("click", () => {
76 paused = !paused;
77 btnPause.textContent = paused ? "Resume" : "Pause";
78 if (!paused) void runLoop();
79 });
80 btnStop.addEventListener("click", () => {
81 if (!sim) return;
82 sim.stop();
83 paused = false;
84 btnPause.textContent = "Pause";
85 if (!running) {
86 form.setEnabled(true);
87 updateButtons();
88 }
89 });
90 btnContinue.addEventListener("click", () => {
91 if (!sim) return;
92 sim.setDuration(duration);
93 if (sim.phase !== "done") void runLoop();
94 updateButtons();
95 });
96 updateButtons();
97}
99function updateButtons(): void {
100 const s = sim?.status();
101 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
102 btnStop.disabled = !sim || s?.phase === "done";
103 btnContinue.disabled = !sim || running || !sim.canContinue();
104}
106// ---------- View toolbars ----------
108const viewMode = { value: "2d" as ViewMode };
109const scrub = document.createElement("input");
110const timeLabel = document.createElement("span");
111const btnLive = mkButton("Live");
112const btnPlay = mkButton("Play");
113const btnWav = mkButton("Download .wav");
114const levelSel = document.createElement("select");
115const gainSlider = document.createElement("input");
116const gainLabel = document.createElement("span");
117const peakLabel = document.createElement("span");
118let exagInput: HTMLInputElement;
119let rangeInput: HTMLInputElement;
120let shapeCheck: HTMLInputElement;
122function gainDb(): number {
123 return parseFloat(gainSlider.value) || 0;
124}
125function levelMode(): LevelMode {
126 return levelSel.value as LevelMode;
127}
129function updateLevelReadout(): void {
130 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
131 gainSlider.disabled = levelMode() !== "absolute";
132 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
133 const audio = sim.recordedAudio();
134 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb()));
135 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : "";
136 peakLabel.className = db > 0 ? "warn" : "sub";
137 peakLabel.style.margin = "0";
138}
140function buildViewToolbar(): void {
141 const tb = $("viewToolbar");
142 const seg = document.createElement("span");
143 seg.className = "seg";
144 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
145 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
146 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
147 seg.append(b2, b3);
148 exagInput = numInput(4, 0.5);
149 rangeInput = numInput(0, 0.5);
150 shapeCheck = document.createElement("input");
151 shapeCheck.type = "checkbox";
152 const hint = document.createElement("span");
153 hint.className = "sub";
154 hint.style.margin = "0";
155 hint.textContent = "3D: drag to orbit, wheel to zoom";
156 tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("3D: add initial shape", shapeCheck), hint);
157}
159function buildMovieToolbar(): void {
160 const tb = $("movieToolbar");
161 scrub.type = "range";
162 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
163 timeLabel.className = "time";
164 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
165 let liveBeforePlay = true;
166 const endPlayback = () => {
167 btnPlay.textContent = "Play";
168 if (liveBeforePlay) {
169 live = true;
170 updateLiveButton();
171 }
172 };
173 scrub.addEventListener("input", () => {
174 live = false;
175 liveBeforePlay = false;
176 scrubFrame = parseInt(scrub.value);
177 if (player.playing) player.stop();
178 updateLiveButton();
179 });
180 btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; updateLiveButton(); });
181 btnPlay.addEventListener("click", () => {
182 if (player.playing) { player.stop(); endPlayback(); return; }
183 if (!sim || sim.samples === 0) return;
184 liveBeforePlay = live;
185 const offset = live ? 0 : scrubFrame / sim.fps;
186 live = false;
187 player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb());
188 btnPlay.textContent = "Stop";
189 updateLiveButton();
190 });
191 player.onEnded = endPlayback;
192 btnWav.addEventListener("click", () => {
193 if (!sim || sim.samples === 0) return;
194 const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb());
195 const a = document.createElement("a");
196 a.href = URL.createObjectURL(blob);
197 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
198 a.click();
199 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
200 });
201 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
202 const o = document.createElement("option");
203 o.value = v; o.textContent = t;
204 levelSel.appendChild(o);
205 }
206 gainSlider.type = "range";
207 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
208 gainSlider.style.width = "120px";
209 gainSlider.style.flex = "none";
210 const restart = () => {
211 updateLevelReadout();
212 const pos = player.position();
213 if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
214 };
215 gainSlider.addEventListener("input", restart);
216 levelSel.addEventListener("change", restart);
217 tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
218 updateLevelReadout();
219 updateLiveButton();
220}
222function updateLiveButton(): void {
223 btnLive.classList.toggle("on", live);
224 btnLive.disabled = live;
225}
227function buildSpecToolbar(): void {
228 const tb = $("specToolbar");
229 const win = document.createElement("select");
230 for (const n of [512, 1024, 2048, 4096, 8192]) {
231 const o = document.createElement("option");
232 o.value = String(n); o.textContent = String(n);
233 win.appendChild(o);
234 }
235 win.value = "2048";
236 const hop = numInput(512, 64);
237 const fmax = numInput(5000, 500);
238 const floor = numInput(80, 5);
239 const logf = document.createElement("input");
240 logf.type = "checkbox";
241 const apply = () => {
242 if (!sim) return;
243 spec.configure(
244 { 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 },
245 sim.recordedAudio(), sim.samples,
246 );
247 };
248 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
249 tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
250}
252function numInput(v: number, step: number): HTMLInputElement {
253 const i = document.createElement("input");
254 i.type = "number"; i.value = String(v); i.step = String(step);
255 return i;
256}
257function labeled(text: string, el: HTMLElement): HTMLLabelElement {
258 const l = document.createElement("label");
259 l.append(text, el);
260 return l;
261}
263// ---------- Simulation control ----------
265async function start(): Promise<void> {
266 if (!device) return;
267 player.stop();
268 running = false;
269 await new Promise((r) => setTimeout(r, 0));
270 sim?.destroy();
271 spec.reset();
272 live = true;
273 paused = false;
274 btnPause.textContent = "Pause";
275 params.duration = duration;
276 sim = new Simulation(device, params);
277 renderer = new Renderer(device, $("view"), sim.grid, params, sim.w0, sim.nw);
278 movieBuf?.destroy();
279 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
280 movieBufFrame = -1;
281 scrub.max = "0"; scrub.value = "0";
282 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
283 form.setEnabled(false);
284 updateLiveButton();
285 void runLoop();
286}
288async function runLoop(): Promise<void> {
289 if (!sim || running) return;
290 running = true;
291 updateButtons();
292 try {
293 while (sim.phase !== "done" && !paused) {
294 await sim.runBatch();
295 spec.update(sim.recordedAudio(), sim.samples);
296 scrub.max = String(Math.max(0, sim.frames.length - 1));
297 if (live) scrub.value = scrub.max;
298 updateLevelReadout();
299 const now = performance.now();
300 if (now - lastStatsTime > 500) {
301 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
302 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
303 }
304 }
305 } catch (e) {
306 console.error(e);
307 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
308 }
309 running = false;
310 if (sim.phase === "done") form.setEnabled(true);
311 updateButtons();
312}
314// ---------- Render loop ----------
316let frameErrorShown = false;
317let lastRenderKey = "";
318let lastInfoHtml = "";
320function frame(): void {
321 requestAnimationFrame(frame);
322 try {
323 frameBody();
324 } catch (e) {
325 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
326 }
327}
329function frameBody(): void {
330 if (!device || !sim || !renderer) return;
331 renderer.mode = viewMode.value;
332 renderer.exaggeration = parseFloat(exagInput.value) || 1;
333 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
334 renderer.showShape = shapeCheck.checked;
335 const pos = player.position();
336 if (pos !== null) {
337 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
338 scrub.value = String(scrubFrame);
339 }
340 const st = sim.status();
341 // Render only when something visible changed, so an idle or paused page does no work.
342 const canvas = $<HTMLCanvasElement>("view");
343 const cam = renderer.camera;
344 const renderKey = [
345 renderer.mode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
346 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
347 live, scrubFrame, sim.frames.length, st.totalSteps, st.phase,
348 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
349 ].join("|");
350 if (renderKey !== lastRenderKey) {
351 lastRenderKey = renderKey;
352 renderView(device, st);
353 }
354 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
355 updateInfo(st);
356}
358function renderView(dev: GPUDevice, st: SimStatus): void {
359 if (!sim || !renderer) return;
360 const enc = dev.createCommandEncoder();
361 if (live || sim.frames.length === 0) {
362 sim.encodeDisplay(enc);
363 renderer.render(enc, sim.displayW, st.wmax);
364 timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`;
365 } else {
366 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
367 if (movieBufFrame !== f) {
368 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
369 movieBufFrame = f;
370 }
371 let wmax = 0;
372 const fr = sim.frames[f];
373 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
374 renderer.render(enc, movieBuf!, wmax);
375 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
376 }
377 dev.pushErrorScope("validation");
378 dev.queue.submit([enc.finish()]);
379 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
380}
382function updateInfo(st: SimStatus): void {
383 if (!sim) return;
384 const g = sim.grid;
385 const { inside } = form.gridInfo();
386 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
387 const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase];
388 const rows: [string, string][] = [
389 ["Phase", phase],
390 ["Time", `${st.t.toFixed(3)} s`],
391 ["Grid", `${g.nx} × ${g.ny} nodes${g.mask ? ` (${inside} in the sheet)` : ""}, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
392 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
393 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
394 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
395 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
396 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
397 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
398 ];
399 if (st.stick) {
400 rows.push(["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`]);
401 rows.push(["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`]);
402 }
403 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
404 if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
405 if (!isFinite(st.wmax) || st.wmax > 1) html += `<div class="err">The solution has diverged. Increase the grid spacing (which lengthens the time step) or reduce the excitation.</div>`;
406 if (html !== lastInfoHtml) {
407 lastInfoHtml = html;
408 $("info").innerHTML = html;
409 }
410}
412// ---------- Self-test ----------
414let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
416async function selfTest(): Promise<void> {
417 if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
418 $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
419 try {
420 const cases = await runSelfTest(device);
421 const passed = cases.every(selfTestPassed);
422 selfTestResult = { done: true, passed, cases };
423 const rows = cases.map((c) => `<div class="row"><span>${c.name}</span><span class="${selfTestPassed(c) ? "" : "err"}">acc ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, pos ${c.positionError.toExponential(1)}</span></div>`);
424 $("info").innerHTML = `<div class="${passed ? "row" : "err"}">Self-test ${passed ? "passed" : "FAILED"}</div>${rows.join("")}<p class="sub">Maximum relative errors of the u, v, w accelerations and of the compensated w update, GPU step versus float64 reference.</p>`;
425 } catch (e) {
426 selfTestResult = { done: true, passed: false, error: String(e) };
427 $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
428 }
429}
431// ---------- Init ----------
433async function init(): Promise<void> {
434 form = new ParamForm($("params"), params);
435 form.onChange = (key, isLive) => {
436 if (!isLive || !sim) return;
437 if (key === "sigma0" || key === "sigma1" || key === "sigmaIn") sim.setDamping(params.sigma0, params.sigma1, params.sigmaIn);
438 if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
439 };
440 buildControls();
441 buildViewToolbar();
442 buildMovieToolbar();
443 buildSpecToolbar();
444 const q = new URLSearchParams(location.search);
445 const set = form.applyUrl(q);
446 if (set.includes("duration")) {
447 duration = params.duration;
448 durInput.value = String(duration);
449 }
450 exposeHooks();
451 if (!("gpu" in navigator)) {
452 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
453 return;
454 }
455 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
456 if (!adapter) {
457 showNoGpu("No WebGPU adapter was found.");
458 return;
459 }
460 device = await adapter.requestDevice();
461 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
462 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
463 const { grid } = form.gridInfo();
464 $("info").innerHTML = `<div class="row"><span>Grid</span><span>${grid.nx} × ${grid.ny} nodes</span></div><p class="sub">Press Start to run.</p>`;
465 requestAnimationFrame(frame);
466 if (q.get("selftest") === "1") await selfTest();
467 else if (q.get("autostart") === "1") void start();
468}
470/** Hooks for automated tests, on window.steelsim. */
471function exposeHooks(): void {
472 (window as unknown as { steelsim: unknown }).steelsim = {
473 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
474 audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
475 frames: () => sim?.frames.length ?? 0,
476 selftest: () => selfTestResult,
477 noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
478 };
479}
481function showNoGpu(msg: string): void {
482 const el = $("nogpu");
483 el.hidden = false;
484 el.textContent = msg;
485 btnStart.disabled = true;
486 selfTestResult = { done: true, passed: false, error: msg };
487}
489void init();