import { Movie, MoviePlayer, DEFAULT_MOVIE_BUDGET, movieEvery } from "./analysis/movie.ts"; import { Stft, bandSummary } from "./analysis/spectrogram.ts"; import { ChatClient, type AppApi } from "./chat/client.ts"; import { ChatPanel } from "./ui/chatPanel.ts"; import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts"; import { PRESETS } from "./config/presets.ts"; import type { SimulationConfig } from "./config/schema.ts"; import { configToYaml, parseConfigYaml, type Issue } from "./config/yaml.ts"; import { CpuSimulator } from "./cpu/simulator.ts"; import { GpuSimulator } from "./gpu/simulator.ts"; import { DECIMATION_SPAN } from "./model/radiation.ts"; import { buildSetup, type Setup } from "./model/setup.ts"; import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts"; import type { FrequencyScale } from "./ui/spectrogramView.ts"; import { Runner } from "./run.ts"; import { YamlEditor } from "./ui/editor.ts"; import { ConfigForm } from "./ui/form.ts"; import { PlateView } from "./ui/plateView.ts"; import { Readouts, formatSummary } from "./ui/readouts.ts"; import { SpectrogramView } from "./ui/spectrogramView.ts"; const $ = (id: string) => document.getElementById(id) as T; const params = new URLSearchParams(location.search); // ---------------------------------------------------------------- WebGPU device let device: GPUDevice | null = null; if (params.get("backend") !== "cpu" && "gpu" in navigator) { try { const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); device = (await adapter?.requestDevice()) ?? null; } catch (err) { console.warn("WebGPU unavailable", err); } } $("backend").textContent = device ? "backend: WebGPU compute (time loop on the GPU)" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration"; // ---------------------------------------------------------------- config editing (YAML <-> form) let config: SimulationConfig | null = null; let issues: Issue[] = []; const issuesEl = $("issues"); function showIssues() { issuesEl.replaceChildren(); if (!issues.length) { const li = document.createElement("li"); li.className = "ok"; li.textContent = "configuration OK"; issuesEl.appendChild(li); return; } for (const i of issues) { const li = document.createElement("li"); li.innerHTML = `${i.path || "document"}: ${i.message}`; issuesEl.appendChild(li); } } const presetSel = $("preset"); for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); } const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0]; presetSel.value = initialPreset.id; let form: ConfigForm; function fromYaml(text: string) { const r = parseConfigYaml(text); issues = r.issues; if (r.config) { config = r.config; form.setConfig(config); } showIssues(); } const editor = new YamlEditor($("editor"), initialPreset.yaml, fromYaml); form = new ConfigForm($("form"), parseConfigYaml(initialPreset.yaml).config!, (cfg) => { config = cfg; editor.setText(configToYaml(cfg)); issues = parseConfigYaml(editor.text).issues; showIssues(); }); fromYaml(initialPreset.yaml); presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value)!; editor.setText(p.yaml); fromYaml(p.yaml); }; $("import").onclick = () => $("file").click(); $("file").onchange = async (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; const text = await file.text(); editor.setText(text); fromYaml(text); (ev.target as HTMLInputElement).value = ""; }; function download(blob: Blob, name: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } $("download-yaml").onclick = () => download(new Blob([editor.text], { type: "text/yaml" }), `${config?.output.prefix ?? "cymbal"}.yaml`); for (const b of document.querySelectorAll(".tabs button")) { b.onclick = () => { for (const x of document.querySelectorAll(".tabs button")) x.classList.toggle("active", x === b); for (const t of document.querySelectorAll(".tab")) t.classList.toggle("hidden", t.id !== `tab-${b.dataset.tab}`); }; } // ---------------------------------------------------------------- views const plate = new PlateView($("plate"), device); const specView = new SpectrogramView($("spectrogram")); const readouts = new Readouts($("readouts")); const player = new Player(); const exag = $("exag"); const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); }; exag.oninput = applyExag; applyExag(); $("reset-view").onclick = () => plate.resetView(); const redraw = () => { plate.render(); specView.draw(); }; new ResizeObserver(redraw).observe($("plate")); new ResizeObserver(redraw).observe($("spectrogram")); // splitters: drag to set the CSS variable that sizes the neighbouring pane for (const sp of document.querySelectorAll(".splitter")) { const target = $(sp.dataset.target!), cssVar = sp.dataset.var!, vertical = sp.classList.contains("vertical"), invert = sp.dataset.invert === "1"; sp.addEventListener("pointerdown", (e) => { e.preventDefault(); sp.setPointerCapture(e.pointerId); sp.classList.add("active"); const start = vertical ? e.clientX : e.clientY, size0 = vertical ? target.getBoundingClientRect().width : target.getBoundingClientRect().height; const move = (ev: PointerEvent) => { const delta = (vertical ? ev.clientX : ev.clientY) - start; const size = Math.max(60, size0 + (invert ? -delta : delta)); document.documentElement.style.setProperty(cssVar, `${size}px`); }; const up = () => { sp.classList.remove("active"); sp.removeEventListener("pointermove", move); sp.removeEventListener("pointerup", up); redraw(); }; sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up); }); } // ---------------------------------------------------------------- run state let setup: Setup | null = null; let runner: Runner | null = null; let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle"; let lastDiagnostics: Diagnostics | null = null; let lastProgress = { done: 0, wall: 0 }; let backend: SimulatorBackend | null = null; let front = new Float32Array(0), back = new Float32Array(0), frontier = 0, pushed = 0; let stft: Stft | null = null; const signalSel = $("signal"); const signal = () => { const out = new Float32Array(frontier); if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier)); else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i]; return out; }; function rebuildSpectrogram() { if (!setup) return; stft = new Stft(setup.fs, 1024, 256); pushed = 0; specView.reset(stft, setup.config.numerics.duration); pushSpectrogram(); } function pushSpectrogram() { if (!stft || !setup) return; if (frontier > pushed) { const s = signal(); stft.push(s.subarray(pushed, frontier)); pushed = frontier; } specView.frontierTime = frontier / setup.fs; specView.update(); const c = stft.centroid.length ? stft.centroid[stft.centroid.length - 1] : 0; $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`; } signalSel.onchange = rebuildSpectrogram; const specScale = $("spec-scale"), specFmax = $("spec-fmax"); specScale.onchange = () => specView.setScale(specScale.value as FrequencyScale); specFmax.onchange = () => specView.setMaxFrequency(Number(specFmax.value)); specView.setScale(specScale.value as FrequencyScale); // ---- displacement movie let movie: Movie | null = null; let moviePlayer: MoviePlayer | null = null; const frameBuf = { w: new Float32Array(0) }; const movieFps = $("movie-fps"), moviePlay = $("movie-play"), movieStop = $("movie-stop"); const slowdown = $("slowdown"), moviePos = $("movie-pos"), movieInfo = $("movie-info"); const slowdownFactor = () => Math.round(Math.pow(10, Number(slowdown.value))); function showFrame(index: number, time: number) { if (!movie || !movie.length) return; if (frameBuf.w.length !== movie.nodes) frameBuf.w = new Float32Array(movie.nodes); plate.setDisplacement(movie.read(index, frameBuf.w)); moviePos.value = String(movie.duration > 0 ? time / movie.duration : 0); 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" : ""}`; } function updateMovieUi() { const have = !!movie && movie.length > 1; moviePlay.disabled = !have; moviePos.disabled = !have; movieStop.disabled = !(moviePlayer?.playing ?? false); $("slowdown-text").textContent = `x${slowdownFactor()}`; if (!have && movie) movieInfo.textContent = `${movie.length} frames`; } slowdown.oninput = () => { if (moviePlayer) moviePlayer.slowdown = slowdownFactor(); updateMovieUi(); if (movie?.length) showFrame(movie.indexAtTime(moviePlayer?.time ?? 0), moviePlayer?.time ?? 0); }; moviePlay.onclick = () => { if (!moviePlayer) return; moviePlayer.slowdown = slowdownFactor(); moviePlayer.play(); updateMovieUi(); }; movieStop.onclick = () => { moviePlayer?.stop(); updateMovieUi(); }; moviePos.oninput = () => { if (!movie || !moviePlayer) return; moviePlayer.stop(); moviePlayer.seek(Number(moviePos.value) * movie.duration); updateMovieUi(); }; updateMovieUi(); const startBtn = $("start"), pauseBtn = $("pause"), stopBtn = $("stop"); const playBtn = $("play"), stopAudioBtn = $("stop-audio"), wavBtn = $("download-wav"); const progress = $("progress"); async function start() { if (!config || issues.length) { readouts.log("fix the configuration issues first"); return; } startBtn.disabled = true; presetSel.disabled = true; runState = "setup"; readouts.clearLog(); const cfg = structuredClone(config); try { readouts.log("building the set-up ..."); setup = await buildSetup(cfg, async (phase) => { readouts.log(` ${phase}`); await new Promise((r) => setTimeout(r, 0)); }); } catch (err) { readouts.log(`set-up failed: ${(err as Error).message}`); startBtn.disabled = false; presetSel.disabled = false; runState = "idle"; return; } $("summary").textContent = formatSummary(setup.summary); 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`); if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`); backend?.dispose(); const fps = Math.max(0, Number(movieFps.value) || 0); const every = fps > 0 ? movieEvery(setup.fs, fps) : 0; backend = device ? new GpuSimulator(device, setup, 8192, every) : new CpuSimulator(setup, every); moviePlayer?.stop(); movie = every > 0 ? new Movie(setup.mesh.n, setup.fs, { fps, budgetBytes: DEFAULT_MOVIE_BUDGET }) : null; moviePlayer = movie ? new MoviePlayer(movie, showFrame) : null; if (movie) readouts.log(`movie: capturing every ${every} samples (${(setup.fs / every).toFixed(0)} fps of simulated time, ${((setup.nSamples / every) * setup.mesh.n * 2 / 1e6).toFixed(0)} MB at full duration)`); updateMovieUi(); plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null); $("plate-info").textContent = `${setup.mesh.n} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`; front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0; rebuildSpectrogram(); player.stop(); playBtn.disabled = true; wavBtn.disabled = true; pauseBtn.disabled = false; stopBtn.disabled = false; pauseBtn.textContent = "Pause"; const kinds = setup.patches.map((p) => p.hit.exciter.kind); let lastPlate = 0; runState = "running"; lastDiagnostics = null; lastProgress = { done: 0, wall: 0 }; runner = new Runner(backend, setup, { onChunk(res: ChunkResult) { const n = res.pressureFront.length; if (n && setup) { front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample); back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample); frontier = Math.min(setup.nSamples, res.firstSample + n); } if (movie) { for (let f = 0; f < res.frames.length; f++) movie.push(res.frames[f], res.frameSamples[f]); if (!moviePlayer?.playing) updateMovieUi(); } pushSpectrogram(); playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0; if (performance.now() - lastPlate > 100 && !moviePlayer?.playing) { lastPlate = performance.now(); if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w)); } }, onDiagnostics(d: Diagnostics) { lastDiagnostics = d; readouts.setDiagnostics(d, kinds); 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(); } }, onProgress(done, wall) { lastProgress = { done, wall }; if (!setup) return; progress.value = done / setup.nSamples; const audio = done / setup.fs; $("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)`; }, onDone(stopped) { runState = stopped ? "stopped" : "done"; if (movie && movie.length) { plate.useOwnState(); moviePlayer!.seek(0); } updateMovieUi(); readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)`); startBtn.disabled = false; presetSel.disabled = false; pauseBtn.disabled = true; stopBtn.disabled = true; void backend?.diagnostics().then((d) => { lastDiagnostics = d; readouts.setDiagnostics(d, kinds); }); }, }); void runner.run(); } startBtn.onclick = () => void start(); function togglePause(): string { if (!runner || runState !== "running" && runState !== "paused") return "no run in progress"; if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; return "continued"; } runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; return "paused"; } pauseBtn.onclick = () => { togglePause(); }; stopBtn.onclick = () => runner?.stop(); // ---------------------------------------------------------------- playback of the completed portion const position = $("position"), timeEl = $("time"), gain = $("gain"), normalize = $("normalize"); gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; }; function tick() { if (player.playing && setup) { const t = player.position(); position.value = String(t / setup.config.numerics.duration); timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } requestAnimationFrame(tick); } requestAnimationFrame(tick); function playFrom(seconds: number): string { if (!setup || frontier === 0) return "no audio yet"; player.setGainDb(Number(gain.value)); player.play(scaleForPlayback(signal(), normalize.checked), setup.fs, seconds); stopAudioBtn.disabled = false; return `playing ${signalSel.value} from ${seconds.toFixed(2)} s (${(frontier / setup.fs).toFixed(2)} s available)`; } playBtn.onclick = () => { playFrom(Number(position.value) * (setup?.config.numerics.duration ?? 0)); }; player.onEnded = () => { // playback reached the end of the available signal: return the position to the start stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; specView.cursorTime = null; specView.draw(); }; stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; specView.cursorTime = null; specView.draw(); }; position.oninput = () => { if (setup) { const t = Number(position.value) * setup.config.numerics.duration; timeEl.textContent = `${t.toFixed(3)} s`; specView.cursorTime = t; specView.draw(); } }; wavBtn.onclick = () => { if (setup) download(encodeWav(scaleForPlayback(signal(), normalize.checked), setup.fs), `${setup.config.output.prefix}_${signalSel.value}.wav`); }; if (params.get("autostart") === "1") void start(); // ---------------------------------------------------------------- chat with Claude Code through the local bridge const appApi: AppApi = { async getState() { const yamlIssues = parseConfigYaml(editor.text).issues; let spectrogram: unknown = null; if (stft && stft.columns.length) spectrogram = bandSummary(stft); return { yaml: editor.text, issues: yamlIssues, preset: presetSel.value, backend: device ? "webgpu" : "cpu", run: { state: runState, samplesDone: lastProgress.done, samplesTotal: setup?.nSamples ?? 0, audioSeconds: setup ? lastProgress.done / setup.fs : 0, wallSeconds: lastProgress.wall, audioAvailableSeconds: setup ? frontier / setup.fs : 0, signal: signalSel.value, }, setup: setup ? formatSummary(setup.summary) : null, 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, spectrogramAxis: { scale: specScale.value, maxFrequencyHz: Number(specFmax.value) || (setup ? setup.fs / 2 : 0) }, diagnostics: lastDiagnostics ? { time: lastDiagnostics.time, kinetic: lastDiagnostics.kinetic, membrane: lastDiagnostics.membrane, bending: lastDiagnostics.bending, exciters: lastDiagnostics.exciters, maxWoverH: lastDiagnostics.maxWoverH, hits: lastDiagnostics.hits.map((h, k) => ({ kind: setup?.patches[k].hit.exciter.kind, peakForceN: h.peakForce, contactMs: h.contactTime * 1e3, exciterVelocity: h.velocity })) } : null, spectrogram, }; }, setYaml(text) { editor.setText(text); fromYaml(text); return { issues }; }, selectPreset(id) { const p = PRESETS.find((x) => x.id === id); if (!p) return `unknown preset ${id}; known: ${PRESETS.map((x) => x.id).join(", ")}`; presetSel.value = id; editor.setText(p.yaml); fromYaml(p.yaml); return `loaded preset ${id}`; }, async start() { if (runState === "running" || runState === "paused" || runState === "setup") return `a run is already ${runState}`; if (!config || issues.length) return `cannot start: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ") || "no configuration"}`; void start(); return "run started; call get_app_state for progress"; }, pause() { return togglePause(); }, stop() { if (!runner || (runState !== "running" && runState !== "paused")) return "no run in progress"; runner.stop(); return "stopping"; }, play(from) { return playFrom(from); }, setSignal(s) { if (s !== "unbaffled" && s !== "rayleigh") return "signal must be 'unbaffled' or 'rayleigh'"; signalSel.value = s; rebuildSpectrogram(); return `signal set to ${s}`; }, playMovie(factor, fromSeconds) { if (!movie || movie.length < 2 || !moviePlayer) return "no displacement movie: set a movie frame rate before the run"; if (factor > 0) { slowdown.value = String(Math.log10(Math.min(Math.max(factor, 2), 10000))); moviePlayer.slowdown = slowdownFactor(); } moviePlayer.play(Math.min(Math.max(fromSeconds, 0), movie.duration)); updateMovieUi(); return `replaying ${movie.length} frames covering ${(movie.duration * 1e3).toFixed(0)} ms of simulated time at x${slowdownFactor()} slowdown from ${fromSeconds.toFixed(4)} s`; }, setSpectrogramAxis(scale, maxHz) { if (scale === "linear" || scale === "log") { specScale.value = scale; specView.setScale(scale); } if (maxHz !== undefined) { specFmax.value = String(maxHz); specView.setMaxFrequency(maxHz); } return `spectrogram axis: ${specScale.value}, up to ${Number(specFmax.value) || (setup ? setup.fs / 2 : 0)} Hz`; }, 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`; }, }; const bridgeUrl = params.get("bridge") ?? "ws://localhost:8765"; const chatEl = $("chat"), chatSplitter = $("chat-splitter"), appEl = $("app"); const chatPanel = new ChatPanel(chatEl, bridgeUrl); const chat = new ChatClient(bridgeUrl, appApi, (e) => chatPanel.handle(e)); chatPanel.onSend = (t) => chat.sendUser(t); chatPanel.onInterrupt = () => chat.interrupt(); chatPanel.onReset = () => chat.reset(); function showChat(open: boolean) { chatEl.classList.toggle("hidden", !open); chatSplitter.classList.toggle("hidden", !open); appEl.classList.toggle("with-chat", open); if (open) { chat.connect(); chatPanel.focus(); } else chat.disconnect(); redraw(); } $("chat-toggle").onclick = () => showChat(chatEl.classList.contains("hidden")); chatEl.addEventListener("chat-close", () => showChat(false)); if (params.get("chat") === "1") showChat(true);