1// Application: design editor, parameter form, run loop, live and movie
2// views, spectrogram, playback, WAV download, and the test hooks on
3// window.steelsim.
5import { type Plate, type SimParams, countInside, defaultParams, massProperties, mountPatch, nodeWeights, strikePoint, thicknessRange } from "./params.ts";
6import { DesignError, evaluatePlate, profileStats } from "./design.ts";
7import { PRESETS } from "./presets.ts";
8import { Simulation, type SimStatus } from "./sim/simulation.ts";
9import { Renderer, type ColorMode, type ViewMode } from "./render/renderer.ts";
10import { Spectrogram } from "./audio/spectrogram.ts";
11import { Player } from "./audio/player.ts";
12import { encodeWav } from "./audio/wav.ts";
13import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
14import { ParamForm } from "./ui/form.ts";
15import { DesignEditor } from "./ui/editor.ts";
16import { runSelfTest, selfTestPassed, type SelfTestCase } from "./selftest.ts";
18const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
20/** Slope above which the shallow-shell treatment of the profile is doubtful. */
21const SLOPE_LIMIT = 0.1;
23// ---------- State ----------
25const params: SimParams = { ...defaultParams };
26let form: ParamForm;
27let editor: DesignEditor;
28let device: GPUDevice | null = null;
29let sim: Simulation | null = null;
30let renderer: Renderer | null = null;
31let running = false;
32let paused = false;
33let live = true;
34let scrubFrame = 0;
35let movieBuf: GPUBuffer | null = null;
36let movieBufFrame = -1;
37/** The design evaluated at the current spacing, or null while the script has an error. */
38let plate: Plate | null = null;
39let plateError: string | null = null;
40/** Show the design (its stress-free shape) instead of the simulation. */
41let previewMode = true;
42let preview: { plate: Plate; w0: GPUBuffer; zero: GPUBuffer; nw: GPUBuffer; hn: GPUBuffer; wmax: number; stale: boolean } | null = null;
43const spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false });
44const player = new Player();
45let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0;
46let duration = params.duration;
48// ---------- Controls ----------
50const btnStart = mkButton("Start", "primary");
51const btnPause = mkButton("Pause");
52const btnStop = mkButton("Stop");
53const btnContinue = mkButton("Continue");
54const durInput = document.createElement("input");
56function mkButton(text: string, cls = ""): HTMLButtonElement {
57 const b = document.createElement("button");
58 b.textContent = text;
59 b.className = cls;
60 return b;
61}
63function buildControls(): void {
64 const c = $("controls");
65 const durRow = document.createElement("div");
66 durRow.className = "field";
67 durRow.style.width = "100%";
68 const l = document.createElement("label");
69 l.textContent = "Recorded duration";
70 durInput.type = "number";
71 durInput.step = "0.5";
72 durInput.min = "0.1";
73 durInput.value = String(duration);
74 durInput.addEventListener("change", () => {
75 const v = parseFloat(durInput.value);
76 if (isFinite(v) && v > 0) {
77 duration = v;
78 params.duration = v;
79 sim?.setDuration(v);
80 form.updateNotes();
81 updateButtons();
82 }
83 });
84 const u = document.createElement("span");
85 u.className = "unit";
86 u.textContent = "s";
87 durRow.append(l, durInput, u);
88 c.append(durRow, btnStart, btnPause, btnStop, btnContinue);
89 btnStart.addEventListener("click", () => void start());
90 btnPause.addEventListener("click", () => {
91 paused = !paused;
92 btnPause.textContent = paused ? "Resume" : "Pause";
93 if (!paused) void runLoop();
94 });
95 btnStop.addEventListener("click", () => {
96 if (!sim) return;
97 sim.stop();
98 paused = false;
99 btnPause.textContent = "Pause";
100 if (!running) {
101 setEditable(true);
102 updateButtons();
103 }
104 });
105 btnContinue.addEventListener("click", () => {
106 if (!sim) return;
107 sim.setDuration(duration);
108 if (sim.phase !== "done") void runLoop();
109 updateButtons();
110 });
111 updateButtons();
112}
114function updateButtons(): void {
115 const s = sim?.status();
116 btnStart.disabled = !device || !plate;
117 btnPause.disabled = !sim || s?.phase === "done" || (!running && !paused);
118 btnStop.disabled = !sim || s?.phase === "done";
119 btnContinue.disabled = !sim || running || !sim.canContinue();
120}
122function setEditable(enabled: boolean): void {
123 form.setEnabled(enabled);
124 editor.setEnabled(enabled);
125}
127// ---------- View toolbars ----------
129const viewMode = { value: "2d" as ViewMode };
130const colorSel = document.createElement("select");
131const scrub = document.createElement("input");
132const timeLabel = document.createElement("span");
133const btnLive = mkButton("Live");
134const btnDesign = mkButton("Design");
135const btnPlay = mkButton("Play");
136const btnWav = mkButton("Download .wav");
137const levelSel = document.createElement("select");
138const gainSlider = document.createElement("input");
139const gainLabel = document.createElement("span");
140const peakLabel = document.createElement("span");
141let exagInput: HTMLInputElement;
142let rangeInput: HTMLInputElement;
143let shapeCheck: HTMLInputElement;
145function gainDb(): number {
146 return parseFloat(gainSlider.value) || 0;
147}
148function levelMode(): LevelMode {
149 return levelSel.value as LevelMode;
150}
152function updateLevelReadout(): void {
153 gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
154 gainSlider.disabled = levelMode() !== "absolute";
155 if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
156 const audio = sim.recordedAudio();
157 const db = peakDbfs(audio, levelGain(audio, sim.fs, levelMode(), gainDb()));
158 peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS${db > 0 ? " (clipped)" : ""}` : "";
159 peakLabel.className = db > 0 ? "warn" : "sub";
160 peakLabel.style.margin = "0";
161}
163function buildViewToolbar(): void {
164 const tb = $("viewToolbar");
165 const seg = document.createElement("span");
166 seg.className = "seg";
167 const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
168 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
169 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
170 seg.append(b2, b3);
171 for (const [v, t] of [["field", "Height / displacement"], ["thickness", "Thickness"]]) {
172 const o = document.createElement("option");
173 o.value = v; o.textContent = t;
174 colorSel.appendChild(o);
175 }
176 exagInput = numInput(1, 0.5);
177 rangeInput = numInput(0, 0.5);
178 shapeCheck = document.createElement("input");
179 shapeCheck.type = "checkbox";
180 shapeCheck.checked = true;
181 shapeCheck.title = "Show the total shape (design profile plus vibration); unticked, only the vibration";
182 const hint = document.createElement("span");
183 hint.className = "sub";
184 hint.style.margin = "0";
185 hint.textContent = "3D: drag to orbit, wheel to zoom";
186 tb.append(seg, labeled("Color", colorSel), labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("Show profile", shapeCheck), hint);
187}
189function buildMovieToolbar(): void {
190 const tb = $("movieToolbar");
191 scrub.type = "range";
192 scrub.min = "0"; scrub.max = "0"; scrub.value = "0";
193 timeLabel.className = "time";
194 // Playback drives the movie; afterwards the view returns to where it was (live or scrubbed).
195 let liveBeforePlay = true;
196 const endPlayback = () => {
197 btnPlay.textContent = "Play";
198 if (liveBeforePlay) {
199 live = true;
200 updateLiveButton();
201 }
202 };
203 scrub.addEventListener("input", () => {
204 live = false;
205 liveBeforePlay = false;
206 previewMode = false;
207 scrubFrame = parseInt(scrub.value);
208 if (player.playing) player.stop();
209 updateLiveButton();
210 });
211 btnLive.addEventListener("click", () => { live = true; liveBeforePlay = true; previewMode = false; updateLiveButton(); });
212 btnDesign.title = "Show the design's stress-free shape instead of the simulation";
213 btnDesign.addEventListener("click", () => { showPreview(); updateLiveButton(); });
214 btnPlay.addEventListener("click", () => {
215 if (player.playing) { player.stop(); endPlayback(); return; }
216 if (!sim || sim.samples === 0) return;
217 liveBeforePlay = live;
218 const offset = live ? 0 : scrubFrame / sim.fps;
219 live = false;
220 previewMode = false;
221 player.play(sim.recordedAudio(), sim.fs, offset, levelMode(), gainDb());
222 btnPlay.textContent = "Stop";
223 updateLiveButton();
224 });
225 player.onEnded = endPlayback;
226 btnWav.addEventListener("click", () => {
227 if (!sim || sim.samples === 0) return;
228 const blob = encodeWav(sim.recordedAudio(), sim.fs, levelMode(), gainDb());
229 const a = document.createElement("a");
230 a.href = URL.createObjectURL(blob);
231 const name = sim.plate.name.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase() || "cymbal";
232 a.download = `${name}_${(sim.samples / sim.fs).toFixed(1)}s_${sim.fs}Hz.wav`;
233 a.click();
234 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
235 });
236 for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
237 const o = document.createElement("option");
238 o.value = v; o.textContent = t;
239 levelSel.appendChild(o);
240 }
241 gainSlider.type = "range";
242 gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
243 gainSlider.style.width = "120px";
244 gainSlider.style.flex = "none";
245 const restart = () => {
246 updateLevelReadout();
247 const pos = player.position();
248 if (pos !== null && sim) player.play(sim.recordedAudio(), sim.fs, pos, levelMode(), gainDb());
249 };
250 gainSlider.addEventListener("input", restart);
251 levelSel.addEventListener("change", restart);
252 tb.append(btnPlay, btnLive, btnDesign, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
253 updateLevelReadout();
254 updateLiveButton();
255}
257function updateLiveButton(): void {
258 btnLive.classList.toggle("on", live && !previewMode);
259 btnLive.disabled = !sim || (live && !previewMode);
260 btnDesign.classList.toggle("on", previewMode);
261 btnDesign.disabled = previewMode;
262}
264function buildSpecToolbar(): void {
265 const tb = $("specToolbar");
266 const win = document.createElement("select");
267 for (const n of [512, 1024, 2048, 4096, 8192]) {
268 const o = document.createElement("option");
269 o.value = String(n); o.textContent = String(n);
270 win.appendChild(o);
271 }
272 win.value = "2048";
273 const hop = numInput(512, 64);
274 const fmax = numInput(5000, 500);
275 const floor = numInput(80, 5);
276 const logf = document.createElement("input");
277 logf.type = "checkbox";
278 const apply = () => {
279 if (!sim) return;
280 spec.configure(
281 { 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 },
282 sim.recordedAudio(), sim.samples,
283 );
284 };
285 for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply);
286 tb.append(document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf));
287}
289function numInput(v: number, step: number): HTMLInputElement {
290 const i = document.createElement("input");
291 i.type = "number"; i.value = String(v); i.step = String(step);
292 return i;
293}
294function labeled(text: string, el: HTMLElement): HTMLLabelElement {
295 const l = document.createElement("label");
296 l.append(text, el);
297 return l;
298}
300// ---------- Design ----------
302/** Re-evaluates the current design at the current spacing and refreshes everything that depends on it. */
303function rebuildPlate(): void {
304 const design = editor.design;
305 plate = null;
306 plateError = editor.error;
307 if (design) {
308 try {
309 plate = evaluatePlate(design, params.spacing);
310 } catch (e) {
311 plateError = e instanceof DesignError ? e.message : String(e);
312 }
313 }
314 form.setPlate(plate);
315 editor.setStatus(plateError, plate ? designSummary(plate) : "");
316 showPreview();
317 updateButtons();
318}
320function fmtMm(v: number): string {
321 return `${(v * 1e3).toFixed(2)} mm`;
322}
324function designSummary(pl: Plate): string {
325 const g = pl.grid;
326 const hr = thicknessRange(pl);
327 const { mass } = massProperties(pl);
328 const { peakToPeak, maxSlope } = profileStats(pl);
329 const rows: [string, string][] = [
330 ["Design", pl.name],
331 ["Bounding box", `${fmtMm(pl.Lx)} × ${fmtMm(pl.Ly)} (${(pl.Lx / 0.0254).toFixed(1)} × ${(pl.Ly / 0.0254).toFixed(1)} in)`],
332 ["Material", `E ${(pl.mat.E / 1e9).toFixed(0)} GPa, ν ${pl.mat.nu}, ρ ${pl.mat.rho} kg/m³`],
333 ["Thickness", hr.min === hr.max ? fmtMm(hr.min) : `${fmtMm(hr.min)} to ${fmtMm(hr.max)} (mean ${fmtMm(hr.mean)})`],
334 ["Mass", `${(mass * 1e3).toFixed(0)} g`],
335 ["Grid", `${g.nx} × ${g.ny}, ${countInside(g)} nodes in the plate`],
336 ["Profile", peakToPeak > 0 ? `${fmtMm(peakToPeak)} peak to peak, largest slope ${maxSlope.toFixed(3)}` : "flat"],
337 ];
338 if (pl.mount) {
339 const mp = mountPatch(pl);
340 rows.push(["Mount", `${mp?.nodes.length ?? 0} nodes within ${fmtMm(pl.mount.radius)} of (${fmtMm(pl.mount.x)}, ${fmtMm(pl.mount.y)}); ${pl.mount.stiffness.toExponential(1)} N/m, ${pl.mount.damping} N s/m`]);
341 } else {
342 rows.push(["Mount", "none (free plate, inertia relief)"]);
343 }
344 rows.push(["Losses", `σ₀ ${pl.loss.sigma0} 1/s, σ₁ ${pl.loss.sigma1} m²/s, in-plane ${pl.loss.sigmaIn} 1/s`]);
345 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
346 if (maxSlope > SLOPE_LIMIT) html += `<div class="warn">The largest slope of the profile, ${maxSlope.toFixed(2)}, is beyond the shallow-shell range of about ${SLOPE_LIMIT}; the bell or the bow is steeper than the model treats accurately.</div>`;
347 return html;
348}
350// ---------- Simulation control ----------
352async function start(): Promise<void> {
353 if (!device || !plate) return;
354 player.stop();
355 running = false;
356 await new Promise((r) => setTimeout(r, 0));
357 sim?.destroy();
358 spec.reset();
359 live = true;
360 paused = false;
361 btnPause.textContent = "Pause";
362 params.duration = duration;
363 try {
364 sim = new Simulation(device, plate, params);
365 } catch (e) {
366 $("info").innerHTML = `<div class="err">Could not start: ${String(e)}</div>`;
367 return;
368 }
369 const hr = thicknessRange(plate);
370 renderer?.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
371 previewMode = false;
372 movieBuf?.destroy();
373 movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
374 movieBufFrame = -1;
375 scrub.max = "0"; scrub.value = "0";
376 lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0;
377 setEditable(false);
378 updateLiveButton();
379 void runLoop();
380}
382async function runLoop(): Promise<void> {
383 if (!sim || running) return;
384 running = true;
385 updateButtons();
386 try {
387 while (sim.phase !== "done" && !paused) {
388 await sim.runBatch();
389 spec.update(sim.recordedAudio(), sim.samples);
390 scrub.max = String(Math.max(0, sim.frames.length - 1));
391 if (live) scrub.value = scrub.max;
392 updateLevelReadout();
393 const now = performance.now();
394 if (now - lastStatsTime > 500) {
395 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
396 lastStatsTime = now; lastStatsSteps = sim.totalSteps;
397 }
398 }
399 } catch (e) {
400 console.error(e);
401 $("info").innerHTML = `<div class="err">Simulation error: ${String(e)}</div>`;
402 }
403 running = false;
404 if (sim.phase === "done") setEditable(true);
405 updateButtons();
406}
408// ---------- Render loop ----------
410let frameErrorShown = false;
411let lastRenderKey = "";
412let lastInfoHtml = "";
414function frame(): void {
415 requestAnimationFrame(frame);
416 try {
417 frameBody();
418 } catch (e) {
419 if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); }
420 }
421}
423function markers(pl: Plate): void {
424 if (!renderer) return;
425 const [sx, sy] = strikePoint(params, pl);
426 renderer.strike = { x: sx, y: sy, r: params.contactRadius };
427 renderer.mount = pl.mount ? { x: pl.mount.x, y: pl.mount.y, r: pl.mount.radius } : null;
428}
430function frameBody(): void {
431 if (!device || !renderer) return;
432 renderer.mode = viewMode.value;
433 renderer.colorMode = colorSel.value as ColorMode;
434 renderer.exaggeration = parseFloat(exagInput.value) || 1;
435 renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3;
436 renderer.showShape = shapeCheck.checked;
437 const canvas = $<HTMLCanvasElement>("view");
438 const cam = renderer.camera;
439 const viewKey = [
440 renderer.mode, renderer.colorMode, renderer.exaggeration, renderer.colorRange, renderer.showShape,
441 cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4),
442 canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio,
443 params.strikeFracX, params.strikeFracY, params.contactRadius,
444 ];
445 if (previewMode || !sim) {
446 // The stress-free shape of the design that the next Start will use.
447 ensurePreview(device);
448 const renderKey = ["preview", ...viewKey, preview?.plate.name, preview?.plate.grid.n].join("|");
449 if (renderKey !== lastRenderKey) {
450 lastRenderKey = renderKey;
451 renderPreview(device);
452 }
453 if (sim) {
454 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), null);
455 updateInfo(sim.status());
456 }
457 return;
458 }
459 const pos = player.position();
460 if (pos !== null) {
461 scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps));
462 scrub.value = String(scrubFrame);
463 }
464 const st = sim.status();
465 // Render only when something visible changed, so an idle or paused page does no work.
466 const renderKey = [...viewKey, live, scrubFrame, sim.frames.length, st.totalSteps, st.phase].join("|");
467 if (renderKey !== lastRenderKey) {
468 lastRenderKey = renderKey;
469 renderView(device, st);
470 }
471 spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps));
472 updateInfo(st);
473}
475/** Builds the preview buffers from the current plate when they are missing or stale. */
476function ensurePreview(dev: GPUDevice): void {
477 if (preview && !preview.stale) return;
478 if (!plate) return;
479 if (preview) for (const b of [preview.w0, preview.zero, preview.nw, preview.hn]) b.destroy();
480 const grid = plate.grid;
481 const shape = Float32Array.from(plate.w0);
482 let wmax = 0;
483 for (let k = 0; k < shape.length; k++) wmax = Math.max(wmax, Math.abs(shape[k]));
484 const mk = (data: Float32Array<ArrayBuffer>) => {
485 const b = dev.createBuffer({ size: Math.max(16, data.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
486 dev.queue.writeBuffer(b, 0, data);
487 return b;
488 };
489 preview = { plate, w0: mk(shape), zero: mk(new Float32Array(grid.n)), nw: mk(nodeWeights(grid)), hn: mk(Float32Array.from(plate.h)), wmax, stale: false };
490 const hr = thicknessRange(plate);
491 renderer?.setField(grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
492 lastRenderKey = "";
493}
495/** Marks the preview stale (the design or the spacing changed) and shows it; the next frame rebuilds it. */
496function showPreview(): void {
497 if (preview) preview.stale = true;
498 previewMode = true;
499 updateLiveButton();
500 if (!sim) updateIdleInfo();
501}
503function renderPreview(dev: GPUDevice): void {
504 if (!renderer || !preview) return;
505 // The renderer's "initial shape" buffer is zero here, and the shape itself is the displayed field.
506 if (renderer.fieldGrid !== preview.plate.grid) {
507 const hr = thicknessRange(preview.plate);
508 renderer.setField(preview.plate.grid, preview.zero, preview.nw, preview.hn, 0, [hr.min, hr.max]);
509 }
510 markers(preview.plate);
511 const enc = dev.createCommandEncoder();
512 renderer.render(enc, preview.w0, preview.wmax);
513 timeLabel.textContent = "design";
514 dev.pushErrorScope("validation");
515 dev.queue.submit([enc.finish()]);
516 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
517}
519function updateIdleInfo(): void {
520 const html = plate
521 ? `<p class="sub">The view shows the design's stress-free shape. Press Start to strike it.</p>`
522 : `<p class="err">${plateError ?? "No design."}</p>`;
523 if (html !== lastInfoHtml) {
524 lastInfoHtml = html;
525 $("info").innerHTML = html;
526 }
527}
529function renderView(dev: GPUDevice, st: SimStatus): void {
530 if (!sim || !renderer) return;
531 if (renderer.fieldGrid !== sim.grid) {
532 const hr = thicknessRange(sim.plate);
533 renderer.setField(sim.grid, sim.w0, sim.nw, sim.hn, sim.w0Max, [hr.min, hr.max]);
534 }
535 markers(sim.plate);
536 const enc = dev.createCommandEncoder();
537 if (live || sim.frames.length === 0) {
538 sim.encodeDisplay(enc);
539 renderer.render(enc, sim.displayW, st.wmax);
540 timeLabel.textContent = `${st.t.toFixed(3)} s`;
541 } else {
542 const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame));
543 if (movieBufFrame !== f) {
544 dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]);
545 movieBufFrame = f;
546 }
547 let wmax = 0;
548 const fr = sim.frames[f];
549 for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k]));
550 renderer.render(enc, movieBuf!, wmax);
551 timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`;
552 }
553 dev.pushErrorScope("validation");
554 dev.queue.submit([enc.finish()]);
555 void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } });
556}
558function updateInfo(st: SimStatus): void {
559 if (!sim) return;
560 const g = sim.grid;
561 const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity;
562 const rows: [string, string][] = [
563 ["Design", sim.plate.name],
564 ["Phase", st.phase === "done" ? "done" : "ringing (recording)"],
565 ["Time", `${st.t.toFixed(3)} s`],
566 ["Grid", `${g.nx} × ${g.ny} nodes (${countInside(g)} in the plate), ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`],
567 ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`],
568 ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`],
569 ["Kinetic energy", `${st.ke.toExponential(2)} J`],
570 ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`],
571 ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`],
572 ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"],
573 ["Stick", `${(st.stick.x * 1e3).toFixed(2)} mm, ${st.stick.v.toFixed(2)} m/s`],
574 ["Contact force", `${st.stick.force.toFixed(1)} N (max ${st.stick.maxForce.toFixed(1)} N)`],
575 ];
576 let html = rows.map(([a, b]) => `<div class="row"><span>${a}</span><span>${b}</span></div>`).join("");
577 if (st.delayClamped) html += `<div class="warn">The listener is so close that some propagation delays were clamped.</div>`;
578 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 strike.</div>`;
579 if (html !== lastInfoHtml) {
580 lastInfoHtml = html;
581 $("info").innerHTML = html;
582 }
583}
585// ---------- Self-test ----------
587let selfTestResult: { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string } = { done: false };
589async function selfTest(): Promise<void> {
590 if (!device) { selfTestResult = { done: true, passed: false, error: "no GPU device" }; return; }
591 $("info").innerHTML = `<p class="sub">Running the GPU-versus-CPU self-test...</p>`;
592 try {
593 const cases = await runSelfTest(device);
594 const passed = cases.every(selfTestPassed);
595 selfTestResult = { done: true, passed, cases };
596 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>`);
597 $("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>`;
598 } catch (e) {
599 selfTestResult = { done: true, passed: false, error: String(e) };
600 $("info").innerHTML = `<div class="err">Self-test error: ${String(e)}</div>`;
601 }
602}
604// ---------- Init ----------
606async function init(): Promise<void> {
607 editor = new DesignEditor($("design"));
608 form = new ParamForm($("params"), params);
609 form.onChange = (key, isLive) => {
610 if (key === "spacing") { rebuildPlate(); return; }
611 if (!isLive) { showPreview(); return; }
612 if (!sim) return;
613 if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
614 };
615 editor.onDesign = () => rebuildPlate();
616 buildControls();
617 buildViewToolbar();
618 buildMovieToolbar();
619 buildSpecToolbar();
620 const q = new URLSearchParams(location.search);
621 const set = form.applyUrl(q);
622 if (set.includes("duration")) {
623 duration = params.duration;
624 durInput.value = String(duration);
625 }
626 const presetId = q.get("preset");
627 const preset = presetId ? PRESETS.find((p) => p.id === presetId) : null;
628 if (preset) editor.setScript(preset.source, preset.id);
629 else editor.compileNow();
630 exposeHooks();
631 if (!("gpu" in navigator)) {
632 showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge, or Firefox or Safari with WebGPU enabled.");
633 return;
634 }
635 const adapter = (await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })) ?? (await navigator.gpu.requestAdapter());
636 if (!adapter) {
637 showNoGpu("No WebGPU adapter was found.");
638 return;
639 }
640 device = await adapter.requestDevice();
641 device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`));
642 device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message));
643 renderer = new Renderer(device, $("view"));
644 updateIdleInfo();
645 updateButtons();
646 requestAnimationFrame(frame);
647 if (q.get("selftest") === "1") await selfTest();
648 else if (q.get("autostart") === "1") void start();
649}
651/** Hooks for automated tests, on window.steelsim. */
652function exposeHooks(): void {
653 (window as unknown as { steelsim: unknown }).steelsim = {
654 status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null),
655 audio: () => (sim ? Array.from(sim.recordedAudio()) : null),
656 frames: () => sim?.frames.length ?? 0,
657 design: () => (plate ? { name: plate.name, nodes: countInside(plate.grid), mounted: !!plate.mount, error: null } : { name: null, nodes: 0, mounted: false, error: plateError }),
658 setScript: (source: string) => editor.setScript(source),
659 start: () => void start(),
660 selftest: () => selfTestResult,
661 noGpu: () => { const e = $("nogpu"); return e.hidden ? null : e.textContent; },
662 };
663}
665function showNoGpu(msg: string): void {
666 const el = $("nogpu");
667 el.hidden = false;
668 el.textContent = msg;
669 btnStart.disabled = true;
670 selfTestResult = { done: true, passed: false, error: msg };
671}
673void init();