// UI wiring: parameters, run loop, live and movie views, playback, downloads. import { defaultParams, makeGrid, makeMaterial, t60, type BowShape, type PhysParams } from "./params.ts"; import { Simulation } from "./sim/simulation.ts"; import { Renderer, type ViewMode } from "./render/renderer.ts"; import { Spectrogram } from "./audio/spectrogram.ts"; import { Player } from "./audio/player.ts"; import { encodeWav } from "./audio/wav.ts"; import { type LevelMode } from "./audio/level.ts"; const $ = (id: string) => document.getElementById(id) as T; // ---------- Parameter form ---------- interface FieldSpec { key: keyof PhysParams; label: string; unit: string; scale: number; // UI value = SI value / scale step?: number; live?: boolean; // may be changed while a simulation runs } const groups: { title: string; open: boolean; fields: FieldSpec[] }[] = [ { title: "Plate", open: true, fields: [ { key: "Lx", label: "Width", unit: "in", scale: 0.0254, step: 0.5 }, { key: "Ly", label: "Height", unit: "in", scale: 0.0254, step: 0.5 }, { key: "h", label: "Thickness", unit: "mm", scale: 1e-3, step: 0.01 }, { key: "E", label: "Young's modulus", unit: "GPa", scale: 1e9, step: 1 }, { key: "nu", label: "Poisson ratio", unit: "", scale: 1, step: 0.01 }, { key: "rho", label: "Density", unit: "kg/m³", scale: 1, step: 10 }, ], }, { title: "Excitation", open: true, fields: [ { key: "pushAmount", label: "Push at corner", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 }, { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 }, { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 }, { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 }, { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 }, { key: "holeInset", label: "Hole inset", unit: "mm", scale: 1e-3, step: 1 }, ], }, { title: "Initial shape", open: true, fields: [{ key: "bowAmplitude", label: "Bow (center vs edges)", unit: "mm", scale: 1e-3, step: 0.5 }], }, { title: "Damping", open: true, fields: [ { key: "sigma0", label: "σ₀ (freq. independent)", unit: "1/s", scale: 1, step: 0.1, live: true }, { key: "sigma1", label: "σ₁ (freq. dependent)", unit: "m²/s", scale: 1, step: 1e-5, live: true }, ], }, { title: "Listener", open: true, fields: [ { key: "listenerDist", label: "Distance", unit: "m", scale: 1, step: 0.1, live: true }, { key: "listenerAzimuth", label: "Azimuth", unit: "deg", scale: 1, step: 5, live: true }, { key: "listenerElevation", label: "Elevation", unit: "deg", scale: 1, step: 5, live: true }, ], }, { title: "Numerics", open: false, fields: [ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 }, { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 }, { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 }, { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 }, ], }, ]; const inputs = new Map(); let params: PhysParams = { ...defaultParams }; function buildForm(): void { const root = $("params"); for (const g of groups) { const det = document.createElement("details"); det.open = g.open; const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum); for (const f of g.fields) { const row = document.createElement("div"); row.className = "field"; const label = document.createElement("label"); label.textContent = f.label; const inp = document.createElement("input"); inp.type = "number"; inp.step = String(f.step ?? 1); inp.value = fmt((params[f.key] as number) / f.scale); inp.addEventListener("change", () => onFieldChange(f, inp)); const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = f.unit; row.append(label, inp, unit); det.appendChild(row); inputs.set(f.key, inp); if (f.key === "listenerElevation") { const r2 = document.createElement("div"); r2.className = "field"; const l2 = document.createElement("label"); l2.textContent = "Radiation model"; const sel = document.createElement("select"); for (const [v, t] of [["rayleigh", "Rayleigh (baffled)"], ["dipole", "Dipole (unbaffled, approx.)"]]) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = params.radiation; sel.addEventListener("change", () => { params.radiation = sel.value as PhysParams["radiation"]; applyListener(); }); r2.append(l2, sel, document.createElement("span")); det.appendChild(r2); inputs.set("radiation", sel); } if (f.key === "bowAmplitude") { const r2 = document.createElement("div"); r2.className = "field"; const l2 = document.createElement("label"); l2.textContent = "Bow shape"; const sel = document.createElement("select"); for (const [v, t] of [["width", "Curved across the width"], ["height", "Curved across the height"], ["dish", "Dish (doubly curved)"], ["none", "Flat"]]) { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); } sel.value = params.bowShape; sel.addEventListener("change", () => { params.bowShape = sel.value as BowShape; }); r2.append(l2, sel, document.createElement("span")); det.appendChild(r2); inputs.set("bowShape", sel); const note = document.createElement("p"); note.className = "sub"; 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."; det.appendChild(note); } if (f.key === "sigma1") { const note = document.createElement("p"); note.className = "sub"; note.id = "dampNote"; det.appendChild(note); } } root.appendChild(det); } updateDampingNote(); } function fmt(v: number): string { if (v === 0) return "0"; const a = Math.abs(v); if (a >= 1e5 || a < 1e-3) return v.toExponential(3); return String(+v.toPrecision(6)); } function onFieldChange(f: FieldSpec, inp: HTMLInputElement): void { const v = parseFloat(inp.value); if (!isFinite(v)) return; (params as unknown as Record)[f.key] = v * f.scale; if (f.key === "sigma0" || f.key === "sigma1") { updateDampingNote(); sim?.setDamping(params.sigma0, params.sigma1); } if (f.key.startsWith("listener")) applyListener(); } function applyListener(): void { sim?.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation, params.radiation); } function updateDampingNote(): void { const m = makeMaterial(params); const f = (hz: number) => t60(m, params, 2 * Math.PI * hz).toFixed(2); $("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`; } function setFormEnabled(enabled: boolean): void { for (const g of groups) for (const f of g.fields) if (!f.live) inputs.get(f.key)!.disabled = !enabled; inputs.get("bowShape")!.disabled = !enabled; } // ---------- State ---------- let device: GPUDevice | null = null; let sim: Simulation | null = null; let renderer: Renderer | null = null; let running = false; let paused = false; let live = true; let scrubFrame = 0; let movieBuf: GPUBuffer | null = null; let movieBufFrame = -1; let spec = new Spectrogram({ windowSize: 2048, hop: 512, fmax: 5000, dbFloor: 80, logFrequency: false }); const player = new Player(); let lastStatsTime = 0, lastStatsSteps = 0, stepsPerSec = 0; let duration = params.duration; // ---------- Controls ---------- const btnStart = mkButton("Start", "primary"); const btnPause = mkButton("Pause"); const btnContinue = mkButton("Continue"); const durInput = document.createElement("input"); function mkButton(text: string, cls = ""): HTMLButtonElement { const b = document.createElement("button"); b.textContent = text; b.className = cls; return b; } function buildControls(): void { const c = $("controls"); const durRow = document.createElement("div"); durRow.className = "field"; durRow.style.width = "100%"; const l = document.createElement("label"); l.textContent = "Duration after release"; durInput.type = "number"; durInput.step = "0.5"; durInput.min = "0.1"; durInput.value = String(duration); durInput.addEventListener("change", () => { const v = parseFloat(durInput.value); if (isFinite(v) && v > 0) { duration = v; params.duration = v; sim?.setDuration(v); updateButtons(); } }); const u = document.createElement("span"); u.className = "unit"; u.textContent = "s"; durRow.append(l, durInput, u); c.append(durRow, btnStart, btnPause, btnContinue); btnStart.addEventListener("click", () => void start()); btnPause.addEventListener("click", () => { paused = !paused; btnPause.textContent = paused ? "Resume" : "Pause"; if (!paused) void runLoop(); }); btnContinue.addEventListener("click", () => { if (!sim) return; sim.setDuration(duration); if (sim.phase !== "done") void runLoop(); updateButtons(); }); updateButtons(); } function updateButtons(): void { const s = sim?.status(); btnPause.disabled = !sim || !running || s?.phase === "done"; btnContinue.disabled = !sim || running || !s || s.phase !== "done" || s.samples >= Math.round(duration * params.fs) - 1; if (sim && sim.phase === "done" && sim.samples < sim.targetSamples()) btnContinue.disabled = false; } // ---------- View toolbars ---------- const viewMode = { value: "2d" as ViewMode }; const scrub = document.createElement("input"); const timeLabel = document.createElement("span"); const btnLive = mkButton("Live"); const btnPlay = mkButton("Play"); const btnWav = mkButton("Download .wav"); const levelSel = document.createElement("select"); let exagInput: HTMLInputElement; let rangeInput: HTMLInputElement; let shapeCheck: HTMLInputElement; function buildViewToolbar(): void { const tb = $("viewToolbar"); const seg = document.createElement("span"); seg.className = "seg"; const b2 = mkButton("2D", "on"), b3 = mkButton("3D"); b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); }); b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); }); seg.append(b2, b3); exagInput = numInput(4, 0.5); rangeInput = numInput(0, 0.5); const hint = document.createElement("span"); hint.className = "sub"; hint.style.margin = "0"; hint.textContent = "3D: drag to orbit, wheel to zoom"; shapeCheck = document.createElement("input"); shapeCheck.type = "checkbox"; tb.append(seg, labeled("Exaggeration ×", exagInput), labeled("Color range (mm, 0 = auto)", rangeInput), labeled("3D: add initial shape", shapeCheck), hint); } function buildMovieToolbar(): void { const tb = $("movieToolbar"); scrub.type = "range"; scrub.min = "0"; scrub.max = "0"; scrub.value = "0"; scrub.addEventListener("input", () => { live = false; scrubFrame = parseInt(scrub.value); if (player.playing) player.stop(); updateLiveButton(); }); timeLabel.className = "time"; btnLive.addEventListener("click", () => { live = true; updateLiveButton(); }); btnPlay.addEventListener("click", () => { if (player.playing) { player.stop(); btnPlay.textContent = "Play"; return; } if (!sim || sim.samples === 0) return; const offset = live ? 0 : scrubFrame / sim.fps; live = false; player.play(sim.processedAudio(), sim.fs, offset, levelSel.value as LevelMode); btnPlay.textContent = "Stop"; updateLiveButton(); }); player.onEnded = () => { btnPlay.textContent = "Play"; }; btnWav.addEventListener("click", () => { if (!sim || sim.samples === 0) return; const blob = encodeWav(sim.processedAudio(), sim.fs, levelSel.value as LevelMode); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }); for (const [v, t] of [["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) { const o = document.createElement("option"); o.value = v; o.textContent = t; levelSel.appendChild(o); } tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), btnWav); updateLiveButton(); } function updateLiveButton(): void { btnLive.classList.toggle("on", live); btnLive.disabled = live; } function buildSpecToolbar(): void { const tb = $("specToolbar"); const win = document.createElement("select"); for (const n of [512, 1024, 2048, 4096, 8192]) { const o = document.createElement("option"); o.value = String(n); o.textContent = String(n); win.appendChild(o); } win.value = "2048"; const hop = numInput(512, 64); const fmax = numInput(5000, 500); const floor = numInput(80, 5); const logf = document.createElement("input"); logf.type = "checkbox"; const apply = () => { if (!sim) return; spec.configure( { 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 }, sim.processedAudio(), sim.samples, ); }; for (const el of [win, hop, fmax, floor, logf]) el.addEventListener("change", apply); tb.append( document.createTextNode("Spectrogram"), labeled("Window", win), labeled("Hop", hop), labeled("Max Hz", fmax), labeled("dB range", floor), labeled("Log freq.", logf), ); } function numInput(v: number, step: number): HTMLInputElement { const i = document.createElement("input"); i.type = "number"; i.value = String(v); i.step = String(step); return i; } function labeled(text: string, el: HTMLElement): HTMLLabelElement { const l = document.createElement("label"); l.append(text, el); return l; } // ---------- Simulation control ---------- async function start(): Promise { if (!device) return; player.stop(); running = false; await new Promise((r) => setTimeout(r, 0)); sim?.destroy(); spec.reset(); live = true; paused = false; btnPause.textContent = "Pause"; params.duration = duration; sim = new Simulation(device, params); renderer = new Renderer(device, $("view"), sim.grid, params, sim.w0); movieBuf?.destroy(); movieBuf = device.createBuffer({ size: Math.max(16, sim.grid.n * 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); movieBufFrame = -1; scrub.max = "0"; scrub.value = "0"; lastStatsTime = performance.now(); lastStatsSteps = 0; stepsPerSec = 0; setFormEnabled(false); updateLiveButton(); void runLoop(); } async function runLoop(): Promise { if (!sim || running) return; running = true; updateButtons(); try { while (sim.phase !== "done" && !paused) { await sim.runBatch(); const audio = sim.processedAudio(); spec.update(audio, sim.samples); scrub.max = String(Math.max(0, sim.frames.length - 1)); if (live) scrub.value = scrub.max; const now = performance.now(); if (now - lastStatsTime > 500) { stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime); lastStatsTime = now; lastStatsSteps = sim.totalSteps; } } } catch (e) { console.error(e); $("info").innerHTML = `
Simulation error: ${String(e)}
`; } running = false; setFormEnabled(sim.phase === "done" && !paused ? true : false); if (sim.phase === "done") setFormEnabled(true); updateButtons(); } // ---------- Render loop ---------- let frameErrorShown = false; function frame(): void { requestAnimationFrame(frame); try { frameBody(); } catch (e) { if (!frameErrorShown) { frameErrorShown = true; console.error("frame exception:", e); } } } let lastRenderKey = ""; function frameBody(): void { if (!device || !sim || !renderer) return; const dev = device; renderer.mode = viewMode.value; renderer.exaggeration = parseFloat(exagInput.value) || 1; renderer.colorRange = (parseFloat(rangeInput.value) || 0) * 1e-3; renderer.showShape = shapeCheck.checked; const pos = player.position(); if (pos !== null) { scrubFrame = Math.min(sim.frames.length - 1, Math.floor(pos * sim.fps)); scrub.value = String(scrubFrame); } const st = sim.status(); // Render only when something visible changed, so an idle or paused page does no work. const canvas = $("view"); const cam = renderer.camera; const renderKey = [ renderer.mode, renderer.exaggeration, renderer.colorRange, renderer.showShape, cam.yaw.toFixed(4), cam.pitch.toFixed(4), cam.dist.toFixed(4), live, scrubFrame, sim.frames.length, st.totalSteps, st.phase, canvas.clientWidth, canvas.clientHeight, window.devicePixelRatio, ].join("|"); if (renderKey !== lastRenderKey) { lastRenderKey = renderKey; renderView(dev, st); } spec.draw($("spec"), sim.fs, Math.max(duration, sim.samples / sim.fs), pos ?? (live ? null : scrubFrame / sim.fps)); updateInfo(st); } function renderView(dev: GPUDevice, st: ReturnType): void { if (!sim || !renderer) return; const enc = dev.createCommandEncoder(); if (live || sim.frames.length === 0) { sim.encodeDisplay(enc); renderer.render(enc, sim.displayW, st.wmax); timeLabel.textContent = `${st.t >= 0 ? "" : "prep "}${st.t.toFixed(3)} s`; } else { const f = Math.max(0, Math.min(sim.frames.length - 1, scrubFrame)); if (movieBufFrame !== f) { dev.queue.writeBuffer(movieBuf!, 0, sim.frames[f]); movieBufFrame = f; } let wmax = 0; const fr = sim.frames[f]; for (let k = 0; k < fr.length; k += 7) wmax = Math.max(wmax, Math.abs(fr[k])); renderer.render(enc, movieBuf!, wmax); timeLabel.textContent = `${(f / sim.fps).toFixed(3)} s`; } dev.pushErrorScope("validation"); dev.queue.submit([enc.finish()]); void dev.popErrorScope().then((e) => { if (e && !frameErrorShown) { frameErrorShown = true; console.error("frame validation error:", e.message); } }); } function updateInfo(st: ReturnType): void { if (!sim) return; const g = sim.grid; const spl = st.peakPressure > 0 ? 20 * Math.log10(st.peakPressure / 2e-5) : -Infinity; const phase = { push: "pushing", hold: "settling (held)", recording: "ringing (recording)", done: "done" }[st.phase]; const rows: [string, string][] = [ ["Phase", phase], ["Time", `${st.t.toFixed(3)} s`], ["Grid", `${g.nx} × ${g.ny} nodes, ${(g.hx * 1e3).toFixed(2)} × ${(g.hy * 1e3).toFixed(2)} mm`], ["Time step", `${(sim.dt * 1e6).toFixed(3)} µs (${sim.stepsPerSample} per sample)`], ["Speed", `${(stepsPerSec / 1e3).toFixed(0)} k steps/s (${(stepsPerSec * sim.dt).toFixed(3)}× real time)`], ["Kinetic energy", `${st.ke.toExponential(2)} J`], ["Max |w|", `${(st.wmax * 1e3).toFixed(3)} mm`], ["Audio", `${(st.samples / sim.fs).toFixed(2)} s, ${st.frames} frames`], ["Peak pressure", st.peakPressure > 0 ? `${st.peakPressure.toFixed(3)} Pa (${spl.toFixed(1)} dB SPL)` : "–"], ]; let html = rows.map(([a, b]) => `
${a}${b}
`).join(""); if (st.delayClamped) html += `
Listener is so close that some propagation delays were clamped.
`; if (!isFinite(st.wmax) || st.wmax > 1) html += `
The solution has diverged. Reduce the time step by increasing the grid spacing, or reduce the push.
`; if (html !== lastInfoHtml) { lastInfoHtml = html; $("info").innerHTML = html; } } let lastInfoHtml = ""; // ---------- Init ---------- async function init(): Promise { buildForm(); buildControls(); buildViewToolbar(); buildMovieToolbar(); buildSpecToolbar(); if (!("gpu" in navigator)) { showNoGpu("WebGPU is not available in this browser. Use a recent Chrome or Edge (or Firefox / Safari with WebGPU enabled)."); return; } const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); if (!adapter) { showNoGpu("No WebGPU adapter was found."); return; } device = await adapter.requestDevice(); device.lost.then((info) => showNoGpu(`WebGPU device lost: ${info.message}`)); device.addEventListener("uncapturederror", (ev) => console.error("WebGPU error:", (ev as GPUUncapturedErrorEvent).error.message)); const g = makeGrid(params); $("info").innerHTML = `
Grid (default)${g.nx} × ${g.ny} nodes

