13f4dbdexport .cymsim fileJeremy Magland 1import { AccelerationStore, DisplacementStore } from "./analysis/acceleration.ts";
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 2import { Movie, MoviePlayer, DEFAULT_MOVIE_BUDGET, movieEvery } from "./analysis/movie.ts";
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 3import { Stft, bandSummary } from "./analysis/spectrogram.ts";
4import { ChatClient, type AppApi } from "./chat/client.ts";
5import { ChatPanel } from "./ui/chatPanel.ts";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 6import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
7import { PRESETS } from "./config/presets.ts";
8import type { SimulationConfig } from "./config/schema.ts";
9import { configToYaml, parseConfigYaml, type Issue } from "./config/yaml.ts";
10import { CpuSimulator } from "./cpu/simulator.ts";
11import { GpuSimulator } from "./gpu/simulator.ts";
13f4dbdexport .cymsim fileJeremy Magland 12import { readCymsim, writeCymsim, type CymsimRun, type H5Module } from "./io/cymsimFile.ts";
13import { patchEnergy } from "./model/exciter.ts";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 14import { DECIMATION_SPAN } from "./model/radiation.ts";
15import { buildSetup, type Setup } from "./model/setup.ts";
13f4dbdexport .cymsim fileJeremy Magland 16import { dispEveryFor, type BackendOptions, type ChunkResult, type Diagnostics, type SimulatorBackend } from "./model/simulator.ts";
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 17import type { FrequencyScale } from "./ui/spectrogramView.ts";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 18import { Runner } from "./run.ts";
19import { YamlEditor } from "./ui/editor.ts";
20import { ConfigForm } from "./ui/form.ts";
21import { PlateView } from "./ui/plateView.ts";
22import { Readouts, formatSummary } from "./ui/readouts.ts";
23import { SpectrogramView } from "./ui/spectrogramView.ts";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 26const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
27const params = new URLSearchParams(location.search);
29// ---------------------------------------------------------------- WebGPU device
30let device: GPUDevice | null = null;
31if (params.get("backend") !== "cpu" && "gpu" in navigator) {
32 try {
33 const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
34 device = (await adapter?.requestDevice()) ?? null;
35 } catch (err) { console.warn("WebGPU unavailable", err); }
36}
37$("backend").textContent = device ? "backend: WebGPU compute (time loop on the GPU)" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
39// ---------------------------------------------------------------- config editing (YAML <-> form)
40let config: SimulationConfig | null = null;
41let issues: Issue[] = [];
42const issuesEl = $<HTMLUListElement>("issues");
43function showIssues() {
44 issuesEl.replaceChildren();
45 if (!issues.length) { const li = document.createElement("li"); li.className = "ok"; li.textContent = "configuration OK"; issuesEl.appendChild(li); return; }
46 for (const i of issues) { const li = document.createElement("li"); li.innerHTML = `<code>${i.path || "document"}</code>: ${i.message}`; issuesEl.appendChild(li); }
47}
48const presetSel = $<HTMLSelectElement>("preset");
49for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
50const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
51presetSel.value = initialPreset.id;
53let form: ConfigForm;
54function fromYaml(text: string) {
55 const r = parseConfigYaml(text);
56 issues = r.issues;
57 if (r.config) { config = r.config; form.setConfig(config); }
58 showIssues();
59}
60const editor = new YamlEditor($("editor"), initialPreset.yaml, fromYaml);
61form = new ConfigForm($("form"), parseConfigYaml(initialPreset.yaml).config!, (cfg) => {
62 config = cfg;
63 editor.setText(configToYaml(cfg));
64 issues = parseConfigYaml(editor.text).issues; showIssues();
65});
66fromYaml(initialPreset.yaml);
67presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value)!; editor.setText(p.yaml); fromYaml(p.yaml); };
68$("import").onclick = () => $<HTMLInputElement>("file").click();
69$<HTMLInputElement>("file").onchange = async (ev) => {
70 const file = (ev.target as HTMLInputElement).files?.[0];
71 if (!file) return;
72 const text = await file.text();
73 editor.setText(text); fromYaml(text);
74 (ev.target as HTMLInputElement).value = "";
75};
76function download(blob: Blob, name: string) {
77 const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
78 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
79}
80$("download-yaml").onclick = () => download(new Blob([editor.text], { type: "text/yaml" }), `${config?.output.prefix ?? "cymbal"}.yaml`);
81for (const b of document.querySelectorAll<HTMLButtonElement>(".tabs button")) {
82 b.onclick = () => {
83 for (const x of document.querySelectorAll<HTMLButtonElement>(".tabs button")) x.classList.toggle("active", x === b);
84 for (const t of document.querySelectorAll<HTMLElement>(".tab")) t.classList.toggle("hidden", t.id !== `tab-${b.dataset.tab}`);
85 };
86}
88// ---------------------------------------------------------------- views
89const plate = new PlateView($("plate"), device);
90const specView = new SpectrogramView($("spectrogram"));
91const readouts = new Readouts($("readouts"));
92const player = new Player();
93const exag = $<HTMLInputElement>("exag");
94const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
95exag.oninput = applyExag; applyExag();
96$("reset-view").onclick = () => plate.resetView();
cffea67UI: exaggeration defaults to x1, play position returns to 0 after playback, resizable panesJeremy Magland 97const redraw = () => { plate.render(); specView.draw(); };
98new ResizeObserver(redraw).observe($("plate"));
99new ResizeObserver(redraw).observe($("spectrogram"));
101// splitters: drag to set the CSS variable that sizes the neighbouring pane
102for (const sp of document.querySelectorAll<HTMLElement>(".splitter")) {
103 const target = $(sp.dataset.target!), cssVar = sp.dataset.var!, vertical = sp.classList.contains("vertical"), invert = sp.dataset.invert === "1";
104 sp.addEventListener("pointerdown", (e) => {
105 e.preventDefault();
106 sp.setPointerCapture(e.pointerId); sp.classList.add("active");
107 const start = vertical ? e.clientX : e.clientY, size0 = vertical ? target.getBoundingClientRect().width : target.getBoundingClientRect().height;
108 const move = (ev: PointerEvent) => {
109 const delta = (vertical ? ev.clientX : ev.clientY) - start;
110 const size = Math.max(60, size0 + (invert ? -delta : delta));
111 document.documentElement.style.setProperty(cssVar, `${size}px`);
112 };
113 const up = () => { sp.classList.remove("active"); sp.removeEventListener("pointermove", move); sp.removeEventListener("pointerup", up); redraw(); };
114 sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up);
115 });
116}
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 117
118// ---------------------------------------------------------------- run state
119let setup: Setup | null = null;
120let runner: Runner | null = null;
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 121let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle";
122let lastDiagnostics: Diagnostics | null = null;
123let lastProgress = { done: 0, wall: 0 };
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 124let backend: SimulatorBackend | null = null;
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 126let front = new Float32Array(0), back = new Float32Array(0), frontier = 0, pushed = 0;
127let stft: Stft | null = null;
129let accStore: AccelerationStore | null = null;
130let dispStore: DisplacementStore | null = null;
131let energyRows: number[][] = [];
132let wallBefore = 0; // wall time of earlier sessions of a resumed run
133let loaded: CymsimRun | null = null; // a loaded file whose run has not been continued
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 134const signalSel = $<HTMLSelectElement>("signal");
135const signal = () => {
136 const out = new Float32Array(frontier);
137 if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
138 else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
139 return out;
140};
141function rebuildSpectrogram() {
142 if (!setup) return;
143 stft = new Stft(setup.fs, 1024, 256); pushed = 0;
144 specView.reset(stft, setup.config.numerics.duration);
145 pushSpectrogram();
146}
147function pushSpectrogram() {
148 if (!stft || !setup) return;
149 if (frontier > pushed) { const s = signal(); stft.push(s.subarray(pushed, frontier)); pushed = frontier; }
150 specView.frontierTime = frontier / setup.fs;
151 specView.update();
152 const c = stft.centroid.length ? stft.centroid[stft.centroid.length - 1] : 0;
153 $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`;
154}
155signalSel.onchange = rebuildSpectrogram;
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 156const specScale = $<HTMLSelectElement>("spec-scale"), specFmax = $<HTMLSelectElement>("spec-fmax");
157specScale.onchange = () => specView.setScale(specScale.value as FrequencyScale);
158specFmax.onchange = () => specView.setMaxFrequency(Number(specFmax.value));
159specView.setScale(specScale.value as FrequencyScale);
161// ---- displacement movie
162let movie: Movie | null = null;
163let moviePlayer: MoviePlayer | null = null;
164const frameBuf = { w: new Float32Array(0) };
165const movieFps = $<HTMLInputElement>("movie-fps"), moviePlay = $<HTMLButtonElement>("movie-play"), movieStop = $<HTMLButtonElement>("movie-stop");
166const slowdown = $<HTMLInputElement>("slowdown"), moviePos = $<HTMLInputElement>("movie-pos"), movieInfo = $("movie-info");
167const slowdownFactor = () => Math.round(Math.pow(10, Number(slowdown.value)));
168function showFrame(index: number, time: number) {
169 if (!movie || !movie.length) return;
170 if (frameBuf.w.length !== movie.nodes) frameBuf.w = new Float32Array(movie.nodes);
171 plate.setDisplacement(movie.read(index, frameBuf.w));
172 moviePos.value = String(movie.duration > 0 ? time / movie.duration : 0);
173 movieInfo.textContent = `${(time * 1e3).toFixed(1)} ms of ${(movie.duration * 1e3).toFixed(0)} ms · frame ${index + 1}/${movie.length} · x${slowdownFactor()} slower${movie.full ? " · budget reached" : ""}`;
174}
175function updateMovieUi() {
176 const have = !!movie && movie.length > 1;
177 moviePlay.disabled = !have; moviePos.disabled = !have;
178 movieStop.disabled = !(moviePlayer?.playing ?? false);
179 $("slowdown-text").textContent = `x${slowdownFactor()}`;
180 if (!have && movie) movieInfo.textContent = `${movie.length} frames`;
181}
182slowdown.oninput = () => { if (moviePlayer) moviePlayer.slowdown = slowdownFactor(); updateMovieUi(); if (movie?.length) showFrame(movie.indexAtTime(moviePlayer?.time ?? 0), moviePlayer?.time ?? 0); };
183moviePlay.onclick = () => { if (!moviePlayer) return; moviePlayer.slowdown = slowdownFactor(); moviePlayer.play(); updateMovieUi(); };
184movieStop.onclick = () => { moviePlayer?.stop(); updateMovieUi(); };
185moviePos.oninput = () => { if (!movie || !moviePlayer) return; moviePlayer.stop(); moviePlayer.seek(Number(moviePos.value) * movie.duration); updateMovieUi(); };
186updateMovieUi();
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 187
13f4dbdexport .cymsim fileJeremy Magland 188const startBtn = $<HTMLButtonElement>("start"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop"), resumeBtn = $<HTMLButtonElement>("resume");
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 189const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
13f4dbdexport .cymsim fileJeremy Magland 190const exportBtn = $<HTMLButtonElement>("export-cymsim"), loadBtn = $<HTMLButtonElement>("load-cymsim");
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 191const progress = $<HTMLProgressElement>("progress");
13f4dbdexport .cymsim fileJeremy Magland 193/** Memory the acceleration record of a run would take, against what this device can be expected to hold. */
194function recordBudget(nSamples: number, nodes: number): { bytes: number; limit: number } {
195 const bytes = AccelerationStore.estimateBytes(nSamples, nodes);
196 const deviceGb = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
197 const limit = deviceGb ? Math.max(1e9, 0.5 * deviceGb * 1e9) : 2e9;
198 return { bytes, limit };
199}
201const fmtBytes = (b: number) => (b >= 1e9 ? `${(b / 1e9).toFixed(2)} GB` : `${(b / 1e6).toFixed(0)} MB`);
203function setRunButtons(running: boolean) {
204 startBtn.disabled = running; presetSel.disabled = running; loadBtn.disabled = running;
205 pauseBtn.disabled = !running; stopBtn.disabled = !running;
206 resumeBtn.disabled = running || !(loaded?.state && !loaded.complete && loaded.state.kind === (device ? "gpu" : "cpu"));
207}
209/** Start a run of the current configuration, or continue the loaded file's run when ``resumeFrom`` is given. */
210async function start(resumeFrom: CymsimRun | null = null) {
211 if (!resumeFrom && (!config || issues.length)) { readouts.log("fix the configuration issues first"); return; }
212 setRunButtons(true); runState = "setup";
213 if (!resumeFrom) readouts.clearLog();
214 const cfg = resumeFrom ? structuredClone(resumeFrom.config) : structuredClone(config!);
215 const fail = (msg: string) => { readouts.log(msg); runState = resumeFrom ? "stopped" : "idle"; setRunButtons(false); };
216 if (!resumeFrom || !setup) {
217 try {
218 readouts.log("building the set-up ...");
219 setup = await buildSetup(cfg, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); });
220 } catch (err) { fail(`set-up failed: ${(err as Error).message}`); return; }
221 $("summary").textContent = formatSummary(setup.summary);
222 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`);
223 if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`);
224 }
225 const N = setup.mesh.n;
226 // ---- records: the acceleration record is opt-in and must fit in memory
227 const recording = resumeFrom ? resumeFrom.acceleration !== null : cfg.output.record_acceleration;
228 if (recording) {
229 const { bytes, limit } = recordBudget(setup.nSamples, N);
230 if (bytes > limit) { fail(`refusing to start: the acceleration record of this run needs ${fmtBytes(bytes)} in memory, above this device's limit of ${fmtBytes(limit)}; shorten the duration, coarsen the mesh, or turn output.record_acceleration off`); return; }
231 readouts.log(`acceleration record: ${setup.nSamples} samples x ${N} nodes, int16, ${fmtBytes(bytes)} in memory${bytes > 1e9 ? " (warning: above 1 GB)" : ""}`);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 232 }
13f4dbdexport .cymsim fileJeremy Magland 233 const dispEvery = dispEveryFor(setup.fs, cfg.output.displacement_rate);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 234 backend?.dispose();
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 235 const fps = Math.max(0, Number(movieFps.value) || 0);
236 const every = fps > 0 ? movieEvery(setup.fs, fps) : 0;
13f4dbdexport .cymsim fileJeremy Magland 237 backendOpts = { movieEvery: every, dispEvery, recordAcceleration: recording };
238 try {
239 backend = device ? new GpuSimulator(device, setup, 8192, backendOpts) : new CpuSimulator(setup, backendOpts);
240 if (resumeFrom) await backend.importState(resumeFrom.state!);
241 } catch (err) { fail(`could not create the simulator: ${(err as Error).message}`); backend = null; return; }
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 242 moviePlayer?.stop();
244 movie = every > 0 ? new Movie(N, setup.fs, { fps, budgetBytes: DEFAULT_MOVIE_BUDGET }) : null;
245 moviePlayer = movie ? new MoviePlayer(movie, showFrame) : null;
246 if (movie) readouts.log(`movie: capturing every ${every} samples (${(setup.fs / every).toFixed(0)} fps of simulated time, ${((setup.nSamples / every) * N * 2 / 1e6).toFixed(0)} MB at full duration)`);
247 front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
248 accStore = recording ? new AccelerationStore(N, setup.fs) : null;
249 dispStore = dispEvery > 0 ? new DisplacementStore(N, setup.fs / dispEvery) : null;
250 energyRows = []; wallBefore = 0;
251 rebuildSpectrogram();
252 player.stop(); playBtn.disabled = true; wavBtn.disabled = true;
253 } else {
254 // keep the loaded signals and records; the movie is rebuilt from the file's displacement frames on load
255 if (every > 0 && (!movie || movie.length === 0)) { movie = new Movie(N, setup.fs, { fps, budgetBytes: DEFAULT_MOVIE_BUDGET }); moviePlayer = new MoviePlayer(movie, showFrame); }
256 wallBefore = resumeFrom.wallTime;
257 readouts.log(`continuing from ${(resumeFrom.nStepsDone * resumeFrom.dt).toFixed(3)} s`);
258 }
259 loaded = null;
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 260 updateMovieUi();
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 261 plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null);
13f4dbdexport .cymsim fileJeremy Magland 262 $("plate-info").textContent = `${N} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
263 exportBtn.disabled = false;
264 pauseBtn.textContent = "Pause";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 265 const kinds = setup.patches.map((p) => p.hit.exciter.kind);
266 let lastPlate = 0;
13f4dbdexport .cymsim fileJeremy Magland 267 runState = "running"; lastDiagnostics = null; lastProgress = { done: backend.samplesDone, wall: 0 };
268 const thisSetup = setup;
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 269 runner = new Runner(backend, setup, {
270 onChunk(res: ChunkResult) {
271 const n = res.pressureFront.length;
273 front.set(res.pressureFront.subarray(0, Math.min(n, thisSetup.nSamples - res.firstSample)), res.firstSample);
274 back.set(res.pressureBack.subarray(0, Math.min(n, thisSetup.nSamples - res.firstSample)), res.firstSample);
275 frontier = Math.min(thisSetup.nSamples, res.firstSample + n);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 276 }
278 try { accStore.push(res.acceleration, Math.min(n, thisSetup.nSamples - res.firstSample), res.firstSample); }
279 catch (err) { readouts.log(`acceleration record stopped: ${(err as Error).message}`); accStore = null; }
280 }
281 if (dispStore) res.dispFrames.forEach((w, i) => { const t = res.dispSamples[i] / thisSetup.fs; if (t <= thisSetup.config.numerics.duration) dispStore!.push(w, t); });
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 282 if (movie) {
283 for (let f = 0; f < res.frames.length; f++) movie.push(res.frames[f], res.frameSamples[f]);
284 if (!moviePlayer?.playing) updateMovieUi();
285 }
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 286 pushSpectrogram();
287 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 288 if (performance.now() - lastPlate > 100 && !moviePlayer?.playing) {
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 289 lastPlate = performance.now();
290 if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
291 }
292 },
293 onDiagnostics(d: Diagnostics) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 294 lastDiagnostics = d; readouts.setDiagnostics(d, kinds);
13f4dbdexport .cymsim fileJeremy Magland 295 energyRows.push([d.time, d.kinetic, d.membrane, d.bending, d.exciters, d.maxWoverH]);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 296 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(); }
297 },
298 onProgress(done, wall) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 299 lastProgress = { done, wall };
301 const audio = done / thisSetup.fs;
302 $("progress-text").textContent = `${audio.toFixed(2)} / ${thisSetup.config.numerics.duration.toFixed(2)} s (${wall.toFixed(0)} s wall, ${(audio / Math.max(wall, 1e-3)).toFixed(3)}x real time)`;
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 303 },
304 onDone(stopped) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 305 runState = stopped ? "stopped" : "done";
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 306 if (movie && movie.length) { plate.useOwnState(); moviePlayer!.seek(0); }
307 updateMovieUi();
13f4dbdexport .cymsim fileJeremy Magland 308 readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)${accStore ? `; acceleration record ${fmtBytes(accStore.bytes)}` : ""}`);
309 setRunButtons(false);
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 310 void backend?.diagnostics().then((d) => { lastDiagnostics = d; readouts.setDiagnostics(d, kinds); });
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 311 },
312 });
313 void runner.run();
314}
315startBtn.onclick = () => void start();
13f4dbdexport .cymsim fileJeremy Magland 316resumeBtn.onclick = () => { if (loaded) void start(loaded); };
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 317function togglePause(): string {
318 if (!runner || runState !== "running" && runState !== "paused") return "no run in progress";
319 if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; return "continued"; }
320 runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; return "paused";
321}
322pauseBtn.onclick = () => { togglePause(); };
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 323stopBtn.onclick = () => runner?.stop();
13f4dbdexport .cymsim fileJeremy Magland 325// ---------------------------------------------------------------- .cymsim files
326// h5wasm (libhdf5 in WebAssembly, about 1 MB compressed) is loaded on first use; its in-memory file system holds the file image
327let h5Promise: Promise<{ h5: H5Module; FS: any }> | null = null;
328function loadH5(): Promise<{ h5: H5Module; FS: any }> {
329 h5Promise ??= import("h5wasm").then(async (m) => { const h5 = m.default as unknown as H5Module; const { FS } = await h5.ready; return { h5, FS }; });
330 return h5Promise;
331}
333/** The run so far as the content of a .cymsim file. */
334async function currentRun(): Promise<CymsimRun> {
335 if (!setup || !backend) throw new Error("no run");
336 const s = setup, { mesh, shell } = s, cfg = s.config;
337 const state = await backend.exportState();
338 const d = lastDiagnostics ?? (await backend.diagnostics());
339 const unbaffled = new Float32Array(frontier);
340 for (let i = 0; i < frontier; i++) unbaffled[i] = front[i] - back[i];
341 return {
342 yaml: configToYaml(cfg), config: cfg,
343 mesh: { xy: mesh.xy, z0: shell.z0Node, thickness: shell.hNode, normal: shell.normalNode, triangles: Int32Array.from(mesh.tri), area: mesh.area, boundary: Int32Array.from(mesh.boundary), nNodes: mesh.n, nElements: mesh.e },
344 listener: { rFront: s.radiation.rFront, rBack: s.radiation.rBack },
345 fs: s.fs, dt: s.dt, substeps: s.substeps, duration: cfg.numerics.duration, nSamples: s.nSamples, nStepsDone: state.step,
346 complete: runState === "done", wallTime: wallBefore + lastProgress.wall, decimationSpan: DECIMATION_SPAN,
347 audioBaffled: front.slice(0, frontier), audioUnbaffled: unbaffled,
348 acceleration: accStore, taps: accStore ? s.radiation.taps : null,
349 displacement: dispStore ? { rate: dispStore.rate, times: dispStore.times, frames: dispStore.frames } : null,
350 energy: energyRows,
351 hits: d.hits.map((h, k) => { const p = s.patches[k]; return { time: p.time, kind: p.hit.exciter.kind, peak_force: h.peakForce, contact_time: h.contactTime, rebound_velocity: h.velocity, energy_in: patchEnergy(p), energy_out: 0.5 * p.mass * h.velocity ** 2 }; }),
352 state: runState === "done" ? null : state, creator: `cymsimweb ${APP_VERSION}`, created: "",
353 };
354}
356async function exportCymsim(): Promise<string> {
357 if (!setup || !backend) return "no run to export";
358 const wasRunning = runState === "running";
359 if (wasRunning && runner) { runner.pause(); await runner.waitPaused(); }
360 exportBtn.disabled = true;
361 try {
362 readouts.log("building the .cymsim file ...");
363 const [{ h5, FS }, run] = [await loadH5(), await currentRun()];
364 const path = "/export.cymsim";
365 writeCymsim(h5, path, run, `cymsimweb ${APP_VERSION}`);
366 const bytes: Uint8Array = FS.readFile(path);
367 FS.unlink(path);
368 const name = `${setup.config.output.prefix}.cymsim`;
369 download(new Blob([bytes as BlobPart], { type: "application/x-hdf5" }), name);
370 const what = `${run.audioBaffled.length} audio samples${run.acceleration ? `, ${run.acceleration.nSamples} acceleration samples` : ""}${run.displacement ? `, ${run.displacement.frames.length} displacement frames` : ""}${run.state ? ", integrator state" : ""}`;
371 readouts.log(`exported ${name} (${fmtBytes(bytes.byteLength)}: ${what}${run.complete ? "" : "; partial run"})`);
372 return `exported ${name} (${fmtBytes(bytes.byteLength)})`;
373 } catch (err) {
374 readouts.log(`export failed: ${(err as Error).message}`); return `export failed: ${(err as Error).message}`;
375 } finally {
376 exportBtn.disabled = false;
377 if (wasRunning && runner) runner.unpause();
378 }
379}
380exportBtn.onclick = () => void exportCymsim();
382async function loadCymsim(file: File): Promise<string> {
383 const { h5, FS } = await loadH5();
384 const path = "/load.cymsim";
385 let run: CymsimRun;
386 try {
387 FS.writeFile(path, new Uint8Array(await file.arrayBuffer()));
388 run = readCymsim(h5, path, (y) => { const r = parseConfigYaml(y); if (!r.config) throw new Error(r.issues[0]?.message ?? "bad YAML"); return r.config; });
389 } catch (err) { readouts.log(`could not load ${file.name}: ${(err as Error).message}`); return `could not load: ${(err as Error).message}`; }
390 finally { try { FS.unlink(path); } catch { /* not written */ } }
391 runner?.stop(); backend?.dispose(); backend = null; runner = null;
392 readouts.clearLog();
393 readouts.log(`loaded ${file.name}: written by ${run.creator} on ${run.created}, ${run.complete ? "complete" : "partial"} run of ${(run.nStepsDone * run.dt).toFixed(3)} of ${run.duration.toFixed(3)} s`);
394 editor.setText(run.yaml); fromYaml(run.yaml);
395 runState = "setup"; setRunButtons(true);
396 try {
397 setup = await buildSetup(run.config, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); });
398 } catch (err) { readouts.log(`set-up of the loaded configuration failed: ${(err as Error).message}`); runState = "idle"; setRunButtons(false); return "set-up failed"; }
399 const s = setup, N = s.mesh.n;
400 $("summary").textContent = formatSummary(s.summary);
401 if (N !== run.mesh.nNodes || s.substeps !== run.substeps) readouts.log(`warning: the file's mesh (${run.mesh.nNodes} nodes, ${run.substeps} sub-steps) differs from this app's set-up (${N} nodes, ${s.substeps} sub-steps); the file was probably written by another version`);
402 // signals
403 front = new Float32Array(s.nSamples); back = new Float32Array(s.nSamples);
404 frontier = Math.min(s.nSamples, run.audioBaffled.length, run.audioUnbaffled.length);
405 front.set(run.audioBaffled.subarray(0, frontier));
406 for (let i = 0; i < frontier; i++) back[i] = run.audioBaffled[i] - run.audioUnbaffled[i];
407 rebuildSpectrogram();
408 player.stop(); playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
409 // records
410 accStore = run.acceleration && run.acceleration.nodes === N ? run.acceleration : null;
411 dispStore = null;
412 if (run.displacement) { dispStore = new DisplacementStore(N, run.displacement.rate); run.displacement.frames.forEach((w, i) => dispStore!.push(w, run.displacement!.times[i])); }
413 energyRows = run.energy.map((r) => r.slice()); wallBefore = run.wallTime;
414 // the displacement frames double as the replay movie
415 moviePlayer?.stop();
416 if (dispStore && dispStore.frames.length) {
417 movie = new Movie(N, s.fs, { fps: dispStore.rate, budgetBytes: DEFAULT_MOVIE_BUDGET });
418 dispStore.frames.forEach((w, i) => movie!.push(w, Math.round(dispStore!.times[i] * s.fs)));
419 moviePlayer = new MoviePlayer(movie, showFrame);
420 } else { movie = null; moviePlayer = null; }
421 plate.setMesh(s.mesh, s.shell.z0Node, s.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), null);
422 $("plate-info").textContent = `${N} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
423 if (movie && movie.length) { moviePlayer!.seek(movie.duration); }
424 updateMovieUi();
425 // diagnostics
426 const kinds = s.patches.map((p) => p.hit.exciter.kind), last = run.energy[run.energy.length - 1];
427 if (last) {
428 lastDiagnostics = { time: last[0], kinetic: last[1], membrane: last[2], bending: last[3], exciters: last[4], maxWoverH: last[5],
429 hits: run.hits.map((h) => ({ peakForce: h.peak_force, contactTime: h.contact_time, velocity: h.rebound_velocity, height: 0 })) };
430 readouts.setDiagnostics(lastDiagnostics, kinds);
431 }
432 lastProgress = { done: Math.round(run.nStepsDone / run.substeps), wall: 0 };
433 progress.value = lastProgress.done / s.nSamples;
434 $("progress-text").textContent = `${(lastProgress.done / s.fs).toFixed(2)} / ${run.duration.toFixed(2)} s (loaded)`;
435 const parts = [`${frontier} audio samples`];
436 if (accStore) parts.push(`acceleration record ${accStore.nSamples} samples (${fmtBytes(accStore.bytes)})`);
437 else if (run.acceleration) parts.push("acceleration record ignored (node count differs)");
438 if (dispStore) parts.push(`${dispStore.frames.length} displacement frames at ${dispStore.rate.toFixed(0)} fps`);
439 if (run.state) parts.push(`integrator state (${run.state.kind})`);
440 readouts.log(`file holds: ${parts.join(", ")}`);
441 loaded = run;
442 runState = run.complete ? "done" : "stopped";
443 setRunButtons(false);
444 exportBtn.disabled = true; // export needs a backend; Resume or Start provides one
445 if (!run.complete && run.state) {
446 if (run.state.kind !== (device ? "gpu" : "cpu")) readouts.log(`the file's state was written by the ${run.state.kind} backend and cannot be continued on the ${device ? "gpu" : "cpu"} backend`);
447 else readouts.log("this partial run can be continued with Resume");
448 }
449 return `loaded ${file.name}: ${parts.join(", ")}`;
450}
451loadBtn.onclick = () => $<HTMLInputElement>("file-cymsim").click();
452$<HTMLInputElement>("file-cymsim").onchange = async (ev) => {
453 const file = (ev.target as HTMLInputElement).files?.[0];
454 (ev.target as HTMLInputElement).value = "";
455 if (file) await loadCymsim(file);
456};
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 458// ---------------------------------------------------------------- playback of the completed portion
459const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
460gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
461function tick() {
462 if (player.playing && setup) {
463 const t = player.position();
464 position.value = String(t / setup.config.numerics.duration); timeEl.textContent = `${t.toFixed(3)} s`;
465 specView.cursorTime = t; specView.draw();
466 }
467 requestAnimationFrame(tick);
468}
469requestAnimationFrame(tick);
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 470function playFrom(seconds: number): string {
471 if (!setup || frontier === 0) return "no audio yet";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 472 player.setGainDb(Number(gain.value));
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 473 player.play(scaleForPlayback(signal(), normalize.checked), setup.fs, seconds);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 474 stopAudioBtn.disabled = false;
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 475 return `playing ${signalSel.value} from ${seconds.toFixed(2)} s (${(frontier / setup.fs).toFixed(2)} s available)`;
476}
477playBtn.onclick = () => { playFrom(Number(position.value) * (setup?.config.numerics.duration ?? 0)); };
cffea67UI: exaggeration defaults to x1, play position returns to 0 after playback, resizable panesJeremy Magland 478player.onEnded = () => {
479 // playback reached the end of the available signal: return the position to the start
480 stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; specView.cursorTime = null; specView.draw();
481};
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 482stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
483position.oninput = () => { if (setup) { const t = Number(position.value) * setup.config.numerics.duration; timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } };
484wavBtn.onclick = () => { if (setup) download(encodeWav(scaleForPlayback(signal(), normalize.checked), setup.fs), `${setup.config.output.prefix}_${signalSel.value}.wav`); };
486if (params.get("autostart") === "1") void start();
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 487
488// ---------------------------------------------------------------- chat with Claude Code through the local bridge
489const appApi: AppApi = {
490 async getState() {
491 const yamlIssues = parseConfigYaml(editor.text).issues;
492 let spectrogram: unknown = null;
493 if (stft && stft.columns.length) spectrogram = bandSummary(stft);
494 return {
495 yaml: editor.text, issues: yamlIssues, preset: presetSel.value,
496 backend: device ? "webgpu" : "cpu",
497 run: {
498 state: runState, samplesDone: lastProgress.done, samplesTotal: setup?.nSamples ?? 0,
499 audioSeconds: setup ? lastProgress.done / setup.fs : 0, wallSeconds: lastProgress.wall,
500 audioAvailableSeconds: setup ? frontier / setup.fs : 0, signal: signalSel.value,
501 },
502 setup: setup ? formatSummary(setup.summary) : null,
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 503 movie: movie ? { frames: movie.length, fps: movie.fps, simulatedSeconds: movie.duration, megabytes: movie.bytes / 1e6, budgetReached: movie.full, slowdown: slowdownFactor(), playing: moviePlayer?.playing ?? false } : null,
13f4dbdexport .cymsim fileJeremy Magland 504 records: { acceleration: accStore ? { samples: accStore.nSamples, megabytes: accStore.bytes / 1e6 } : null,
505 displacementFrames: dispStore?.frames.length ?? 0, loadedFile: loaded ? { creator: loaded.creator, complete: loaded.complete, resumable: !resumeBtn.disabled } : null },
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 506 spectrogramAxis: { scale: specScale.value, maxFrequencyHz: Number(specFmax.value) || (setup ? setup.fs / 2 : 0) },
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 507 diagnostics: lastDiagnostics ? { time: lastDiagnostics.time, kinetic: lastDiagnostics.kinetic, membrane: lastDiagnostics.membrane, bending: lastDiagnostics.bending,
508 exciters: lastDiagnostics.exciters, maxWoverH: lastDiagnostics.maxWoverH,
509 hits: lastDiagnostics.hits.map((h, k) => ({ kind: setup?.patches[k].hit.exciter.kind, peakForceN: h.peakForce, contactMs: h.contactTime * 1e3, exciterVelocity: h.velocity })) } : null,
510 spectrogram,
511 };
512 },
513 setYaml(text) { editor.setText(text); fromYaml(text); return { issues }; },
514 selectPreset(id) {
515 const p = PRESETS.find((x) => x.id === id);
516 if (!p) return `unknown preset ${id}; known: ${PRESETS.map((x) => x.id).join(", ")}`;
517 presetSel.value = id; editor.setText(p.yaml); fromYaml(p.yaml);
518 return `loaded preset ${id}`;
519 },
520 async start() {
521 if (runState === "running" || runState === "paused" || runState === "setup") return `a run is already ${runState}`;
522 if (!config || issues.length) return `cannot start: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ") || "no configuration"}`;
523 void start();
524 return "run started; call get_app_state for progress";
525 },
526 pause() { return togglePause(); },
527 stop() { if (!runner || (runState !== "running" && runState !== "paused")) return "no run in progress"; runner.stop(); return "stopping"; },
528 play(from) { return playFrom(from); },
529 setSignal(s) { if (s !== "unbaffled" && s !== "rayleigh") return "signal must be 'unbaffled' or 'rayleigh'"; signalSel.value = s; rebuildSpectrogram(); return `signal set to ${s}`; },
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 530 playMovie(factor, fromSeconds) {
531 if (!movie || movie.length < 2 || !moviePlayer) return "no displacement movie: set a movie frame rate before the run";
532 if (factor > 0) { slowdown.value = String(Math.log10(Math.min(Math.max(factor, 2), 10000))); moviePlayer.slowdown = slowdownFactor(); }
533 moviePlayer.play(Math.min(Math.max(fromSeconds, 0), movie.duration));
534 updateMovieUi();
535 return `replaying ${movie.length} frames covering ${(movie.duration * 1e3).toFixed(0)} ms of simulated time at x${slowdownFactor()} slowdown from ${fromSeconds.toFixed(4)} s`;
536 },
537 setSpectrogramAxis(scale, maxHz) {
538 if (scale === "linear" || scale === "log") { specScale.value = scale; specView.setScale(scale); }
539 if (maxHz !== undefined) { specFmax.value = String(maxHz); specView.setMaxFrequency(maxHz); }
540 return `spectrogram axis: ${specScale.value}, up to ${Number(specFmax.value) || (setup ? setup.fs / 2 : 0)} Hz`;
541 },
542 setMovieFps(fps) { movieFps.value = String(Math.max(0, Math.round(fps))); return `movie frame rate set to ${movieFps.value} fps of simulated time; it applies to the next run`; },
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 543};
544const bridgeUrl = params.get("bridge") ?? "ws://localhost:8765";
545const chatEl = $("chat"), chatSplitter = $("chat-splitter"), appEl = $("app");
546const chatPanel = new ChatPanel(chatEl, bridgeUrl);
547const chat = new ChatClient(bridgeUrl, appApi, (e) => chatPanel.handle(e));
548chatPanel.onSend = (t) => chat.sendUser(t); chatPanel.onInterrupt = () => chat.interrupt(); chatPanel.onReset = () => chat.reset();
549function showChat(open: boolean) {
550 chatEl.classList.toggle("hidden", !open); chatSplitter.classList.toggle("hidden", !open); appEl.classList.toggle("with-chat", open);
551 if (open) { chat.connect(); chatPanel.focus(); } else chat.disconnect();
552 redraw();
553}
554$("chat-toggle").onclick = () => showChat(chatEl.classList.contains("hidden"));
555chatEl.addEventListener("chat-close", () => showChat(false));
556if (params.get("chat") === "1") showChat(true);