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