Press Start to run.

`; requestAnimationFrame(frame); applyUrlParams(); } /** URL parameters: any PhysParams key in SI units, plus autostart=1. Exposes the simulation on window for tests. */ function applyUrlParams(): void { const q = new URLSearchParams(location.search); for (const [k, v] of q) { if (k in params && k !== "radiation" && k !== "bowShape") { const num = parseFloat(v); if (isFinite(num)) { (params as unknown as Record)[k] = num; const inp = inputs.get(k as keyof PhysParams); const spec = groups.flatMap((g) => g.fields).find((f) => f.key === k); if (inp && spec) (inp as HTMLInputElement).value = fmt(num / spec.scale); } } if (k === "radiation" && (v === "rayleigh" || v === "dipole")) params.radiation = v; if (k === "bowShape" && ["none", "width", "height", "dish"].includes(v)) { params.bowShape = v as BowShape; (inputs.get("bowShape") as HTMLSelectElement).value = v; } if (k === "duration") { duration = params.duration; durInput.value = String(duration); } } updateDampingNote(); (window as unknown as { steelsim: unknown }).steelsim = { status: () => (sim ? { ...sim.status(), dt: sim.dt, stepsPerSample: sim.stepsPerSample, nx: sim.grid.nx, ny: sim.grid.ny, stepsPerSec } : null), audio: () => (sim ? Array.from(sim.processedAudio()) : null), frame: (k: number) => (sim ? Array.from(sim.frames[k] ?? []) : null), frames: () => sim?.frames.length ?? 0, state: async () => { if (!sim) return null; const st = await sim.readState(); return { pos: Array.from(st.pos), vel: Array.from(st.vel) }; }, pause: () => { paused = true; }, canvasInfo: () => { const c = $("view"); return { cw: c.clientWidth, ch: c.clientHeight, w: c.width, h: c.height, mode: viewMode.value }; }, setView: (m: ViewMode) => { viewMode.value = m; }, scrub: (k: number) => { live = false; scrubFrame = k; scrub.value = String(k); updateLiveButton(); }, }; if (q.get("autostart") === "1") void start(); } function showNoGpu(msg: string): void { const el = $("nogpu"); el.hidden = false; el.textContent = msg; btnStart.disabled = true; } void init();