import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts"; import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts"; import { PRESETS } from "./config/presets.ts"; import type { SimulationConfig } from "./config/schema.ts"; import { cloneConfig, configToYaml, designIssues, normalizeConfig, parseConfigYaml } from "./config/yaml.ts"; import { CpuSimulator } from "./cpu/simulator.ts"; import { derive, type Derived } from "./derived.ts"; import { DesignPanel, type Field, type Tier } from "./design.ts"; import { antinode, buildDrive, buildProjection, modalAmplitude, modalInitialState, modeShape, type Mechanism } from "./drive.ts"; import { GpuSimulator } from "./gpu/simulator.ts"; import { classifyMode } from "./modal/classify.ts"; import { ModalTracker } from "./modal/tracker.ts"; import { parseModes, type ModesData } from "./modes.ts"; import { buildSetup, type Setup } from "./model/setup.ts"; import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts"; import { structuralKey } from "./precompute/pipeline.ts"; import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts"; import { Runner } from "./run.ts"; import { Stft } from "./analysis/spectrogram.ts"; import { EnergyBars, ModalSpectrogram, StftView, type FrequencyScale } from "./ui/modalView.ts"; import { ModeList } from "./ui/modeList.ts"; import { PlateView } from "./ui/plateView.ts"; const CACHE_VERSION = 1; 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" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration"; // ---------------------------------------------------------------- state let presetYaml = PRESETS[0].yaml; let config: SimulationConfig = parseConfigYaml(presetYaml).config!; let modes: ModesData | null = null; let modesBuffer: ArrayBuffer | null = null; let modesStructural = ""; let derived: Derived | null = null; let selectedMode = -1; let worker: Worker | null = null; let token = 0, computeToken = -1, computing = false, computeStart = 0; const complementWaiters = new Map void; reject: (e: Error) => void }>(); const complementCache = new Map(); // per mode, for the unit (factor 1) shape let setup: Setup | null = null; let backend: SimulatorBackend | null = null; let runner: Runner | null = null; let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle"; let tracker: ModalTracker | null = null; let front = new Float32Array(0), back = new Float32Array(0), frontier = 0; let runDuration = 0, runFs = 44100; let cursorTime: number | null = null; // null: the bar view follows the run // ---------------------------------------------------------------- DOM const presetSel = $("preset"), yamlFile = $("yamlfile"), modesFile = $("modesfile"); const esizeSel = $("esize"), fmaxInput = $("fmax"); const computeBtn = $("compute"), downloadModesBtn = $("download-modes"); const modesProgress = $("modes-progress"), modesStatus = $("modes-status"), designStatus = $("design-status"); const mechanismSel = $("mechanism"), amplitudeInput = $("amplitude"); const dampScaleInput = $("damp-scale"), detuneInput = $("detune"); const rampInput = $("ramp"), driveTimeInput = $("drive-time"); const durationInput = $("duration"), snapRateInput = $("snap-rate"), linearBox = $("linear"); const runBtn = $("run"), pauseBtn = $("pause"), stopBtn = $("stop"); const runProgress = $("run-progress"), runProgressText = $("run-progress-text"); const driveModeEl = $("drive-mode"), driveStatus = $("drive-status"); const playBtn = $("play"), stopAudioBtn = $("stop-audio"), wavBtn = $("download-wav"); const position = $("position"), timeEl = $("time"), gain = $("gain"), normalize = $("normalize"); const signalSel = $("signal"); const followBtn = $("follow"); const logEl = $("log"); function log(text: string) { logEl.textContent = `${logEl.textContent}${text}\n`.split("\n").slice(-30).join("\n"); } 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), 10000); } // ---------------------------------------------------------------- views const plate = new PlateView($("plate"), device); const modeList = new ModeList($("modelist")); const specCanvas = $("modal-spec"); const spec = new ModalSpectrogram(specCanvas); const soundView = new StftView(specCanvas, spec.axis); const traceView = new StftView(specCanvas, spec.axis); const bars = new EnergyBars($("bars"), spec.axis); const player = new Player(); spec.axis.scale = $("freq-scale").value as FrequencyScale; spec.axis.fmax = Number($("freq-max").value); const viewSel = $("view"); type AnalysisView = ModalSpectrogram | StftView; const activeView = (): AnalysisView => (viewSel.value === "sound" ? soundView : viewSel.value === "trace" ? traceView : spec); // the STFT columns of the run so far: the listener signal (rebuilt when the signal choice // changes) and the driven mode's coordinate at the audio rate let stftSound: Stft | null = null, soundPushed = 0; let stftTrace: Stft | null = null, tracePushed = 0; let traceArr = new Float32Array(0), traceFrontier = 0; 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(); function invalidateViews() { spec.invalidate(); soundView.invalidate(); traceView.invalidate(); } for (const [sel, apply] of [ ["freq-scale", (v: string) => { spec.axis.scale = v as FrequencyScale; }], ["freq-max", (v: string) => { spec.axis.fmax = Number(v); }], ["db-range", (v: string) => { spec.dbRange = Number(v); bars.dbRange = Number(v); soundView.dbRange = Number(v); traceView.dbRange = Number(v); }], ] as const) { $(sel).onchange = () => { apply($(sel).value); invalidateViews(); redrawModal(); }; } viewSel.onchange = () => redrawModal(); spec.onHover = (t) => { $("modal-info").textContent = t; }; soundView.onHover = (t) => { $("modal-info").textContent = t; }; traceView.onHover = (t) => { $("modal-info").textContent = t; }; bars.onHover = (t) => { $("bars-info").textContent = t || barsDefaultInfo(); }; spec.onSelect = (mode, time) => { cursorTime = time; followBtn.disabled = false; selectMode(mode); updateBars(); }; soundView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); }; traceView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); }; bars.onSelect = (mode) => selectMode(mode); modeList.onSelect = (mode) => selectMode(mode); followBtn.onclick = () => { cursorTime = null; followBtn.disabled = true; updateBars(); }; function redrawModal() { spec.cursorTime = cursorTime; soundView.cursorTime = cursorTime; traceView.cursorTime = cursorTime; activeView().draw(); bars.draw(); } new ResizeObserver(() => { plate.render(); }).observe($("plate")); new ResizeObserver(() => { activeView().draw(); }).observe($("modal-spec")); new ResizeObserver(() => { bars.draw(); }).observe($("bars")); // 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); plate.render(); redrawModal(); }; sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up); }); } // ---------------------------------------------------------------- mode-shape animation let animShape: Float64Array | null = null; // physical peak displacement per node [m] let animBuf = new Float32Array(0); const animateBox = $("animate"); function prepareAnimation() { animShape = null; if (!modes || !derived || selectedMode < 0) return; const shape = modeShape(modes, selectedMode, derived.phiFactor); const a = modalAmplitude(modes, shape, Number(amplitudeInput.value) || 1); animShape = shape.map((x) => a * x); const { node } = antinode(shape); plate.colorScale = Math.abs(animShape[node]); if (animBuf.length !== shape.length) animBuf = new Float32Array(shape.length); } function animating(): boolean { return animateBox.checked && animShape !== null && runState !== "running" && runState !== "paused" && runState !== "setup"; } function animationTick(tMs: number) { if (animating()) { const c = Math.cos(2 * Math.PI * 0.7 * (tMs / 1000)); for (let n = 0; n < animShape!.length; n++) animBuf[n] = c * animShape![n]; plate.setDisplacement(animBuf); } if (player.playing) { const t = player.position(); position.value = String(runDuration > 0 ? t / runDuration : 0); timeEl.textContent = `${t.toFixed(3)} s`; } requestAnimationFrame(animationTick); } requestAnimationFrame(animationTick); animateBox.onchange = () => { if (!animating()) plate.render(); }; // ---------------------------------------------------------------- modes lifecycle function options() { return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) }; } function structuralString(cfg: SimulationConfig) { return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) }); } function stale(): boolean { return !!modes && modesStructural !== structuralString(config); } function ensureWorker(): Worker { if (!worker) { worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" }); worker.onmessage = (ev: MessageEvent) => void onWorkerMessage(ev.data); worker.onerror = (e) => { modesStatus.textContent = `worker error: ${e.message}`; }; } return worker; } async function onWorkerMessage(msg: PrecomputeReply) { if (msg.type === "progress") { const secs = (performance.now() - computeStart) / 1000; if (msg.token === computeToken) { modesProgress.style.width = `${Math.round(100 * msg.frac)}%`; modesStatus.textContent = `${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${secs.toFixed(0)} s]`; } else if (complementWaiters.has(msg.token)) { driveStatus.textContent = `condensation: ${msg.phase} ${(100 * msg.frac).toFixed(0)} %`; } } else if (msg.type === "modes") { if (msg.token !== computeToken) return; computing = false; computeBtn.disabled = false; modesProgress.style.width = "100%"; const structural = structuralString(config); await cachePut(await cacheKey(structural), msg.buffer); setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural); } else if (msg.type === "complement") { const w = complementWaiters.get(msg.token); if (w) { complementWaiters.delete(msg.token); w.resolve(msg.o); } } else if (msg.type === "gains") { // not used here: the run computes its own radiation } else { const w = complementWaiters.get(msg.token); if (w) { complementWaiters.delete(msg.token); w.reject(new Error(msg.message)); return; } computing = false; computeBtn.disabled = false; modesStatus.textContent = `error: ${msg.message}`; updateRunUi(); } } function compute() { const issues = designIssues(config); if (issues.length) { modesStatus.textContent = "the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; } computing = true; computeBtn.disabled = true; computeStart = performance.now(); computeToken = ++token; modesProgress.style.width = "0%"; modesStatus.textContent = "computing the modes..."; updateRunUi(); const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() }; ensureWorker().postMessage(req); } function setModes(buffer: ArrayBuffer, label: string, structural: string) { let parsed: ModesData; try { parsed = parseModes(buffer); } catch (err) { modesStatus.textContent = `could not read the modes: ${(err as Error).message}`; return; } modes = parsed; modesBuffer = buffer; modesStructural = structural; complementCache.clear(); derived = derive(parsed, config); downloadModesBtn.disabled = false; modesStatus.textContent = `${label}: ${parsed.nNodes} nodes, ${parsed.nModes} modes, ` + `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[parsed.nModes - 1].toFixed(0)} Hz`; modeList.setModes(parsed, derived.freq, derived.sigma); $("modelist-info").textContent = `${parsed.nModes} modes`; plate.setMesh({ n: parsed.nNodes, e: parsed.nElements, xy: parsed.xy, tri: parsed.tri }, parsed.z0, Math.min(...parsed.h), null); $("plate-info").textContent = `${parsed.nNodes} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`; selectMode(Math.min(selectedMode >= 0 ? selectedMode : 0, parsed.nModes - 1)); updateRunUi(); } async function tryCache() { if (!modes) { const buf = await cacheGet(await cacheKey(structuralString(config))); if (buf) setModes(buf, "cached modes", structuralString(config)); else modesStatus.textContent = "no modes for this cymbal yet"; } updateRunUi(); } function requestComplement(mode: number): Promise { const cached = complementCache.get(mode); if (cached) return Promise.resolve(cached); const raw = normalizeConfig(modes!.meta.config).config; const w = modeShape(modes!, mode, 1); const tok = ++token; return new Promise((resolve, reject) => { complementWaiters.set(tok, { resolve: (o) => { complementCache.set(mode, o); resolve(o); }, reject }); const req: PrecomputeRequest = { type: "complement", token: tok, config: raw, options: { elementSize: modes!.meta.playback_element_size, fmax: modes!.meta.fmax }, w }; ensureWorker().postMessage(req, [w.buffer]); }); } // ---------------------------------------------------------------- selection function selectMode(mode: number) { if (!modes || !derived || mode < 0 || mode >= modes.nModes) return; selectedMode = mode; modeList.select(mode); spec.selectedMode = mode; bars.selectedMode = mode; soundView.selectedMode = mode; traceView.selectedMode = mode; const f = derived.freq[mode], sigma = derived.sigma[mode]; const { diameters, circles } = classifyMode(modes, mode); const shape = modeShape(modes, mode, 1); const { node } = antinode(shape); const r = Math.hypot(modes.xy[2 * node], modes.xy[2 * node + 1]); driveModeEl.textContent = `mode ${mode}: ${f.toFixed(1)} Hz, T60 ${(6.907755 / sigma).toFixed(2)} s, ` + `≈ (${diameters} diameters, ${circles} circles), antinode at r = ${(r * 1e3).toFixed(0)} mm`; prepareAnimation(); redrawModal(); updateRunUi(); } // ---------------------------------------------------------------- design panel and presets const panel = new DesignPanel($("design"), () => config, onDesignChange); function describeDesign() { const issues = designIssues(config); let text = ""; if (modes && derived) { text = stale() ? "the shape changed: recompute the modes before running" : ""; } if (issues.length) text += (text ? "\n" : "") + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); designStatus.textContent = text; designStatus.className = "status" + (issues.length ? " bad" : ""); } function onDesignChange(_field: Field, tier: Tier) { if (modes && tier === "live") { derived = derive(modes, config); modeList.setDerived(derived.freq, derived.sigma); if (selectedMode >= 0) selectMode(selectedMode); } describeDesign(); updateRunUi(); } function setConfig(cfg: SimulationConfig, label: string) { config = cfg; panel.refresh(); modes = null; modesBuffer = null; derived = null; modesStructural = ""; complementCache.clear(); downloadModesBtn.disabled = true; modeList.setModes(null, null, null); modesStatus.textContent = `${label}; no modes yet`; describeDesign(); void tryCache(); } for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); } const customOpt = document.createElement("option"); customOpt.value = "custom"; customOpt.textContent = "loaded YAML"; customOpt.disabled = true; presetSel.appendChild(customOpt); const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0]; presetSel.value = initialPreset.id; presetYaml = initialPreset.yaml; presetSel.onchange = () => { const p = PRESETS.find((x) => x.id === presetSel.value); if (p) { presetYaml = p.yaml; setConfig(parseConfigYaml(p.yaml).config!, p.name); } }; $("import-yaml").onclick = () => yamlFile.click(); yamlFile.onchange = async () => { const file = yamlFile.files?.[0]; if (!file) return; const text = await file.text(); const { config: cfg, issues } = parseConfigYaml(text); if (!cfg) { modesStatus.textContent = "YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; } presetYaml = text; customOpt.disabled = false; presetSel.value = "custom"; setConfig(cfg, file.name); yamlFile.value = ""; }; $("download-yaml").onclick = () => download(new Blob([configToYaml(config)], { type: "text/yaml" }), "cymmodes_design.yaml"); computeBtn.onclick = compute; downloadModesBtn.onclick = () => { if (modesBuffer) download(new Blob([modesBuffer]), `cymmodes_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`); }; $("import-modes").onclick = () => modesFile.click(); modesFile.onchange = async () => { const file = modesFile.files?.[0]; if (!file) return; const buf = await file.arrayBuffer(); let loaded: ModesData; try { loaded = parseModes(buf); } catch (err) { modesStatus.textContent = `could not load the modes: ${(err as Error).message}`; return; } const cfg = normalizeConfig(loaded.meta.config).config; customOpt.disabled = false; presetSel.value = "custom"; config = cfg; esizeSel.value = String(loaded.meta.playback_element_size); fmaxInput.value = String(loaded.meta.fmax); panel.refresh(); setModes(buf, file.name, structuralString(config)); modesFile.value = ""; }; $("clear-cache").onclick = async () => { await cacheClear(); modesStatus.textContent = "the cache is empty"; }; for (const el of [esizeSel, fmaxInput]) el.addEventListener("change", () => { modes = null; modesBuffer = null; derived = null; modesStructural = ""; complementCache.clear(); downloadModesBtn.disabled = true; modeList.setModes(null, null, null); updateRunUi(); void tryCache(); }); function applyMechanism() { for (const el of document.querySelectorAll(".forced-only")) el.classList.toggle("hidden", mechanismSel.value !== "forced"); updateRunUi(); } mechanismSel.onchange = applyMechanism; applyMechanism(); amplitudeInput.onchange = prepareAnimation; // ---------------------------------------------------------------- the run function updateRunUi() { const issues = designIssues(config); const busy = runState === "running" || runState === "paused" || runState === "setup"; runBtn.disabled = busy || computing || !modes || stale() || selectedMode < 0 || issues.length > 0; computeBtn.disabled = computing || busy; pauseBtn.disabled = !(runState === "running" || runState === "paused"); stopBtn.disabled = !(runState === "running" || runState === "paused"); playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0; if (!modes) runBtn.title = "compute the modes first"; else if (stale()) runBtn.title = "the shape changed: recompute the modes"; else if (selectedMode < 0) runBtn.title = "select a mode"; else runBtn.title = ""; } function barsDefaultInfo(): string { if (!tracker || !tracker.length) return ""; const s = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime); if (s < 0) return ""; const top = tracker.topModes(s, 4).filter((t) => t.energy > 0); const items = top.map((t) => `${t.mode} (${(tracker!.omega[t.mode] / (2 * Math.PI)).toFixed(0)} Hz)`).join(", "); return `t = ${tracker.times[s].toFixed(2)} s${cursorTime === null ? " (following)" : ""}; strongest: ${items}`; } function updateBars() { if (!tracker || !tracker.length) { bars.snapshot = -1; redrawModal(); return; } bars.snapshot = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime); $("bars-info").textContent = barsDefaultInfo(); redrawModal(); if (bars.snapshot >= 0) modeList.setEnergies(tracker.energies[bars.snapshot], tracker.maxEnergy); } /** Push the newly arrived part of the listener signal into its STFT (rebuilt on a signal change). */ function pushSound(rebuild = false) { if (rebuild || !stftSound) { stftSound = new Stft(runFs, 2048, 256); soundPushed = 0; soundView.stft = stftSound; } if (frontier > soundPushed) { const part = new Float32Array(frontier - soundPushed); if (signalSel.value === "rayleigh") part.set(front.subarray(soundPushed, frontier)); else for (let i = soundPushed; i < frontier; i++) part[i - soundPushed] = front[i] - back[i]; if (stftSound.push(part) > 0) soundView.invalidate(); soundPushed = frontier; } } async function startRun() { if (!modes || !derived || selectedMode < 0 || stale()) return; runState = "setup"; updateRunUi(); driveStatus.textContent = "building the set-up ..."; const mech = mechanismSel.value as Mechanism; const dampScale = Math.min(1, Math.max(0, Number(dampScaleInput.value))); const cfg = cloneConfig(config); cfg.hits = []; cfg.mesh.element_size = modes.meta.playback_element_size; cfg.numerics.duration = Math.max(0.05, Number(durationInput.value) || 1); cfg.numerics.nonlinear = !linearBox.checked; cfg.material.sigma0 *= dampScale; cfg.material.sigma1 *= dampScale; try { setup = await buildSetup(cfg, async (phase) => { driveStatus.textContent = `set-up: ${phase}`; await new Promise((r) => setTimeout(r, 0)); }); } catch (err) { driveStatus.textContent = `set-up failed: ${(err as Error).message}`; runState = "idle"; updateRunUi(); return; } if (setup.mesh.n !== modes.nNodes) { driveStatus.textContent = `mesh mismatch: the set-up has ${setup.mesh.n} nodes, the modes ${modes.nNodes}; recompute the modes`; runState = "idle"; updateRunUi(); return; } runFs = setup.fs; runDuration = cfg.numerics.duration; const m = selectedMode; const shape = modeShape(modes, m, derived.phiFactor); const ampWH = Number(amplitudeInput.value) || 1; const Mw = new Float64Array(setup.mesh.n); for (let n = 0; n < setup.mesh.n; n++) Mw[n] = setup.shell.M[5 * n + 2]; const every = Math.max(1, Math.round(setup.fs / (Number(snapRateInput.value) || 240))); const projection = buildProjection(modes, Mw, derived.phiFactor, every); let drive; let initial: { q: Float64Array; v: Float64Array } | null = null; const spec_: { mode: number; mechanism: Mechanism; amplitude: number; detuneCents: number; rampTime: number; driveTime: number } = { mode: m, mechanism: mech, amplitude: ampWH, detuneCents: Number(detuneInput.value) || 0, rampTime: Math.max(0, Number(rampInput.value) || 0), driveTime: Math.max(0.05, Number(driveTimeInput.value) || 1), }; if (mech === "decay") { try { driveStatus.textContent = "condensation: in-plane and rotation complement of the mode ..."; const factor = derived.phiFactor; const o = await requestComplement(m); const of = Float64Array.from(o, (x) => x * factor); initial = modalInitialState(shape, of, modalAmplitude(modes, shape, ampWH)); } catch (err) { driveStatus.textContent = `complement failed (${(err as Error).message}); releasing the pure-w shape`; initial = modalInitialState(shape, new Float64Array(4 * setup.mesh.n), modalAmplitude(modes, shape, ampWH)); } } else { drive = buildDrive(spec_, modes, shape, Mw, derived.sigma[m], 2 * Math.PI * derived.freq[m], setup.dt); } backend?.dispose(); backend = device ? new GpuSimulator(device, setup, 8192, { drive, projection, traceMode: m }) : new CpuSimulator(setup, { drive, projection, traceMode: m }); if (initial) backend.setState(initial.q, initial.v); tracker = new ModalTracker(derived.freq, m); modeList.setDriven(m); spec.tracker = tracker; spec.duration = runDuration; spec.invalidate(); bars.tracker = tracker; bars.snapshot = -1; // the two true spectrograms: the listener signal and the driven mode's own coordinate const modeFreqs = Float64Array.from(derived.freq); for (const v of [soundView, traceView]) { v.duration = runDuration; v.modeFreqs = modeFreqs; v.drivenMode = m; v.selectedMode = m; v.cursorTime = null; v.invalidate(); } stftSound = null; soundPushed = 0; stftTrace = new Stft(setup.fs, 2048, 256); tracePushed = 0; traceView.stft = stftTrace; traceArr = new Float32Array(setup.nSamples); traceFrontier = 0; cursorTime = null; followBtn.disabled = true; front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0; pushSound(true); player.stop(); 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.colorScale = ampWH * Math.min(...modes.h); $("plate-title").textContent = "Live"; const detuneNote = mech === "forced" && spec_.detuneCents !== 0 ? `, detuned ${spec_.detuneCents} cents` : ""; log(`run: mode ${m} at ${derived.freq[m].toFixed(1)} Hz, ${mech === "decay" ? "free decay from" : "forced towards"} ` + `${ampWH} thicknesses${detuneNote}, damping x${dampScale}, ${cfg.numerics.nonlinear ? "nonlinear" : "linear"}, ${runDuration} s`); if (mech === "forced" && dampScale < 1) log(`note: the force targets the amplitude at physical damping; with damping x${dampScale} the linear response is larger`); let lastPlate = 0, lastBars = 0; runState = "running"; updateRunUi(); 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); pushSound(); } if (res.trace.length && setup && stftTrace) { const len = Math.min(res.trace.length, setup.nSamples - res.traceBase); traceArr.set(res.trace.subarray(0, len), res.traceBase); traceFrontier = res.traceBase + len; if (stftTrace.push(traceArr.subarray(tracePushed, traceFrontier)) > 0) traceView.invalidate(); tracePushed = traceFrontier; } if (tracker && res.proj.length) { tracker.addChunk(res, runFs); spec.invalidate(); } if (performance.now() - lastBars > 250) { lastBars = performance.now(); updateBars(); } playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0; if (performance.now() - lastPlate > 100) { lastPlate = performance.now(); if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w)); } }, onDiagnostics(d: Diagnostics) { const total = d.kinetic + d.membrane + d.bending; const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0; driveStatus.textContent = `t = ${d.time.toFixed(2)} s: kinetic ${d.kinetic.toExponential(2)} J, membrane ${d.membrane.toExponential(2)} J, ` + `bending ${d.bending.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}\n` + `modal sum ${modal.toExponential(2)} J (${total > 0 ? ((100 * modal) / total).toFixed(0) : "-"} % of the shell energy)`; if (!Number.isFinite(total)) { log("energy is not finite: the run has blown up (lower numerics.cfl_safety)"); runner?.stop(); } }, onProgress(done, wall) { if (!setup) return; runProgress.value = done / setup.nSamples; const audio = done / setup.fs; runProgressText.textContent = `${audio.toFixed(2)} / ${runDuration.toFixed(2)} s (${(audio / Math.max(wall, 1e-3)).toFixed(2)}x)`; }, onDone(stopped) { runState = stopped ? "stopped" : "done"; updateBars(); updateRunUi(); plate.useOwnState(); $("plate-title").textContent = "Mode shape"; log(stopped ? "stopped" : "done"); void backend?.diagnostics().then((d) => { const total = d.kinetic + d.membrane + d.bending; const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0; log(`final: shell energy ${total.toExponential(2)} J, modal sum ${modal.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}`); }); }, }); void runner.run(); } runBtn.onclick = () => void startRun(); pauseBtn.onclick = () => { if (!runner) return; if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; } else { runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; } }; stopBtn.onclick = () => runner?.stop(); // ---------------------------------------------------------------- audio 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; }; gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; }; signalSel.onchange = () => { pushSound(true); if (viewSel.value === "sound") redrawModal(); }; playBtn.onclick = () => { if (frontier === 0) return; player.setGainDb(Number(gain.value)); player.play(scaleForPlayback(signal(), normalize.checked), runFs, Number(position.value) * runDuration); stopAudioBtn.disabled = false; }; player.onEnded = () => { stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; }; stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; }; position.oninput = () => { timeEl.textContent = `${(Number(position.value) * runDuration).toFixed(3)} s`; }; wavBtn.onclick = () => { if (frontier > 0) download(encodeWav(scaleForPlayback(signal(), normalize.checked), runFs), `cymmodes_${signalSel.value}.wav`); }; // ---------------------------------------------------------------- start-up panel.refresh(); describeDesign(); updateRunUi(); void tryCache();