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