8354482cymmodes: linear modes of a cymsim cymbal driven in the full nonlinear model, with the energy of every mode over timeJeremy Magland 1import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
2import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts";
3import { PRESETS } from "./config/presets.ts";
4import type { SimulationConfig } from "./config/schema.ts";
5import { cloneConfig, configToYaml, designIssues, normalizeConfig, parseConfigYaml } from "./config/yaml.ts";
6import { CpuSimulator } from "./cpu/simulator.ts";
7import { derive, type Derived } from "./derived.ts";
8import { DesignPanel, type Field, type Tier } from "./design.ts";
9import { antinode, buildDrive, buildProjection, modalAmplitude, modalInitialState, modeShape, type Mechanism } from "./drive.ts";
10import { GpuSimulator } from "./gpu/simulator.ts";
11import { classifyMode } from "./modal/classify.ts";
12import { ModalTracker } from "./modal/tracker.ts";
13import { parseModes, type ModesData } from "./modes.ts";
14import { buildSetup, type Setup } from "./model/setup.ts";
15import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
16import { structuralKey } from "./precompute/pipeline.ts";
17import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts";
18import { Runner } from "./run.ts";
19import { Stft } from "./analysis/spectrogram.ts";
20import { EnergyBars, ModalSpectrogram, StftView, type FrequencyScale } from "./ui/modalView.ts";
21import { ModeList } from "./ui/modeList.ts";
22import { PlateView } from "./ui/plateView.ts";
24const CACHE_VERSION = 1;
25const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
26const params = new URLSearchParams(location.search);
28// ---------------------------------------------------------------- WebGPU device
29let device: GPUDevice | null = null;
30if (params.get("backend") !== "cpu" && "gpu" in navigator) {
31 try {
32 const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
33 device = (await adapter?.requestDevice()) ?? null;
34 } catch (err) { console.warn("WebGPU unavailable", err); }
35}
36$("backend").textContent = device ? "backend: WebGPU compute" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
38// ---------------------------------------------------------------- state
39let presetYaml = PRESETS[0].yaml;
40let config: SimulationConfig = parseConfigYaml(presetYaml).config!;
41let modes: ModesData | null = null;
42let modesBuffer: ArrayBuffer | null = null;
43let modesStructural = "";
44let derived: Derived | null = null;
45let selectedMode = -1;
46let worker: Worker | null = null;
47let token = 0, computeToken = -1, computing = false, computeStart = 0;
48const complementWaiters = new Map<number, { resolve: (o: Float64Array) => void; reject: (e: Error) => void }>();
49const complementCache = new Map<number, Float64Array>(); // per mode, for the unit (factor 1) shape
51let setup: Setup | null = null;
52let backend: SimulatorBackend | null = null;
53let runner: Runner | null = null;
54let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle";
55let tracker: ModalTracker | null = null;
56let front = new Float32Array(0), back = new Float32Array(0), frontier = 0;
57let runDuration = 0, runFs = 44100;
58let cursorTime: number | null = null; // null: the bar view follows the run
60// ---------------------------------------------------------------- DOM
61const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile"), modesFile = $<HTMLInputElement>("modesfile");
62const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
63const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
64const modesProgress = $("modes-progress"), modesStatus = $("modes-status"), designStatus = $("design-status");
65const mechanismSel = $<HTMLSelectElement>("mechanism"), amplitudeInput = $<HTMLInputElement>("amplitude");
66const dampScaleInput = $<HTMLInputElement>("damp-scale"), detuneInput = $<HTMLInputElement>("detune");
67const rampInput = $<HTMLInputElement>("ramp"), driveTimeInput = $<HTMLInputElement>("drive-time");
68const durationInput = $<HTMLInputElement>("duration"), snapRateInput = $<HTMLInputElement>("snap-rate"), linearBox = $<HTMLInputElement>("linear");
69const runBtn = $<HTMLButtonElement>("run"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
70const runProgress = $<HTMLProgressElement>("run-progress"), runProgressText = $("run-progress-text");
71const driveModeEl = $("drive-mode"), driveStatus = $("drive-status");
72const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
73const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
74const signalSel = $<HTMLSelectElement>("signal");
75const followBtn = $<HTMLButtonElement>("follow");
76const logEl = $("log");
78function log(text: string) {
79 logEl.textContent = `${logEl.textContent}${text}\n`.split("\n").slice(-30).join("\n");
80}
81function download(blob: Blob, name: string) {
82 const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
83 setTimeout(() => URL.revokeObjectURL(a.href), 10000);
84}
86// ---------------------------------------------------------------- views
87const plate = new PlateView($<HTMLCanvasElement>("plate"), device);
88const modeList = new ModeList($("modelist"));
89const specCanvas = $<HTMLCanvasElement>("modal-spec");
90const spec = new ModalSpectrogram(specCanvas);
91const soundView = new StftView(specCanvas, spec.axis);
92const traceView = new StftView(specCanvas, spec.axis);
93const bars = new EnergyBars($<HTMLCanvasElement>("bars"), spec.axis);
94const player = new Player();
95spec.axis.scale = $<HTMLSelectElement>("freq-scale").value as FrequencyScale;
96spec.axis.fmax = Number($<HTMLSelectElement>("freq-max").value);
97const viewSel = $<HTMLSelectElement>("view");
98type AnalysisView = ModalSpectrogram | StftView;
99const activeView = (): AnalysisView => (viewSel.value === "sound" ? soundView : viewSel.value === "trace" ? traceView : spec);
100// the STFT columns of the run so far: the listener signal (rebuilt when the signal choice
101// changes) and the driven mode's coordinate at the audio rate
102let stftSound: Stft | null = null, soundPushed = 0;
103let stftTrace: Stft | null = null, tracePushed = 0;
104let traceArr = new Float32Array(0), traceFrontier = 0;
106const exag = $<HTMLInputElement>("exag");
107const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
108exag.oninput = applyExag; applyExag();
109$("reset-view").onclick = () => plate.resetView();
111function invalidateViews() { spec.invalidate(); soundView.invalidate(); traceView.invalidate(); }
112for (const [sel, apply] of [
113 ["freq-scale", (v: string) => { spec.axis.scale = v as FrequencyScale; }],
114 ["freq-max", (v: string) => { spec.axis.fmax = Number(v); }],
115 ["db-range", (v: string) => { spec.dbRange = Number(v); bars.dbRange = Number(v); soundView.dbRange = Number(v); traceView.dbRange = Number(v); }],
116] as const) {
117 $<HTMLSelectElement>(sel).onchange = () => { apply($<HTMLSelectElement>(sel).value); invalidateViews(); redrawModal(); };
118}
119viewSel.onchange = () => redrawModal();
120spec.onHover = (t) => { $("modal-info").textContent = t; };
121soundView.onHover = (t) => { $("modal-info").textContent = t; };
122traceView.onHover = (t) => { $("modal-info").textContent = t; };
123bars.onHover = (t) => { $("bars-info").textContent = t || barsDefaultInfo(); };
124spec.onSelect = (mode, time) => { cursorTime = time; followBtn.disabled = false; selectMode(mode); updateBars(); };
125soundView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); };
126traceView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); };
127bars.onSelect = (mode) => selectMode(mode);
128modeList.onSelect = (mode) => selectMode(mode);
129followBtn.onclick = () => { cursorTime = null; followBtn.disabled = true; updateBars(); };
131function redrawModal() {
132 spec.cursorTime = cursorTime; soundView.cursorTime = cursorTime; traceView.cursorTime = cursorTime;
133 activeView().draw(); bars.draw();
134}
135new ResizeObserver(() => { plate.render(); }).observe($("plate"));
136new ResizeObserver(() => { activeView().draw(); }).observe($("modal-spec"));
137new ResizeObserver(() => { bars.draw(); }).observe($("bars"));
139// splitters: drag to set the CSS variable that sizes the neighbouring pane
140for (const sp of document.querySelectorAll<HTMLElement>(".splitter")) {
141 const target = $(sp.dataset.target!), cssVar = sp.dataset.var!, vertical = sp.classList.contains("vertical"), invert = sp.dataset.invert === "1";
142 sp.addEventListener("pointerdown", (e) => {
143 e.preventDefault();
144 sp.setPointerCapture(e.pointerId); sp.classList.add("active");
145 const start = vertical ? e.clientX : e.clientY, size0 = vertical ? target.getBoundingClientRect().width : target.getBoundingClientRect().height;
146 const move = (ev: PointerEvent) => {
147 const delta = (vertical ? ev.clientX : ev.clientY) - start;
148 const size = Math.max(60, size0 + (invert ? -delta : delta));
149 document.documentElement.style.setProperty(cssVar, `${size}px`);
150 };
151 const up = () => { sp.classList.remove("active"); sp.removeEventListener("pointermove", move); sp.removeEventListener("pointerup", up); plate.render(); redrawModal(); };
152 sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up);
153 });
154}
156// ---------------------------------------------------------------- mode-shape animation
157let animShape: Float64Array | null = null; // physical peak displacement per node [m]
158let animBuf = new Float32Array(0);
159const animateBox = $<HTMLInputElement>("animate");
160function prepareAnimation() {
161 animShape = null;
162 if (!modes || !derived || selectedMode < 0) return;
163 const shape = modeShape(modes, selectedMode, derived.phiFactor);
164 const a = modalAmplitude(modes, shape, Number(amplitudeInput.value) || 1);
165 animShape = shape.map((x) => a * x);
166 const { node } = antinode(shape);
167 plate.colorScale = Math.abs(animShape[node]);
168 if (animBuf.length !== shape.length) animBuf = new Float32Array(shape.length);
169}
170function animating(): boolean {
171 return animateBox.checked && animShape !== null && runState !== "running" && runState !== "paused" && runState !== "setup";
172}
173function animationTick(tMs: number) {
174 if (animating()) {
175 const c = Math.cos(2 * Math.PI * 0.7 * (tMs / 1000));
176 for (let n = 0; n < animShape!.length; n++) animBuf[n] = c * animShape![n];
177 plate.setDisplacement(animBuf);
178 }
179 if (player.playing) {
180 const t = player.position();
181 position.value = String(runDuration > 0 ? t / runDuration : 0); timeEl.textContent = `${t.toFixed(3)} s`;
182 }
183 requestAnimationFrame(animationTick);
184}
185requestAnimationFrame(animationTick);
186animateBox.onchange = () => { if (!animating()) plate.render(); };
188// ---------------------------------------------------------------- modes lifecycle
189function options() { return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) }; }
190function structuralString(cfg: SimulationConfig) { return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) }); }
191function stale(): boolean { return !!modes && modesStructural !== structuralString(config); }
193function ensureWorker(): Worker {
194 if (!worker) {
195 worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" });
196 worker.onmessage = (ev: MessageEvent<PrecomputeReply>) => void onWorkerMessage(ev.data);
197 worker.onerror = (e) => { modesStatus.textContent = `worker error: ${e.message}`; };
198 }
199 return worker;
200}
202async function onWorkerMessage(msg: PrecomputeReply) {
203 if (msg.type === "progress") {
204 const secs = (performance.now() - computeStart) / 1000;
205 if (msg.token === computeToken) {
206 modesProgress.style.width = `${Math.round(100 * msg.frac)}%`;
207 modesStatus.textContent = `${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${secs.toFixed(0)} s]`;
208 } else if (complementWaiters.has(msg.token)) {
209 driveStatus.textContent = `condensation: ${msg.phase} ${(100 * msg.frac).toFixed(0)} %`;
210 }
211 } else if (msg.type === "modes") {
212 if (msg.token !== computeToken) return;
213 computing = false; computeBtn.disabled = false;
214 modesProgress.style.width = "100%";
215 const structural = structuralString(config);
216 await cachePut(await cacheKey(structural), msg.buffer);
217 setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
218 } else if (msg.type === "complement") {
219 const w = complementWaiters.get(msg.token);
220 if (w) { complementWaiters.delete(msg.token); w.resolve(msg.o); }
221 } else if (msg.type === "gains") {
222 // not used here: the run computes its own radiation
223 } else {
224 const w = complementWaiters.get(msg.token);
225 if (w) { complementWaiters.delete(msg.token); w.reject(new Error(msg.message)); return; }
226 computing = false; computeBtn.disabled = false;
227 modesStatus.textContent = `error: ${msg.message}`;
228 updateRunUi();
229 }
230}
232function compute() {
233 const issues = designIssues(config);
234 if (issues.length) { modesStatus.textContent = "the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; }
235 computing = true; computeBtn.disabled = true; computeStart = performance.now();
236 computeToken = ++token;
237 modesProgress.style.width = "0%";
238 modesStatus.textContent = "computing the modes...";
239 updateRunUi();
240 const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
241 ensureWorker().postMessage(req);
242}
244function setModes(buffer: ArrayBuffer, label: string, structural: string) {
245 let parsed: ModesData;
246 try { parsed = parseModes(buffer); } catch (err) { modesStatus.textContent = `could not read the modes: ${(err as Error).message}`; return; }
247 modes = parsed; modesBuffer = buffer; modesStructural = structural;
248 complementCache.clear();
249 derived = derive(parsed, config);
250 downloadModesBtn.disabled = false;
251 modesStatus.textContent = `${label}: ${parsed.nNodes} nodes, ${parsed.nModes} modes, ` +
252 `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[parsed.nModes - 1].toFixed(0)} Hz`;
253 modeList.setModes(parsed, derived.freq, derived.sigma);
254 $("modelist-info").textContent = `${parsed.nModes} modes`;
255 plate.setMesh({ n: parsed.nNodes, e: parsed.nElements, xy: parsed.xy, tri: parsed.tri }, parsed.z0,
256 Math.min(...parsed.h), null);
257 $("plate-info").textContent = `${parsed.nNodes} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
258 selectMode(Math.min(selectedMode >= 0 ? selectedMode : 0, parsed.nModes - 1));
259 updateRunUi();
260}
262async function tryCache() {
263 if (!modes) {
264 const buf = await cacheGet(await cacheKey(structuralString(config)));
265 if (buf) setModes(buf, "cached modes", structuralString(config));
266 else modesStatus.textContent = "no modes for this cymbal yet";
267 }
268 updateRunUi();
269}
271function requestComplement(mode: number): Promise<Float64Array> {
272 const cached = complementCache.get(mode);
273 if (cached) return Promise.resolve(cached);
274 const raw = normalizeConfig(modes!.meta.config).config;
275 const w = modeShape(modes!, mode, 1);
276 const tok = ++token;
277 return new Promise((resolve, reject) => {
278 complementWaiters.set(tok, { resolve: (o) => { complementCache.set(mode, o); resolve(o); }, reject });
279 const req: PrecomputeRequest = { type: "complement", token: tok, config: raw,
280 options: { elementSize: modes!.meta.playback_element_size, fmax: modes!.meta.fmax }, w };
281 ensureWorker().postMessage(req, [w.buffer]);
282 });
283}
285// ---------------------------------------------------------------- selection
286function selectMode(mode: number) {
287 if (!modes || !derived || mode < 0 || mode >= modes.nModes) return;
288 selectedMode = mode;
289 modeList.select(mode);
290 spec.selectedMode = mode; bars.selectedMode = mode;
291 soundView.selectedMode = mode; traceView.selectedMode = mode;
292 const f = derived.freq[mode], sigma = derived.sigma[mode];
293 const { diameters, circles } = classifyMode(modes, mode);
294 const shape = modeShape(modes, mode, 1);
295 const { node } = antinode(shape);
296 const r = Math.hypot(modes.xy[2 * node], modes.xy[2 * node + 1]);
297 driveModeEl.textContent = `mode ${mode}: ${f.toFixed(1)} Hz, T60 ${(6.907755 / sigma).toFixed(2)} s, ` +
298 `≈ (${diameters} diameters, ${circles} circles), antinode at r = ${(r * 1e3).toFixed(0)} mm`;
299 prepareAnimation();
300 redrawModal();
301 updateRunUi();
302}
304// ---------------------------------------------------------------- design panel and presets
305const panel = new DesignPanel($("design"), () => config, onDesignChange);
307function describeDesign() {
308 const issues = designIssues(config);
309 let text = "";
310 if (modes && derived) {
311 text = stale() ? "the shape changed: recompute the modes before running" : "";
312 }
313 if (issues.length) text += (text ? "\n" : "") + issues.map((i) => `${i.path}: ${i.message}`).join("\n");
314 designStatus.textContent = text;
315 designStatus.className = "status" + (issues.length ? " bad" : "");
316}
318function onDesignChange(_field: Field, tier: Tier) {
319 if (modes && tier === "live") {
320 derived = derive(modes, config);
321 modeList.setDerived(derived.freq, derived.sigma);
322 if (selectedMode >= 0) selectMode(selectedMode);
323 }
324 describeDesign();
325 updateRunUi();
326}
328function setConfig(cfg: SimulationConfig, label: string) {
329 config = cfg;
330 panel.refresh();
331 modes = null; modesBuffer = null; derived = null; modesStructural = "";
332 complementCache.clear();
333 downloadModesBtn.disabled = true;
334 modeList.setModes(null, null, null);
335 modesStatus.textContent = `${label}; no modes yet`;
336 describeDesign();
337 void tryCache();
338}
340for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
341const customOpt = document.createElement("option");
342customOpt.value = "custom"; customOpt.textContent = "loaded YAML"; customOpt.disabled = true;
343presetSel.appendChild(customOpt);
344const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
345presetSel.value = initialPreset.id; presetYaml = initialPreset.yaml;
346presetSel.onchange = () => {
347 const p = PRESETS.find((x) => x.id === presetSel.value);
348 if (p) { presetYaml = p.yaml; setConfig(parseConfigYaml(p.yaml).config!, p.name); }
349};
350$("import-yaml").onclick = () => yamlFile.click();
351yamlFile.onchange = async () => {
352 const file = yamlFile.files?.[0];
353 if (!file) return;
354 const text = await file.text();
355 const { config: cfg, issues } = parseConfigYaml(text);
356 if (!cfg) { modesStatus.textContent = "YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; }
357 presetYaml = text; customOpt.disabled = false; presetSel.value = "custom";
358 setConfig(cfg, file.name);
359 yamlFile.value = "";
360};
361$("download-yaml").onclick = () => download(new Blob([configToYaml(config)], { type: "text/yaml" }), "cymmodes_design.yaml");
362computeBtn.onclick = compute;
363downloadModesBtn.onclick = () => { if (modesBuffer) download(new Blob([modesBuffer]), `cymmodes_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`); };
364$("import-modes").onclick = () => modesFile.click();
365modesFile.onchange = async () => {
366 const file = modesFile.files?.[0];
367 if (!file) return;
368 const buf = await file.arrayBuffer();
369 let loaded: ModesData;
370 try { loaded = parseModes(buf); } catch (err) { modesStatus.textContent = `could not load the modes: ${(err as Error).message}`; return; }
371 const cfg = normalizeConfig(loaded.meta.config).config;
372 customOpt.disabled = false; presetSel.value = "custom";
373 config = cfg;
374 esizeSel.value = String(loaded.meta.playback_element_size);
375 fmaxInput.value = String(loaded.meta.fmax);
376 panel.refresh();
377 setModes(buf, file.name, structuralString(config));
378 modesFile.value = "";
379};
380$("clear-cache").onclick = async () => { await cacheClear(); modesStatus.textContent = "the cache is empty"; };
381for (const el of [esizeSel, fmaxInput]) el.addEventListener("change", () => {
382 modes = null; modesBuffer = null; derived = null; modesStructural = "";
383 complementCache.clear();
384 downloadModesBtn.disabled = true;
385 modeList.setModes(null, null, null);
386 updateRunUi();
387 void tryCache();
388});
390function applyMechanism() {
391 for (const el of document.querySelectorAll<HTMLElement>(".forced-only")) el.classList.toggle("hidden", mechanismSel.value !== "forced");
392 updateRunUi();
393}
394mechanismSel.onchange = applyMechanism;
395applyMechanism();
396amplitudeInput.onchange = prepareAnimation;
398// ---------------------------------------------------------------- the run
399function updateRunUi() {
400 const issues = designIssues(config);
401 const busy = runState === "running" || runState === "paused" || runState === "setup";
402 runBtn.disabled = busy || computing || !modes || stale() || selectedMode < 0 || issues.length > 0;
403 computeBtn.disabled = computing || busy;
404 pauseBtn.disabled = !(runState === "running" || runState === "paused");
405 stopBtn.disabled = !(runState === "running" || runState === "paused");
406 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
407 if (!modes) runBtn.title = "compute the modes first";
408 else if (stale()) runBtn.title = "the shape changed: recompute the modes";
409 else if (selectedMode < 0) runBtn.title = "select a mode";
410 else runBtn.title = "";
411}
413function barsDefaultInfo(): string {
414 if (!tracker || !tracker.length) return "";
415 const s = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime);
416 if (s < 0) return "";
417 const top = tracker.topModes(s, 4).filter((t) => t.energy > 0);
418 const items = top.map((t) => `${t.mode} (${(tracker!.omega[t.mode] / (2 * Math.PI)).toFixed(0)} Hz)`).join(", ");
419 return `t = ${tracker.times[s].toFixed(2)} s${cursorTime === null ? " (following)" : ""}; strongest: ${items}`;
420}
422function updateBars() {
423 if (!tracker || !tracker.length) { bars.snapshot = -1; redrawModal(); return; }
424 bars.snapshot = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime);
425 $("bars-info").textContent = barsDefaultInfo();
426 redrawModal();
427 if (bars.snapshot >= 0) modeList.setEnergies(tracker.energies[bars.snapshot], tracker.maxEnergy);
428}
430/** Push the newly arrived part of the listener signal into its STFT (rebuilt on a signal change). */
431function pushSound(rebuild = false) {
432 if (rebuild || !stftSound) { stftSound = new Stft(runFs, 2048, 256); soundPushed = 0; soundView.stft = stftSound; }
433 if (frontier > soundPushed) {
434 const part = new Float32Array(frontier - soundPushed);
435 if (signalSel.value === "rayleigh") part.set(front.subarray(soundPushed, frontier));
436 else for (let i = soundPushed; i < frontier; i++) part[i - soundPushed] = front[i] - back[i];
437 if (stftSound.push(part) > 0) soundView.invalidate();
438 soundPushed = frontier;
439 }
440}
442async function startRun() {
443 if (!modes || !derived || selectedMode < 0 || stale()) return;
444 runState = "setup"; updateRunUi();
445 driveStatus.textContent = "building the set-up ...";
446 const mech = mechanismSel.value as Mechanism;
447 const dampScale = Math.min(1, Math.max(0, Number(dampScaleInput.value)));
448 const cfg = cloneConfig(config);
449 cfg.hits = [];
450 cfg.mesh.element_size = modes.meta.playback_element_size;
451 cfg.numerics.duration = Math.max(0.05, Number(durationInput.value) || 1);
452 cfg.numerics.nonlinear = !linearBox.checked;
453 cfg.material.sigma0 *= dampScale; cfg.material.sigma1 *= dampScale;
454 try {
455 setup = await buildSetup(cfg, async (phase) => { driveStatus.textContent = `set-up: ${phase}`; await new Promise((r) => setTimeout(r, 0)); });
456 } catch (err) {
457 driveStatus.textContent = `set-up failed: ${(err as Error).message}`; runState = "idle"; updateRunUi(); return;
458 }
459 if (setup.mesh.n !== modes.nNodes) {
460 driveStatus.textContent = `mesh mismatch: the set-up has ${setup.mesh.n} nodes, the modes ${modes.nNodes}; recompute the modes`;
461 runState = "idle"; updateRunUi(); return;
462 }
463 runFs = setup.fs; runDuration = cfg.numerics.duration;
464 const m = selectedMode;
465 const shape = modeShape(modes, m, derived.phiFactor);
466 const ampWH = Number(amplitudeInput.value) || 1;
467 const Mw = new Float64Array(setup.mesh.n);
468 for (let n = 0; n < setup.mesh.n; n++) Mw[n] = setup.shell.M[5 * n + 2];
469 const every = Math.max(1, Math.round(setup.fs / (Number(snapRateInput.value) || 240)));
470 const projection = buildProjection(modes, Mw, derived.phiFactor, every);
471 let drive;
472 let initial: { q: Float64Array; v: Float64Array } | null = null;
473 const spec_: { mode: number; mechanism: Mechanism; amplitude: number; detuneCents: number; rampTime: number; driveTime: number } = {
474 mode: m, mechanism: mech, amplitude: ampWH,
475 detuneCents: Number(detuneInput.value) || 0, rampTime: Math.max(0, Number(rampInput.value) || 0),
476 driveTime: Math.max(0.05, Number(driveTimeInput.value) || 1),
477 };
478 if (mech === "decay") {
479 try {
480 driveStatus.textContent = "condensation: in-plane and rotation complement of the mode ...";
481 const factor = derived.phiFactor;
482 const o = await requestComplement(m);
483 const of = Float64Array.from(o, (x) => x * factor);
484 initial = modalInitialState(shape, of, modalAmplitude(modes, shape, ampWH));
485 } catch (err) {
486 driveStatus.textContent = `complement failed (${(err as Error).message}); releasing the pure-w shape`;
487 initial = modalInitialState(shape, new Float64Array(4 * setup.mesh.n), modalAmplitude(modes, shape, ampWH));
488 }
489 } else {
490 drive = buildDrive(spec_, modes, shape, Mw, derived.sigma[m], 2 * Math.PI * derived.freq[m], setup.dt);
491 }
492 backend?.dispose();
493 backend = device ? new GpuSimulator(device, setup, 8192, { drive, projection, traceMode: m }) : new CpuSimulator(setup, { drive, projection, traceMode: m });
494 if (initial) backend.setState(initial.q, initial.v);
495 tracker = new ModalTracker(derived.freq, m);
496 modeList.setDriven(m);
497 spec.tracker = tracker; spec.duration = runDuration; spec.invalidate();
498 bars.tracker = tracker; bars.snapshot = -1;
499 // the two true spectrograms: the listener signal and the driven mode's own coordinate
500 const modeFreqs = Float64Array.from(derived.freq);
501 for (const v of [soundView, traceView]) { v.duration = runDuration; v.modeFreqs = modeFreqs; v.drivenMode = m; v.selectedMode = m; v.cursorTime = null; v.invalidate(); }
502 stftSound = null; soundPushed = 0;
503 stftTrace = new Stft(setup.fs, 2048, 256); tracePushed = 0;
504 traceView.stft = stftTrace;
505 traceArr = new Float32Array(setup.nSamples); traceFrontier = 0;
506 cursorTime = null; followBtn.disabled = true;
507 front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
508 pushSound(true);
509 player.stop();
510 plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity),
511 backend instanceof GpuSimulator ? backend.stateBuffer : null);
512 plate.colorScale = ampWH * Math.min(...modes.h);
513 $("plate-title").textContent = "Live";
514 const detuneNote = mech === "forced" && spec_.detuneCents !== 0 ? `, detuned ${spec_.detuneCents} cents` : "";
515 log(`run: mode ${m} at ${derived.freq[m].toFixed(1)} Hz, ${mech === "decay" ? "free decay from" : "forced towards"} ` +
516 `${ampWH} thicknesses${detuneNote}, damping x${dampScale}, ${cfg.numerics.nonlinear ? "nonlinear" : "linear"}, ${runDuration} s`);
517 if (mech === "forced" && dampScale < 1) log(`note: the force targets the amplitude at physical damping; with damping x${dampScale} the linear response is larger`);
518 let lastPlate = 0, lastBars = 0;
519 runState = "running"; updateRunUi();
520 runner = new Runner(backend, setup, {
521 onChunk(res: ChunkResult) {
522 const n = res.pressureFront.length;
523 if (n && setup) {
524 front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
525 back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
526 frontier = Math.min(setup.nSamples, res.firstSample + n);
527 pushSound();
528 }
529 if (res.trace.length && setup && stftTrace) {
530 const len = Math.min(res.trace.length, setup.nSamples - res.traceBase);
531 traceArr.set(res.trace.subarray(0, len), res.traceBase);
532 traceFrontier = res.traceBase + len;
533 if (stftTrace.push(traceArr.subarray(tracePushed, traceFrontier)) > 0) traceView.invalidate();
534 tracePushed = traceFrontier;
535 }
536 if (tracker && res.proj.length) { tracker.addChunk(res, runFs); spec.invalidate(); }
537 if (performance.now() - lastBars > 250) { lastBars = performance.now(); updateBars(); }
538 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
539 if (performance.now() - lastPlate > 100) {
540 lastPlate = performance.now();
541 if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
542 }
543 },
544 onDiagnostics(d: Diagnostics) {
545 const total = d.kinetic + d.membrane + d.bending;
546 const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0;
547 driveStatus.textContent =
548 `t = ${d.time.toFixed(2)} s: kinetic ${d.kinetic.toExponential(2)} J, membrane ${d.membrane.toExponential(2)} J, ` +
549 `bending ${d.bending.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}\n` +
550 `modal sum ${modal.toExponential(2)} J (${total > 0 ? ((100 * modal) / total).toFixed(0) : "-"} % of the shell energy)`;
551 if (!Number.isFinite(total)) { log("energy is not finite: the run has blown up (lower numerics.cfl_safety)"); runner?.stop(); }
552 },
553 onProgress(done, wall) {
554 if (!setup) return;
555 runProgress.value = done / setup.nSamples;
556 const audio = done / setup.fs;
557 runProgressText.textContent = `${audio.toFixed(2)} / ${runDuration.toFixed(2)} s (${(audio / Math.max(wall, 1e-3)).toFixed(2)}x)`;
558 },
559 onDone(stopped) {
560 runState = stopped ? "stopped" : "done";
561 updateBars();
562 updateRunUi();
563 plate.useOwnState();
564 $("plate-title").textContent = "Mode shape";
565 log(stopped ? "stopped" : "done");
566 void backend?.diagnostics().then((d) => {
567 const total = d.kinetic + d.membrane + d.bending;
568 const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0;
569 log(`final: shell energy ${total.toExponential(2)} J, modal sum ${modal.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}`);
570 });
571 },
572 });
573 void runner.run();
574}
575runBtn.onclick = () => void startRun();
576pauseBtn.onclick = () => {
577 if (!runner) return;
578 if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; }
579 else { runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; }
580};
581stopBtn.onclick = () => runner?.stop();
583// ---------------------------------------------------------------- audio
584const signal = () => {
585 const out = new Float32Array(frontier);
586 if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
587 else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
588 return out;
589};
590gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
591signalSel.onchange = () => { pushSound(true); if (viewSel.value === "sound") redrawModal(); };
592playBtn.onclick = () => {
593 if (frontier === 0) return;
594 player.setGainDb(Number(gain.value));
595 player.play(scaleForPlayback(signal(), normalize.checked), runFs, Number(position.value) * runDuration);
596 stopAudioBtn.disabled = false;
597};
598player.onEnded = () => { stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; };
599stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; };
600position.oninput = () => { timeEl.textContent = `${(Number(position.value) * runDuration).toFixed(3)} s`; };
601wavBtn.onclick = () => { if (frontier > 0) download(encodeWav(scaleForPlayback(signal(), normalize.checked), runFs), `cymmodes_${signalSel.value}.wav`); };
603// ---------------------------------------------------------------- start-up
604panel.refresh();
605describeDesign();
606updateRunUi();
607void tryCache();