/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / main.ts
208 lines · 11.5 KBCodeBlameHistory
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 1import { Stft } from "./analysis/spectrogram.ts";
2import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
3import { PRESETS } from "./config/presets.ts";
4import type { SimulationConfig } from "./config/schema.ts";
5import { configToYaml, parseConfigYaml, type Issue } from "./config/yaml.ts";
6import { CpuSimulator } from "./cpu/simulator.ts";
7import { GpuSimulator } from "./gpu/simulator.ts";
8import { DECIMATION_SPAN } from "./model/radiation.ts";
9import { buildSetup, type Setup } from "./model/setup.ts";
10import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
11import { Runner } from "./run.ts";
12import { YamlEditor } from "./ui/editor.ts";
13import { ConfigForm } from "./ui/form.ts";
14import { PlateView } from "./ui/plateView.ts";
15import { Readouts, formatSummary } from "./ui/readouts.ts";
16import { SpectrogramView } from "./ui/spectrogramView.ts";
18const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
19const params = new URLSearchParams(location.search);
21// ---------------------------------------------------------------- WebGPU device
22let device: GPUDevice | null = null;
23if (params.get("backend") !== "cpu" && "gpu" in navigator) {
24 try {
25 const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
26 device = (await adapter?.requestDevice()) ?? null;
27 } catch (err) { console.warn("WebGPU unavailable", err); }
29$("backend").textContent = device ? "backend: WebGPU compute (time loop on the GPU)" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
31// ---------------------------------------------------------------- config editing (YAML <-> form)
32let config: SimulationConfig | null = null;
33let issues: Issue[] = [];
34const issuesEl = $<HTMLUListElement>("issues");
35function showIssues() {
36 issuesEl.replaceChildren();
37 if (!issues.length) { const li = document.createElement("li"); li.className = "ok"; li.textContent = "configuration OK"; issuesEl.appendChild(li); return; }
38 for (const i of issues) { const li = document.createElement("li"); li.innerHTML = `<code>${i.path || "document"}</code>: ${i.message}`; issuesEl.appendChild(li); }
40const presetSel = $<HTMLSelectElement>("preset");
41for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
42const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
43presetSel.value = initialPreset.id;
45let form: ConfigForm;
46function fromYaml(text: string) {
47 const r = parseConfigYaml(text);
48 issues = r.issues;
49 if (r.config) { config = r.config; form.setConfig(config); }
50 showIssues();
52const editor = new YamlEditor($("editor"), initialPreset.yaml, fromYaml);
53form = new ConfigForm($("form"), parseConfigYaml(initialPreset.yaml).config!, (cfg) => {
54 config = cfg;
55 editor.setText(configToYaml(cfg));
56 issues = parseConfigYaml(editor.text).issues; showIssues();
57});
58fromYaml(initialPreset.yaml);
59presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value)!; editor.setText(p.yaml); fromYaml(p.yaml); };
60$("import").onclick = () => $<HTMLInputElement>("file").click();
61$<HTMLInputElement>("file").onchange = async (ev) => {
62 const file = (ev.target as HTMLInputElement).files?.[0];
63 if (!file) return;
64 const text = await file.text();
65 editor.setText(text); fromYaml(text);
66 (ev.target as HTMLInputElement).value = "";
67};
68function download(blob: Blob, name: string) {
69 const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
70 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
72$("download-yaml").onclick = () => download(new Blob([editor.text], { type: "text/yaml" }), `${config?.output.prefix ?? "cymbal"}.yaml`);
73for (const b of document.querySelectorAll<HTMLButtonElement>(".tabs button")) {
74 b.onclick = () => {
75 for (const x of document.querySelectorAll<HTMLButtonElement>(".tabs button")) x.classList.toggle("active", x === b);
76 for (const t of document.querySelectorAll<HTMLElement>(".tab")) t.classList.toggle("hidden", t.id !== `tab-${b.dataset.tab}`);
77 };
80// ---------------------------------------------------------------- views
81const plate = new PlateView($("plate"), device);
82const specView = new SpectrogramView($("spectrogram"));
83const readouts = new Readouts($("readouts"));
84const player = new Player();
85const exag = $<HTMLInputElement>("exag");
86const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
87exag.oninput = applyExag; applyExag();
88$("reset-view").onclick = () => plate.resetView();
89window.addEventListener("resize", () => { plate.render(); specView.draw(); });
91// ---------------------------------------------------------------- run state
92let setup: Setup | null = null;
93let runner: Runner | null = null;
94let backend: SimulatorBackend | null = null;
95let front = new Float32Array(0), back = new Float32Array(0), frontier = 0, pushed = 0;
96let stft: Stft | null = null;
97const signalSel = $<HTMLSelectElement>("signal");
98const signal = () => {
99 const out = new Float32Array(frontier);
100 if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
101 else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
102 return out;
103};
104function rebuildSpectrogram() {
105 if (!setup) return;
106 stft = new Stft(setup.fs, 1024, 256); pushed = 0;
107 specView.reset(stft, setup.config.numerics.duration);
108 pushSpectrogram();
110function pushSpectrogram() {
111 if (!stft || !setup) return;
112 if (frontier > pushed) { const s = signal(); stft.push(s.subarray(pushed, frontier)); pushed = frontier; }
113 specView.frontierTime = frontier / setup.fs;
114 specView.update();
115 const c = stft.centroid.length ? stft.centroid[stft.centroid.length - 1] : 0;
116 $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`;
118signalSel.onchange = rebuildSpectrogram;
120const startBtn = $<HTMLButtonElement>("start"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
121const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
122const progress = $<HTMLProgressElement>("progress");
124async function start() {
125 if (!config || issues.length) { readouts.log("fix the configuration issues first"); return; }
126 startBtn.disabled = true; presetSel.disabled = true;
127 readouts.clearLog();
128 const cfg = structuredClone(config);
129 try {
130 readouts.log("building the set-up ...");
131 setup = await buildSetup(cfg, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); });
132 } catch (err) {
133 readouts.log(`set-up failed: ${(err as Error).message}`); startBtn.disabled = false; presetSel.disabled = false; return;
134 }
135 $("summary").textContent = formatSummary(setup.summary);
136 readouts.log(`set-up done in ${setup.summary.setupSeconds.toFixed(1)} s: ${setup.summary.nodes} nodes, ${setup.summary.substeps} sub-steps per sample, ${setup.summary.nSteps} steps`);
137 if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`);
138 backend?.dispose();
139 backend = device ? new GpuSimulator(device, setup, 8192) : new CpuSimulator(setup);
140 plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null);
141 $("plate-info").textContent = `${setup.mesh.n} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
142 front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
143 rebuildSpectrogram();
144 player.stop(); playBtn.disabled = true; wavBtn.disabled = true;
145 pauseBtn.disabled = false; stopBtn.disabled = false; pauseBtn.textContent = "Pause";
146 const kinds = setup.patches.map((p) => p.hit.exciter.kind);
147 let lastPlate = 0;
148 runner = new Runner(backend, setup, {
149 onChunk(res: ChunkResult) {
150 const n = res.pressureFront.length;
151 if (n && setup) {
152 front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
153 back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
154 frontier = Math.min(setup.nSamples, res.firstSample + n);
155 }
156 pushSpectrogram();
157 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
158 if (performance.now() - lastPlate > 100) {
159 lastPlate = performance.now();
160 if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
161 }
162 },
163 onDiagnostics(d: Diagnostics) {
164 readouts.setDiagnostics(d, kinds);
165 if (!Number.isFinite(d.kinetic + d.membrane + d.bending)) { readouts.log("energy is not finite: the run has blown up (lower numerics.cfl_safety)"); runner?.stop(); }
166 },
167 onProgress(done, wall) {
168 if (!setup) return;
169 progress.value = done / setup.nSamples;
170 const audio = done / setup.fs;
171 $("progress-text").textContent = `${audio.toFixed(2)} / ${setup.config.numerics.duration.toFixed(2)} s (${wall.toFixed(0)} s wall, ${(audio / Math.max(wall, 1e-3)).toFixed(3)}x real time)`;
172 },
173 onDone(stopped) {
174 readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)`);
175 startBtn.disabled = false; presetSel.disabled = false; pauseBtn.disabled = true; stopBtn.disabled = true;
176 void backend?.diagnostics().then((d) => readouts.setDiagnostics(d, kinds));
177 },
178 });
179 void runner.run();
181startBtn.onclick = () => void start();
182pauseBtn.onclick = () => { if (!runner) return; if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; } else { runner.pause(); pauseBtn.textContent = "Continue"; } };
183stopBtn.onclick = () => runner?.stop();
185// ---------------------------------------------------------------- playback of the completed portion
186const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
187gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
188function tick() {
189 if (player.playing && setup) {
190 const t = player.position();
191 position.value = String(t / setup.config.numerics.duration); timeEl.textContent = `${t.toFixed(3)} s`;
192 specView.cursorTime = t; specView.draw();
193 }
194 requestAnimationFrame(tick);
196requestAnimationFrame(tick);
197playBtn.onclick = () => {
198 if (!setup || frontier === 0) return;
199 player.setGainDb(Number(gain.value));
200 player.play(scaleForPlayback(signal(), normalize.checked), setup.fs, Number(position.value) * setup.config.numerics.duration);
201 stopAudioBtn.disabled = false;
202};
203player.onEnded = () => { stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
204stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
205position.oninput = () => { if (setup) { const t = Number(position.value) * setup.config.numerics.duration; timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } };
206wavBtn.onclick = () => { if (setup) download(encodeWav(scaleForPlayback(signal(), normalize.checked), setup.fs), `${setup.config.output.prefix}_${signalSel.value}.wav`); };
208if (params.get("autostart") === "1") void start();
↑↓ move↵openescclose