/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / main.ts
298 lines · 16.8 KBCodeBlameHistory
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 1import { Stft, bandSummary } from "./analysis/spectrogram.ts";
2import { ChatClient, type AppApi } from "./chat/client.ts";
3import { ChatPanel } from "./ui/chatPanel.ts";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 4import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
5import { PRESETS } from "./config/presets.ts";
6import type { SimulationConfig } from "./config/schema.ts";
7import { configToYaml, parseConfigYaml, type Issue } from "./config/yaml.ts";
8import { CpuSimulator } from "./cpu/simulator.ts";
9import { GpuSimulator } from "./gpu/simulator.ts";
10import { DECIMATION_SPAN } from "./model/radiation.ts";
11import { buildSetup, type Setup } from "./model/setup.ts";
12import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
13import { Runner } from "./run.ts";
14import { YamlEditor } from "./ui/editor.ts";
15import { ConfigForm } from "./ui/form.ts";
16import { PlateView } from "./ui/plateView.ts";
17import { Readouts, formatSummary } from "./ui/readouts.ts";
18import { SpectrogramView } from "./ui/spectrogramView.ts";
20const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
21const params = new URLSearchParams(location.search);
23// ---------------------------------------------------------------- WebGPU device
24let device: GPUDevice | null = null;
25if (params.get("backend") !== "cpu" && "gpu" in navigator) {
26 try {
27 const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
28 device = (await adapter?.requestDevice()) ?? null;
29 } catch (err) { console.warn("WebGPU unavailable", err); }
31$("backend").textContent = device ? "backend: WebGPU compute (time loop on the GPU)" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
33// ---------------------------------------------------------------- config editing (YAML <-> form)
34let config: SimulationConfig | null = null;
35let issues: Issue[] = [];
36const issuesEl = $<HTMLUListElement>("issues");
37function showIssues() {
38 issuesEl.replaceChildren();
39 if (!issues.length) { const li = document.createElement("li"); li.className = "ok"; li.textContent = "configuration OK"; issuesEl.appendChild(li); return; }
40 for (const i of issues) { const li = document.createElement("li"); li.innerHTML = `<code>${i.path || "document"}</code>: ${i.message}`; issuesEl.appendChild(li); }
42const presetSel = $<HTMLSelectElement>("preset");
43for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
44const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
45presetSel.value = initialPreset.id;
47let form: ConfigForm;
48function fromYaml(text: string) {
49 const r = parseConfigYaml(text);
50 issues = r.issues;
51 if (r.config) { config = r.config; form.setConfig(config); }
52 showIssues();
54const editor = new YamlEditor($("editor"), initialPreset.yaml, fromYaml);
55form = new ConfigForm($("form"), parseConfigYaml(initialPreset.yaml).config!, (cfg) => {
56 config = cfg;
57 editor.setText(configToYaml(cfg));
58 issues = parseConfigYaml(editor.text).issues; showIssues();
59});
60fromYaml(initialPreset.yaml);
61presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value)!; editor.setText(p.yaml); fromYaml(p.yaml); };
62$("import").onclick = () => $<HTMLInputElement>("file").click();
63$<HTMLInputElement>("file").onchange = async (ev) => {
64 const file = (ev.target as HTMLInputElement).files?.[0];
65 if (!file) return;
66 const text = await file.text();
67 editor.setText(text); fromYaml(text);
68 (ev.target as HTMLInputElement).value = "";
69};
70function download(blob: Blob, name: string) {
71 const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
72 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
74$("download-yaml").onclick = () => download(new Blob([editor.text], { type: "text/yaml" }), `${config?.output.prefix ?? "cymbal"}.yaml`);
75for (const b of document.querySelectorAll<HTMLButtonElement>(".tabs button")) {
76 b.onclick = () => {
77 for (const x of document.querySelectorAll<HTMLButtonElement>(".tabs button")) x.classList.toggle("active", x === b);
78 for (const t of document.querySelectorAll<HTMLElement>(".tab")) t.classList.toggle("hidden", t.id !== `tab-${b.dataset.tab}`);
79 };
82// ---------------------------------------------------------------- views
83const plate = new PlateView($("plate"), device);
84const specView = new SpectrogramView($("spectrogram"));
85const readouts = new Readouts($("readouts"));
86const player = new Player();
87const exag = $<HTMLInputElement>("exag");
88const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
89exag.oninput = applyExag; applyExag();
90$("reset-view").onclick = () => plate.resetView();
cffea67UI: exaggeration defaults to x1, play position returns to 0 after playback, resizable panesJeremy Magland 91const redraw = () => { plate.render(); specView.draw(); };
92new ResizeObserver(redraw).observe($("plate"));
93new ResizeObserver(redraw).observe($("spectrogram"));
95// splitters: drag to set the CSS variable that sizes the neighbouring pane
96for (const sp of document.querySelectorAll<HTMLElement>(".splitter")) {
97 const target = $(sp.dataset.target!), cssVar = sp.dataset.var!, vertical = sp.classList.contains("vertical"), invert = sp.dataset.invert === "1";
98 sp.addEventListener("pointerdown", (e) => {
99 e.preventDefault();
100 sp.setPointerCapture(e.pointerId); sp.classList.add("active");
101 const start = vertical ? e.clientX : e.clientY, size0 = vertical ? target.getBoundingClientRect().width : target.getBoundingClientRect().height;
102 const move = (ev: PointerEvent) => {
103 const delta = (vertical ? ev.clientX : ev.clientY) - start;
104 const size = Math.max(60, size0 + (invert ? -delta : delta));
105 document.documentElement.style.setProperty(cssVar, `${size}px`);
106 };
107 const up = () => { sp.classList.remove("active"); sp.removeEventListener("pointermove", move); sp.removeEventListener("pointerup", up); redraw(); };
108 sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up);
109 });
112// ---------------------------------------------------------------- run state
113let setup: Setup | null = null;
114let runner: Runner | null = null;
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 115let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle";
116let lastDiagnostics: Diagnostics | null = null;
117let lastProgress = { done: 0, wall: 0 };
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 118let backend: SimulatorBackend | null = null;
119let front = new Float32Array(0), back = new Float32Array(0), frontier = 0, pushed = 0;
120let stft: Stft | null = null;
121const signalSel = $<HTMLSelectElement>("signal");
122const signal = () => {
123 const out = new Float32Array(frontier);
124 if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
125 else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
126 return out;
127};
128function rebuildSpectrogram() {
129 if (!setup) return;
130 stft = new Stft(setup.fs, 1024, 256); pushed = 0;
131 specView.reset(stft, setup.config.numerics.duration);
132 pushSpectrogram();
134function pushSpectrogram() {
135 if (!stft || !setup) return;
136 if (frontier > pushed) { const s = signal(); stft.push(s.subarray(pushed, frontier)); pushed = frontier; }
137 specView.frontierTime = frontier / setup.fs;
138 specView.update();
139 const c = stft.centroid.length ? stft.centroid[stft.centroid.length - 1] : 0;
140 $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`;
142signalSel.onchange = rebuildSpectrogram;
144const startBtn = $<HTMLButtonElement>("start"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
145const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
146const progress = $<HTMLProgressElement>("progress");
148async function start() {
149 if (!config || issues.length) { readouts.log("fix the configuration issues first"); return; }
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 150 startBtn.disabled = true; presetSel.disabled = true; runState = "setup";
152 const cfg = structuredClone(config);
153 try {
154 readouts.log("building the set-up ...");
155 setup = await buildSetup(cfg, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); });
156 } catch (err) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 157 readouts.log(`set-up failed: ${(err as Error).message}`); startBtn.disabled = false; presetSel.disabled = false; runState = "idle"; return;
159 $("summary").textContent = formatSummary(setup.summary);
160 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`);
161 if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`);
162 backend?.dispose();
163 backend = device ? new GpuSimulator(device, setup, 8192) : new CpuSimulator(setup);
164 plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null);
165 $("plate-info").textContent = `${setup.mesh.n} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
166 front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
167 rebuildSpectrogram();
168 player.stop(); playBtn.disabled = true; wavBtn.disabled = true;
169 pauseBtn.disabled = false; stopBtn.disabled = false; pauseBtn.textContent = "Pause";
170 const kinds = setup.patches.map((p) => p.hit.exciter.kind);
171 let lastPlate = 0;
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 172 runState = "running"; lastDiagnostics = null; lastProgress = { done: 0, wall: 0 };
174 onChunk(res: ChunkResult) {
175 const n = res.pressureFront.length;
176 if (n && setup) {
177 front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
178 back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
179 frontier = Math.min(setup.nSamples, res.firstSample + n);
180 }
181 pushSpectrogram();
182 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
183 if (performance.now() - lastPlate > 100) {
184 lastPlate = performance.now();
185 if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
186 }
187 },
188 onDiagnostics(d: Diagnostics) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 189 lastDiagnostics = d; readouts.setDiagnostics(d, kinds);
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 190 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(); }
191 },
192 onProgress(done, wall) {
195 progress.value = done / setup.nSamples;
196 const audio = done / setup.fs;
197 $("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)`;
198 },
199 onDone(stopped) {
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 200 runState = stopped ? "stopped" : "done";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 201 readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)`);
202 startBtn.disabled = false; presetSel.disabled = false; pauseBtn.disabled = true; stopBtn.disabled = true;
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 203 void backend?.diagnostics().then((d) => { lastDiagnostics = d; readouts.setDiagnostics(d, kinds); });
205 });
206 void runner.run();
208startBtn.onclick = () => void start();
210 if (!runner || runState !== "running" && runState !== "paused") return "no run in progress";
211 if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; return "continued"; }
212 runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; return "paused";
214pauseBtn.onclick = () => { togglePause(); };
217// ---------------------------------------------------------------- playback of the completed portion
218const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
219gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
220function tick() {
221 if (player.playing && setup) {
222 const t = player.position();
223 position.value = String(t / setup.config.numerics.duration); timeEl.textContent = `${t.toFixed(3)} s`;
224 specView.cursorTime = t; specView.draw();
225 }
226 requestAnimationFrame(tick);
228requestAnimationFrame(tick);
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 229function playFrom(seconds: number): string {
230 if (!setup || frontier === 0) return "no audio yet";
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 232 player.play(scaleForPlayback(signal(), normalize.checked), setup.fs, seconds);
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 234 return `playing ${signalSel.value} from ${seconds.toFixed(2)} s (${(frontier / setup.fs).toFixed(2)} s available)`;
236playBtn.onclick = () => { playFrom(Number(position.value) * (setup?.config.numerics.duration ?? 0)); };
238 // playback reached the end of the available signal: return the position to the start
239 stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; specView.cursorTime = null; specView.draw();
240};
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 241stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); };
242position.oninput = () => { if (setup) { const t = Number(position.value) * setup.config.numerics.duration; timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } };
243wavBtn.onclick = () => { if (setup) download(encodeWav(scaleForPlayback(signal(), normalize.checked), setup.fs), `${setup.config.output.prefix}_${signalSel.value}.wav`); };
245if (params.get("autostart") === "1") void start();
247// ---------------------------------------------------------------- chat with Claude Code through the local bridge
248const appApi: AppApi = {
249 async getState() {
250 const yamlIssues = parseConfigYaml(editor.text).issues;
251 let spectrogram: unknown = null;
252 if (stft && stft.columns.length) spectrogram = bandSummary(stft);
253 return {
254 yaml: editor.text, issues: yamlIssues, preset: presetSel.value,
255 backend: device ? "webgpu" : "cpu",
256 run: {
257 state: runState, samplesDone: lastProgress.done, samplesTotal: setup?.nSamples ?? 0,
258 audioSeconds: setup ? lastProgress.done / setup.fs : 0, wallSeconds: lastProgress.wall,
259 audioAvailableSeconds: setup ? frontier / setup.fs : 0, signal: signalSel.value,
260 },
261 setup: setup ? formatSummary(setup.summary) : null,
262 diagnostics: lastDiagnostics ? { time: lastDiagnostics.time, kinetic: lastDiagnostics.kinetic, membrane: lastDiagnostics.membrane, bending: lastDiagnostics.bending,
263 exciters: lastDiagnostics.exciters, maxWoverH: lastDiagnostics.maxWoverH,
264 hits: lastDiagnostics.hits.map((h, k) => ({ kind: setup?.patches[k].hit.exciter.kind, peakForceN: h.peakForce, contactMs: h.contactTime * 1e3, exciterVelocity: h.velocity })) } : null,
265 spectrogram,
266 };
267 },
268 setYaml(text) { editor.setText(text); fromYaml(text); return { issues }; },
269 selectPreset(id) {
270 const p = PRESETS.find((x) => x.id === id);
271 if (!p) return `unknown preset ${id}; known: ${PRESETS.map((x) => x.id).join(", ")}`;
272 presetSel.value = id; editor.setText(p.yaml); fromYaml(p.yaml);
273 return `loaded preset ${id}`;
274 },
275 async start() {
276 if (runState === "running" || runState === "paused" || runState === "setup") return `a run is already ${runState}`;
277 if (!config || issues.length) return `cannot start: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ") || "no configuration"}`;
278 void start();
279 return "run started; call get_app_state for progress";
280 },
281 pause() { return togglePause(); },
282 stop() { if (!runner || (runState !== "running" && runState !== "paused")) return "no run in progress"; runner.stop(); return "stopping"; },
283 play(from) { return playFrom(from); },
284 setSignal(s) { if (s !== "unbaffled" && s !== "rayleigh") return "signal must be 'unbaffled' or 'rayleigh'"; signalSel.value = s; rebuildSpectrogram(); return `signal set to ${s}`; },
285};
286const bridgeUrl = params.get("bridge") ?? "ws://localhost:8765";
287const chatEl = $("chat"), chatSplitter = $("chat-splitter"), appEl = $("app");
288const chatPanel = new ChatPanel(chatEl, bridgeUrl);
289const chat = new ChatClient(bridgeUrl, appApi, (e) => chatPanel.handle(e));
290chatPanel.onSend = (t) => chat.sendUser(t); chatPanel.onInterrupt = () => chat.interrupt(); chatPanel.onReset = () => chat.reset();
291function showChat(open: boolean) {
292 chatEl.classList.toggle("hidden", !open); chatSplitter.classList.toggle("hidden", !open); appEl.classList.toggle("with-chat", open);
293 if (open) { chat.connect(); chatPanel.focus(); } else chat.disconnect();
294 redraw();
296$("chat-toggle").onclick = () => showChat(chatEl.classList.contains("hidden"));
297chatEl.addEventListener("chat-close", () => showChat(false));
298if (params.get("chat") === "1") showChat(true);
↑↓ move↵openescclose