5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 1// The application: the design script, the two run modes (the linear modal
2// model on the CPU workers and the full nonlinear simulation on the GPU),
3// the plate view, the movie, the audio, and the spectrogram.
5import { compileDesign, evaluatePlate, isFlat, profileStats, type Design, DesignError } from "./design.ts";
6import { PRESETS, DEFAULT_PRESET, presetById } from "./presets.ts";
7import {
8 type Plate,
9 type SimParams,
10 defaultParams,
11 countInside,
12 massProperties,
13 meanThickness,
14 thicknessRange,
15 standRates,
16 strikePoint,
17 strikePatch,
18 listenerPosition,
19 dispersionFrequency,
20 inPlaneSpeed,
21 CFL,
22} from "./params.ts";
23import { fitSigma, fittedRate, t60At, LN1000, decayRate } from "./loss.ts";
24import { assemble, type LinearSystem } from "./model/assemble.ts";
25import { maxAngularFrequency, fullLoss, type FullLoss } from "./model/reference.ts";
26import { type ModeSet } from "./modal/modes.ts";
27import { WorkerPool } from "./modal/pool.ts";
28import { modalModel, modeShapeOnGrid, type ModalModel, type ContactResult } from "./modal/synth.ts";
29import { Simulation } from "./sim/simulation.ts";
30import { runSelfTest } from "./sim/selftest.ts";
31import { PlateRenderer, type ViewMode, type ColorMode } from "./render/renderer.ts";
32import { Spectrogram } from "./audio/spectrogram.ts";
33import { Player } from "./audio/player.ts";
34import { encodeWav } from "./audio/wav.ts";
35import { levelGain, applyLevel, peakDbfs, peakSpl, type LevelMode } from "./audio/level.ts";
36import { DesignEditor } from "./ui/editor.ts";
37import { ParamForm, formatNumber } from "./ui/form.ts";
38import { ModeBrowser } from "./ui/modes.ts";
40type RunMode = "linear" | "full";
41type ViewState = "design" | "mode" | "live" | "movie";
43const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
45/** A recording: the listener signal and the movie of one run, from either model. */
46interface Recording {
47 audio: Float32Array;
48 samples: number;
49 fs: number;
50 frames: Float32Array[];
51 fps: number;
52 source: RunMode;
53}
55class App {
56 // GPU
57 device: GPUDevice | null = null;
58 // Design and plate
59 design: Design | null = null;
60 plate: Plate | null = null;
61 plateSpacing = 0;
62 designGeneration = 0;
63 // Linear model
64 pool: WorkerPool | null = null;
65 sys: LinearSystem | null = null;
66 modes: ModeSet | null = null;
67 model: ModalModel | null = null;
68 modesGeneration = 0;
69 modesBusy: Promise<void> = Promise.resolve();
70 synthBusy = false;
71 synthPending = false;
72 synthesized = false;
73 lastContact: ContactResult | null = null;
74 // Full simulation
75 sim: Simulation | null = null;
76 running = false;
77 paused = false;
78 // Recording and view
79 rec: Recording | null = null;
80 view: ViewState = "design";
81 frameIndex = 0;
82 runMode: RunMode = "linear";
83 // UI
84 editor!: DesignEditor;
85 form!: ParamForm;
86 modeBrowser!: ModeBrowser;
87 renderer!: PlateRenderer;
88 spec!: Spectrogram;
89 player = new Player();
90 levelMode: LevelMode = "absolute";
91 gainDb = -40;
92 showTicks = true;
93 viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 20, range: 0 };
94 // Controls
95 runModeSel!: HTMLSelectElement;
96 durationInput!: HTMLInputElement;
97 startBtn!: HTMLButtonElement;
98 pauseBtn!: HTMLButtonElement;
99 stopBtn!: HTMLButtonElement;
100 continueBtn!: HTMLButtonElement;
101 runStatus!: HTMLDivElement;
102 scrub!: HTMLInputElement;
103 timeLabel!: HTMLSpanElement;
104 playBtn!: HTMLButtonElement;
105 liveBtn!: HTMLButtonElement;
106 designBtn!: HTMLButtonElement;
107 peakLabel!: HTMLSpanElement;
108 downloadBtn!: HTMLButtonElement;
109 overlay!: HTMLDivElement;
110 synthTimer: number | null = null;
112 async init(): Promise<void> {
113 const url = new URLSearchParams(location.search);
114 if (!url.has("nogpu") && navigator.gpu) {
115 try {
116 const adapter = await navigator.gpu.requestAdapter();
117 if (adapter) this.device = await adapter.requestDevice();
118 } catch (e) {
119 console.warn("WebGPU unavailable", e);
120 }
121 }
122 if (typeof Worker !== "undefined") this.pool = new WorkerPool(WorkerPool.defaultSize());
124 this.buildControls();
125 this.buildForm();
126 this.buildViewToolbar();
127 this.buildMovieToolbar();
128 this.buildSpecToolbar();
129 this.buildSplitter();
131 this.renderer = new PlateRenderer($("view"), this.device);
132 if (!this.renderer.has3d) this.viewOpts.mode = "2d";
133 this.renderer.setOptions(this.viewOpts);
134 this.overlay = $("viewOverlay");
135 const p = this.form.get();
136 this.spec = new Spectrogram($("spec"), { fs: p.fs, window: 2048, hop: 256, maxHz: 12000, dbRange: 80, logFreq: false });
137 this.spec.setDuration(p.duration);
138 this.modeBrowser = new ModeBrowser($("modes"), (m) => this.showMode(m));
139 this.editor = new DesignEditor($("design"), { onSource: (src) => this.onSource(src) });
141 if (url.has("mode")) this.setRunMode(url.get("mode") === "full" ? "full" : "linear");
142 else this.setRunMode(this.device ? "linear" : "linear");
143 const preset = url.get("preset");
144 this.editor.selectPreset(preset && presetById(preset) ? preset : DEFAULT_PRESET);
145 this.form.applyUrl(url);
146 this.durationInput.value = String(this.form.get().duration);
148 const loop = () => {
149 this.renderer.render();
150 requestAnimationFrame(loop);
151 };
152 requestAnimationFrame(loop);
153 window.addEventListener("resize", () => { this.renderer.resize(); this.spec.resize(); });
154 this.renderer.resize();
155 this.spec.resize();
157 // Test hooks.
158 (window as unknown as { cymbalsim: unknown }).cymbalsim = {
159 app: this,
160 selftest: () => this.selfTest(),
161 runLinear: () => this.runLinear(),
162 runFull: () => this.runFull(),
163 modesReady: () => this.modesBusy,
164 status: () => this.testStatus(),
165 };
166 if (url.has("selftest")) {
167 const r = await this.selfTest();
168 this.overlay.textContent = r;
169 }
170 if (url.has("autostart")) {
171 await this.modesBusy;
172 this.start();
173 }
174 }
176 // ---------- Controls ----------
178 private buildControls(): void {
179 const c = $("controls");
180 this.runModeSel = document.createElement("select");
181 this.runModeSel.innerHTML = `<option value="linear">Linear (modal)</option><option value="full">Full (nonlinear, GPU)</option>`;
182 this.runModeSel.addEventListener("change", () => this.setRunMode(this.runModeSel.value as RunMode));
183 const durLabel = document.createElement("label");
184 durLabel.textContent = "Duration";
185 this.durationInput = document.createElement("input");
186 this.durationInput.type = "number"; this.durationInput.min = "0.1"; this.durationInput.step = "0.5"; this.durationInput.style.width = "60px";
187 this.durationInput.addEventListener("change", () => {
188 const d = Math.max(0.1, Number(this.durationInput.value) || 1);
189 this.form.set("duration", d);
190 this.spec.setDuration(d);
191 if (this.sim) this.sim.setDuration(d);
192 this.updateButtons();
193 });
194 const s = document.createElement("span"); s.textContent = " s";
195 durLabel.append(this.durationInput, s);
196 this.startBtn = this.button("Start", () => this.start(), "primary");
197 this.pauseBtn = this.button("Pause", () => this.pause());
198 this.stopBtn = this.button("Stop", () => this.stopRun());
199 this.continueBtn = this.button("Continue", () => this.continueRun());
200 c.append(this.runModeSel, durLabel, this.startBtn, this.pauseBtn, this.stopBtn, this.continueBtn);
201 this.runStatus = document.createElement("div");
202 this.runStatus.className = "status";
203 $("sidebarHead").append(this.runStatus);
204 }
206 private button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
207 const b = document.createElement("button");
208 b.textContent = label;
209 if (cls) b.className = cls;
210 b.addEventListener("click", onClick);
211 return b;
212 }
214 private buildForm(): void {
215 this.form = new ParamForm(
216 $("params"),
217 [
218 {
219 title: "Strike",
220 notes: true,
221 fields: [
222 { key: "strikeFracX", label: "Position x (fraction of width)", step: 0.05, min: 0, max: 1 },
223 { key: "strikeFracY", label: "Position y (fraction of height)", step: 0.05, min: 0, max: 1 },
224 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", step: 0.5, min: 0.01 },
225 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, min: 1 },
226 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/m^a", step: 1e8, min: 1e5 },
227 { key: "contactExponent", label: "Contact exponent a", step: 0.1, min: 1, max: 3 },
228 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, min: 0.5 },
229 ],
230 },
231 {
232 title: "Listener",
233 fields: [
234 { key: "listenerDist", label: "Distance", unit: "m", step: 0.1, min: 0.05, live: true },
235 { key: "listenerAngle", label: "Angle from the normal", unit: "deg", step: 5, min: 0, max: 90, live: true },
236 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", step: 15, min: -180, max: 180, live: true },
237 ],
238 },
239 {
240 title: "Grid and numerics",
241 notes: true,
242 fields: [
243 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 1, min: 2 },
244 { key: "modeMaxFreq", label: "Modes up to", unit: "Hz", step: 1000, min: 200 },
245 { key: "padLiftoff", label: "Pads lift off (full model)", kind: "checkbox" },
246 { key: "fs", label: "Sample rate", kind: "select", options: [{ value: 44100, label: "44.1 kHz" }, { value: 48000, label: "48 kHz" }, { value: 96000, label: "96 kHz" }] },
247 { key: "fps", label: "Movie frame rate", kind: "select", options: [{ value: 60, label: "60" }, { value: 120, label: "120" }, { value: 240, label: "240" }] },
248 ],
249 },
250 ],
251 defaultParams,
252 (key, _value, live) => this.onParam(key, live),
253 );
254 }
256 private onParam(key: keyof SimParams, live: boolean): void {
257 const p = this.form.get();
258 if (key === "spacing" || key === "modeMaxFreq" || key === "fs") {
259 if (this.design) this.rebuildPlate(this.design);
260 return;
261 }
262 if (key === "fps") { this.updateNotes(); return; }
263 if (key === "padLiftoff") { this.updateNotes(); return; }
264 if (live && this.sim) this.sim.setListener(p);
265 if (key.startsWith("strike") || key.startsWith("stick") || key.startsWith("contact") || key.startsWith("listener")) {
266 this.updateMarkers();
267 this.updateNotes();
268 if (this.runMode === "linear" && this.synthesized) this.scheduleSynth();
269 }
270 }
272 private buildViewToolbar(): void {
273 const t = $("viewToolbar");
274 const modeSel = document.createElement("select");
275 modeSel.innerHTML = `<option value="3d">3D</option><option value="2d">2D</option>`;
276 modeSel.addEventListener("change", () => { this.viewOpts.mode = modeSel.value as ViewMode; this.renderer.setOptions(this.viewOpts); });
277 const colorSel = document.createElement("select");
278 colorSel.innerHTML = `<option value="height">Height</option><option value="thickness">Thickness</option>`;
279 colorSel.addEventListener("change", () => { this.viewOpts.color = colorSel.value as ColorMode; this.renderer.setOptions(this.viewOpts); });
280 const exag = document.createElement("input");
281 exag.type = "number"; exag.min = "1"; exag.step = "5"; exag.value = String(this.viewOpts.exaggeration); exag.style.width = "60px";
282 exag.addEventListener("change", () => { this.viewOpts.exaggeration = Math.max(0.1, Number(exag.value) || 1); this.renderer.setOptions(this.viewOpts); });
283 const range = document.createElement("input");
284 range.type = "number"; range.min = "0"; range.step = "0.1"; range.value = "0"; range.style.width = "60px";
285 range.addEventListener("change", () => { this.viewOpts.range = Math.max(0, Number(range.value) || 0) * 1e-3; this.renderer.setOptions(this.viewOpts); });
286 const prof = document.createElement("input");
287 prof.type = "checkbox"; prof.checked = this.viewOpts.showProfile;
288 prof.addEventListener("change", () => { this.viewOpts.showProfile = prof.checked; this.renderer.setOptions(this.viewOpts); });
289 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
290 t.append(lab("View", modeSel), lab("Color", colorSel), lab("Exaggeration", exag), lab("Range (mm, 0 = auto)", range), lab("Show profile", prof));
291 if (!this.device) {
292 modeSel.value = "2d"; modeSel.disabled = true;
293 const w = document.createElement("span"); w.id = "nogpu"; w.textContent = "WebGPU is not available: the 2D view and the linear model only.";
294 t.append(w);
295 }
296 }
298 private buildMovieToolbar(): void {
299 const t = $("movieToolbar");
300 this.playBtn = this.button("Play", () => this.togglePlay());
301 this.liveBtn = this.button("Live", () => this.setView("live"));
302 this.designBtn = this.button("Design", () => this.setView("design"));
303 this.scrub = document.createElement("input");
304 this.scrub.type = "range"; this.scrub.className = "scrub"; this.scrub.min = "0"; this.scrub.max = "0"; this.scrub.value = "0";
305 this.scrub.addEventListener("input", () => { this.frameIndex = Number(this.scrub.value); this.setView("movie"); this.showFrame(); });
306 this.timeLabel = document.createElement("span");
307 this.timeLabel.textContent = "0.000 s";
308 const levelSel = document.createElement("select");
309 levelSel.innerHTML = `<option value="absolute">Absolute level</option><option value="loudness">Normalize loudness</option><option value="peak">Normalize peak</option>`;
310 levelSel.addEventListener("change", () => { this.levelMode = levelSel.value as LevelMode; this.updatePeak(); });
311 const gain = document.createElement("input");
312 gain.type = "range"; gain.min = "-60"; gain.max = "40"; gain.step = "1"; gain.value = String(this.gainDb);
313 const gainLabel = document.createElement("span"); gainLabel.textContent = `${this.gainDb} dB`;
314 gain.addEventListener("input", () => { this.gainDb = Number(gain.value); gainLabel.textContent = `${this.gainDb} dB`; this.updatePeak(); });
315 this.peakLabel = document.createElement("span");
316 this.downloadBtn = this.button("Download .wav", () => this.download());
317 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
318 t.append(this.playBtn, this.liveBtn, this.designBtn, this.scrub, this.timeLabel, levelSel, lab("Gain", gain, gainLabel), this.peakLabel, this.downloadBtn);
319 }
321 private buildSpecToolbar(): void {
322 const t = $("specToolbar");
323 const sel = (opts: [string, string][], value: string, onChange: (v: string) => void) => {
324 const s = document.createElement("select");
325 for (const [v, l] of opts) { const o = document.createElement("option"); o.value = v; o.textContent = l; s.append(o); }
326 s.value = value;
327 s.addEventListener("change", () => onChange(s.value));
328 return s;
329 };
330 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
331 const win = sel([["512", "512"], ["1024", "1024"], ["2048", "2048"], ["4096", "4096"]], "2048", (v) => this.spec.setOptions({ window: Number(v), hop: Number(v) / 8 }));
332 const maxHz = sel([["4000", "4 kHz"], ["8000", "8 kHz"], ["12000", "12 kHz"], ["16000", "16 kHz"], ["20000", "20 kHz"]], "12000", (v) => this.spec.setOptions({ maxHz: Number(v) }));
333 const db = sel([["60", "60 dB"], ["80", "80 dB"], ["100", "100 dB"]], "80", (v) => this.spec.setOptions({ dbRange: Number(v) }));
334 const log = document.createElement("input"); log.type = "checkbox";
335 log.addEventListener("change", () => this.spec.setOptions({ logFreq: log.checked }));
336 const ticks = document.createElement("input"); ticks.type = "checkbox"; ticks.checked = this.showTicks;
337 ticks.addEventListener("change", () => { this.showTicks = ticks.checked; this.updateTicks(); });
338 t.append(lab("Spectrogram window", win), lab("Max", maxHz), lab("Range", db), lab("Log frequency", log), lab("Modal frequencies", ticks));
339 }
341 private buildSplitter(): void {
342 const sp = $("splitter"), sb = $("sidebar");
343 const saved = localStorage.getItem("cymbalsim.sidebar");
344 if (saved) sb.style.width = saved;
345 let dragging = false;
346 sp.addEventListener("mousedown", (e) => { dragging = true; e.preventDefault(); });
347 window.addEventListener("mousemove", (e) => {
348 if (!dragging) return;
349 const w = Math.max(280, Math.min(window.innerWidth - 300, e.clientX));
350 sb.style.width = `${w}px`;
351 this.renderer.resize(); this.spec.resize();
352 });
353 window.addEventListener("mouseup", () => { if (dragging) { dragging = false; localStorage.setItem("cymbalsim.sidebar", sb.style.width); } });
354 sp.addEventListener("dblclick", () => { sb.style.width = ""; localStorage.removeItem("cymbalsim.sidebar"); this.renderer.resize(); this.spec.resize(); });
355 }
357 // ---------- Design ----------
359 private onSource(source: string): void {
360 if (this.running) return;
361 let d: Design;
362 try {
363 d = compileDesign(source);
364 } catch (e) {
365 this.editor.setStatus(e instanceof Error ? e.message : String(e), "error");
366 return;
367 }
368 this.editor.setStatus(`Compiled: ${d.name}`, "ok");
369 this.rebuildPlate(d);
370 }
372 private rebuildPlate(d: Design): void {
373 const p = this.form.get();
374 let plate: Plate;
375 try {
376 plate = evaluatePlate(d, p.spacing);
377 } catch (e) {
378 this.editor.setStatus(e instanceof DesignError ? e.message : String(e), "error");
379 return;
380 }
381 this.design = d;
382 this.plate = plate;
383 this.plateSpacing = p.spacing;
384 this.designGeneration++;
385 this.modes = null; this.model = null; this.sys = null;
386 this.synthesized = false;
387 this.lastContact = null;
388 this.rec = null;
389 this.spec.reset();
390 this.spec.setOptions({ fs: p.fs });
391 this.renderer.setPlate(plate);
392 this.setView("design");
393 this.updateMarkers();
394 this.updateDesignInfo();
395 this.updateNotes();
396 this.updateButtons();
397 if (this.runMode === "linear") this.computeModes();
398 else this.modeBrowser.clear("Switch to the linear model to compute the modes.");
399 }
401 private updateDesignInfo(): void {
402 const pl = this.plate;
403 if (!pl) return;
404 const p = this.form.get();
405 const g = pl.grid;
406 const { mass } = massProperties(pl);
407 const hr = thicknessRange(pl);
408 const ps = profileStats(pl);
409 const sr = standRates(pl);
410 const lines: string[] = [];
411 lines.push(`${pl.name}: ${(pl.Lx * 1e3).toFixed(0)} x ${(pl.Ly * 1e3).toFixed(0)} mm, E = ${(pl.mat.E / 1e9).toFixed(0)} GPa, nu = ${pl.mat.nu}, rho = ${pl.mat.rho} kg/m^3`);
412 lines.push(`Thickness ${(hr.min * 1e3).toFixed(2)} to ${(hr.max * 1e3).toFixed(2)} mm, mass ${(mass * 1e3).toFixed(0)} g, grid ${g.nx} x ${g.ny} at ${(g.hx * 1e3).toFixed(2)} mm (${countInside(g)} nodes)`);
413 if (ps.peakToPeak > 0) {
414 const warn = ps.maxSlope > 0.1 ? ` <span class="warn">(above the shallow-shell range of about 0.1)</span>` : "";
415 lines.push(`Profile ${(ps.peakToPeak * 1e3).toFixed(1)} mm peak to peak, largest slope ${ps.maxSlope.toFixed(3)}${warn}`);
416 } else lines.push("Flat plate (no profile): the linear model is bending only");
417 if (pl.stand && sr) {
418 const pad = pl.stand.pad;
419 lines.push(`Stand: ${pl.stand.topPad ? "two pads" : "bottom pad only"}, ${(pad.innerRadius * 1e3).toFixed(0)} to ${(pad.outerRadius * 1e3).toFixed(0)} mm, ${(pad.thickness * 1e3).toFixed(0)} mm felt at ${(pad.E / 1e6).toFixed(2)} MPa; ${(sr.totalKw / 1e3).toFixed(0)} kN/m, bounce ${sr.bounceHz.toFixed(0)} Hz, ${sr.nodes.length} pad nodes, preload ${(sr.preloadTop * 1e3).toFixed(2)} / ${(sr.preloadBottom * 1e3).toFixed(2)} mm`);
420 } else lines.push("Free plate (no stand)");
421 lines.push(`Decay T60: ${t60At(pl.loss, 100).toFixed(2)} s at 100 Hz, ${t60At(pl.loss, 1000).toFixed(2)} s at 1 kHz, ${t60At(pl.loss, 5000).toFixed(2)} s at 5 kHz`);
422 void p;
423 $("designInfo").innerHTML = lines.join("\n");
424 }
426 private updateNotes(): void {
427 const pl = this.plate;
428 if (!pl) return;
429 const p = this.form.get();
430 const g = pl.grid;
431 const hm = meanThickness(pl);
432 const f3 = dispersionFrequency(pl.mat, hm, g.hx, 0.03), f10 = dispersionFrequency(pl.mat, hm, g.hx, 0.1);
433 const wmax = maxAngularFrequency(g, pl.mat, thicknessRange(pl).max);
434 const dtMax = (CFL * 2) / wmax;
435 const sps = Math.ceil(1 / (p.fs * dtMax));
436 const dt = 1 / (p.fs * sps);
437 const fit = fitSigma(pl.loss, pl.mat, hm);
438 const t60f = (f: number) => (LN1000 / fittedRate(fit, f)).toFixed(2);
439 const lines = [
440 `Grid ${g.nx} x ${g.ny}, ${countInside(g)} plate nodes. Grid dispersion lowers the bending partials by 3% near ${(f3 / 1e3).toFixed(1)} kHz and by 10% near ${(f10 / 1e3).toFixed(1)} kHz (at the mean thickness).`,
441 `Linear model: modes up to ${(p.modeMaxFreq / 1e3).toFixed(1)} kHz${this.modes ? `, ${this.modes.count} found in ${this.modes.seconds.toFixed(1)} s` : ""}${this.modes && this.modes.missing ? ` <span class="warn">(${this.modes.missing} missing)</span>` : ""}.`,
442 `Full model: time step ${(dt * 1e6).toFixed(2)} us (${sps} per sample, in-plane wave speed ${inPlaneSpeed(pl.mat).toFixed(0)} m/s), ${((p.duration * p.fs * sps) / 1e6).toFixed(1)} million steps for ${p.duration} s. Losses fitted as sigma0 = ${fit.sigma0.toFixed(2)} 1/s, sigma1 = ${fit.sigma1.toExponential(2)} m^2/s: T60 ${t60f(100)} / ${t60f(1000)} / ${t60f(5000)} s at 100 Hz / 1 kHz / 5 kHz (requested ${t60At(pl.loss, 100).toFixed(2)} / ${t60At(pl.loss, 1000).toFixed(2)} / ${t60At(pl.loss, 5000).toFixed(2)}).`,
443 ];
444 this.form.setNotes("Grid and numerics", lines.join("\n"));
445 const patch = strikePatch(pl, p);
446 const sp = strikePoint(pl, p);
447 const sl = [`Strike at (${(sp.x * 1e3).toFixed(0)}, ${(sp.y * 1e3).toFixed(0)}) mm on ${patch.nodes.length} node${patch.nodes.length === 1 ? "" : "s"} (${(patch.mass * 1e3).toFixed(2)} g).`];
448 if (this.lastContact) sl.push(`Last linear strike: peak force ${this.lastContact.peakForce.toFixed(0)} N, contact ${(this.lastContact.contactTime * 1e3).toFixed(2)} ms.`);
449 if (this.sim) { const st = this.sim.status().stick; sl.push(`Full simulation: peak force ${st.maxForce.toFixed(0)} N${st.withdrawn ? ", stick withdrawn" : ""}.`); }
450 this.form.setNotes("Strike", sl.join("\n"));
451 }
453 private updateMarkers(): void {
454 const pl = this.plate;
455 if (!pl) return;
456 const p = this.form.get();
457 const sp = strikePoint(pl, p);
458 const L = listenerPosition(p);
459 this.renderer.setMarkers({
460 strike: { x: sp.x, y: sp.y, radius: p.contactRadius },
461 stand: pl.stand ? { x: pl.stand.x, y: pl.stand.y, innerRadius: pl.stand.pad.innerRadius, outerRadius: pl.stand.pad.outerRadius } : null,
462 listener: L,
463 });
464 }
466 // ---------- Linear model ----------
468 private computeModes(): void {
469 const pl = this.plate, gen = this.designGeneration;
470 if (!pl) return;
471 const p = this.form.get();
472 const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
473 this.modeBrowser.clear("");
474 this.modeBrowser.setProgress(0, "Computing the modes...");
475 this.updateButtons();
476 this.modesBusy = this.modesBusy.then(async () => {
477 if (gen !== this.designGeneration) return;
478 const t0 = performance.now();
479 let sys: LinearSystem;
480 try {
481 sys = assemble(pl);
482 } catch (e) {
483 this.modeBrowser.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`);
484 return;
485 }
486 let modes: ModeSet;
487 try {
488 if (this.pool) modes = await this.pool.modes(sys, fMax, (d, t) => { if (gen === this.designGeneration) this.modeBrowser.setProgress(t ? d / t : 0, `Computing the modes: slice ${d} of ${t}`); });
489 else {
490 const { computeModes, localRunner } = await import("./modal/modes.ts");
491 modes = await computeModes(sys, fMax, localRunner(sys));
492 }
493 } catch (e) {
494 this.modeBrowser.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`);
495 return;
496 }
497 if (gen !== this.designGeneration) return;
498 this.sys = sys;
499 this.modes = modes;
500 this.model = modalModel(pl, sys, modes);
501 if (this.pool) await this.pool.setModel(this.model);
502 const secs = (performance.now() - t0) / 1000;
503 const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: LN1000 / Math.min(decayRate(pl.loss, f) + this.model!.padSigma[m], 0.99 * 2 * Math.PI * f) }));
504 const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)";
505 const info = `${modes.count} ${kind} up to ${(fMax / 1e3).toFixed(1)} kHz in ${secs.toFixed(1)} s (${sys.n} unknowns${modes.missing ? `, ${modes.missing} missing` : ""}). Click a mode to see its shape.`;
506 this.modeBrowser.setProgress(null);
507 this.modeBrowser.setModes(rows, info);
508 this.updateNotes();
509 this.updateTicks();
510 this.updateButtons();
511 });
512 }
514 private showMode(m: number | null): void {
515 if (m === null) { this.setView(this.rec ? "movie" : "design"); return; }
516 if (!this.model) return;
517 this.view = "mode";
518 const shape = modeShapeOnGrid(this.model, m);
519 // Scale the mass-normalized shape to a visible amplitude: 1 mm peak.
520 let peak = 0;
521 for (const v of shape) peak = Math.max(peak, Math.abs(v));
522 const w = new Float32Array(shape.length);
523 for (let k = 0; k < w.length; k++) w[k] = (shape[k] / (peak || 1)) * 1e-3;
524 this.renderer.setField(w);
525 this.overlay.textContent = `Mode ${m + 1}: ${this.model.freqs[m].toFixed(1)} Hz (shape scaled to 1 mm)`;
526 }
528 private scheduleSynth(): void {
529 if (this.synthTimer !== null) clearTimeout(this.synthTimer);
530 this.synthTimer = window.setTimeout(() => { this.synthTimer = null; void this.runLinear(); }, 250);
531 }
533 /** Synthesizes the strike with the current parameters (linear model). */
534 async runLinear(): Promise<void> {
535 const pl = this.plate, model = this.model;
536 if (!pl || !model) return;
537 if (this.synthBusy) { this.synthPending = true; return; }
538 this.synthBusy = true;
539 const p = this.form.get();
540 const gen = this.designGeneration;
541 const t0 = performance.now();
542 this.runStatus.textContent = "Synthesizing...";
543 try {
544 let audio: Float32Array, frames: Float32Array, nFrames: number, contact: ContactResult;
545 if (this.pool) {
546 const r = await this.pool.synthesize(model, pl, pl.loss, p, (a, c) => {
547 if (gen !== this.designGeneration) return;
548 this.installRecording(a, [], p, "linear");
549 this.lastContact = c;
550 this.runStatus.textContent = `Audio ready in ${((performance.now() - t0) / 1000).toFixed(2)} s; movie frames...`;
551 });
552 audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
553 } else {
554 const { synthesize } = await import("./modal/synth.ts");
555 const r = synthesize(model, pl, pl.loss, p, true);
556 audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
557 }
558 if (gen !== this.designGeneration) return;
559 const n = pl.grid.n;
560 const frameList: Float32Array[] = [];
561 for (let f = 0; f < nFrames; f++) frameList.push(frames.subarray(f * n, (f + 1) * n));
562 this.installRecording(audio, frameList, p, "linear");
563 this.lastContact = contact;
564 this.synthesized = true;
565 const secs = (performance.now() - t0) / 1000;
566 this.runStatus.textContent = `Linear model: ${model.count} modes, ${p.duration} s synthesized in ${secs.toFixed(2)} s. Peak ${peakSpl(audio).toFixed(0)} dB SPL, contact ${(contact.contactTime * 1e3).toFixed(2)} ms, peak force ${contact.peakForce.toFixed(0)} N.`;
567 this.updateNotes();
568 } catch (e) {
569 this.runStatus.textContent = `Synthesis failed: ${e instanceof Error ? e.message : String(e)}`;
570 } finally {
571 this.synthBusy = false;
572 if (this.synthPending) { this.synthPending = false; void this.runLinear(); }
573 }
574 }
576 // ---------- Full simulation ----------
578 async runFull(): Promise<void> {
579 const pl = this.plate;
580 if (!pl || !this.device || this.running) return;
581 const p = this.form.get();
582 this.sim?.destroy();
583 const hm = meanThickness(pl);
584 const fit = fitSigma(pl.loss, pl.mat, hm);
585 const loss: FullLoss = fullLoss(fit, standRates(pl), decayRate(pl.loss, 5000));
586 this.sim = new Simulation(this.device, pl, p, loss);
587 this.rec = { audio: new Float32Array(0), samples: 0, fs: p.fs, frames: [new Float32Array(pl.grid.n)], fps: p.fps, source: "full" };
588 this.spec.reset();
589 this.spec.setOptions({ fs: p.fs });
590 this.spec.setDuration(p.duration);
591 this.running = true;
592 this.paused = false;
593 this.setLocked(true);
594 this.setView("live");
595 this.updateButtons();
596 await this.fullLoop();
597 }
599 private async fullLoop(): Promise<void> {
600 const sim = this.sim;
601 if (!sim) return;
602 const t0 = performance.now();
603 let lastUi = 0;
604 while (this.running && sim.phase === "recording") {
605 if (this.paused) { await new Promise((r) => setTimeout(r, 50)); continue; }
606 await sim.runBatch();
607 if (!this.rec || this.sim !== sim) break;
608 // New audio and frames.
609 const rec = this.rec;
610 const newSamples = sim.samples - rec.samples;
611 if (newSamples > 0) {
612 const chunk = sim.audio.subarray(rec.samples, sim.samples);
613 this.spec.append(chunk);
614 rec.audio = sim.audio; rec.samples = sim.samples;
615 }
616 rec.frames = sim.frames;
617 if (this.view === "live") this.renderer.setField(sim.frames[sim.frames.length - 1]);
618 const now = performance.now();
619 if (now - lastUi > 100) {
620 lastUi = now;
621 const st = sim.status();
622 const elapsed = (now - t0) / 1000;
623 this.runStatus.textContent = `Full model: t = ${st.t.toFixed(3)} s, ${(st.totalSteps / elapsed / 1000).toFixed(0)}k steps/s (${(elapsed / Math.max(st.t, 1e-3)).toFixed(0)} s per second of sound), peak ${peakSpl(sim.recordedAudio()).toFixed(0)} dB SPL, stick force ${st.stick.force.toFixed(0)} N${st.delayClamped ? ", delay spread clamped" : ""}`;
624 this.scrub.max = String(Math.max(0, rec.frames.length - 1));
625 this.updatePeak();
626 }
627 }
628 if (this.sim === sim) {
629 this.running = false;
630 this.setLocked(false);
631 const st = sim.status();
632 this.runStatus.textContent = `Full model: ${st.t.toFixed(2)} s recorded, peak ${peakSpl(sim.recordedAudio()).toFixed(0)} dB SPL, peak force ${st.stick.maxForce.toFixed(0)} N, largest displacement ${(st.wmax * 1e3).toFixed(2)} mm.`;
633 if (this.rec) { this.rec.audio = sim.recordedAudio(); this.rec.samples = sim.samples; this.rec.frames = sim.frames; }
634 this.updateNotes();
635 this.updateButtons();
636 this.updatePeak();
637 }
638 }
640 // ---------- Run control ----------
642 private setRunMode(mode: RunMode): void {
643 if (mode === "full" && !this.device) mode = "linear";
644 this.runMode = mode;
645 this.runModeSel.value = mode;
646 if (mode === "linear" && this.plate && !this.modes) this.computeModes();
647 if (mode === "full") this.modeBrowser.clear("Switch to the linear model to compute the modes.");
648 this.updateButtons();
649 }
651 private start(): void {
652 if (this.runMode === "linear") void this.runLinear();
653 else void this.runFull();
654 }
655 private pause(): void {
656 if (!this.running) return;
657 this.paused = !this.paused;
658 this.pauseBtn.textContent = this.paused ? "Resume" : "Pause";
659 }
660 private stopRun(): void {
661 if (this.sim && this.running) { this.sim.stop(); this.running = false; }
662 this.paused = false;
663 this.pauseBtn.textContent = "Pause";
664 this.setLocked(false);
665 this.updateButtons();
666 }
667 private continueRun(): void {
668 const sim = this.sim;
669 if (!sim || this.running || !sim.canContinue()) return;
670 sim.setDuration(this.form.get().duration);
671 this.running = true;
672 this.setLocked(true);
673 this.setView("live");
674 this.updateButtons();
675 void this.fullLoop();
676 }
678 private setLocked(locked: boolean): void {
679 this.editor.setLocked(locked);
680 this.form.setLocked(locked);
681 this.runModeSel.disabled = locked;
682 }
684 private updateButtons(): void {
685 const linear = this.runMode === "linear";
686 const canLinear = linear && !!this.model && !this.synthBusy;
687 const canFull = !linear && !!this.plate && !!this.device && !this.running;
688 this.startBtn.disabled = !(canLinear || canFull);
689 this.startBtn.textContent = linear ? "Synthesize" : "Start";
690 this.pauseBtn.disabled = linear || !this.running;
691 this.stopBtn.disabled = linear || !this.running;
692 this.continueBtn.disabled = linear || this.running || !this.sim || !this.sim.canContinue() || this.sim.samples >= Math.round(this.form.get().duration * this.form.get().fs);
693 this.pauseBtn.hidden = linear; this.stopBtn.hidden = linear; this.continueBtn.hidden = linear;
694 this.liveBtn.hidden = linear;
695 }
697 // ---------- Recording, view, playback ----------
699 private installRecording(audio: Float32Array, frames: Float32Array[], p: SimParams, source: RunMode): void {
700 this.rec = { audio, samples: audio.length, fs: p.fs, frames, fps: p.fps, source };
701 this.spec.setOptions({ fs: p.fs });
702 this.spec.setDuration(p.duration);
703 this.spec.setSignal(audio);
704 this.scrub.max = String(Math.max(0, frames.length - 1));
705 if (frames.length > 0) {
706 this.frameIndex = Math.min(this.frameIndex, frames.length - 1);
707 if (this.view !== "mode") { this.setView("movie"); this.showFrame(); }
708 }
709 this.updatePeak();
710 this.updateButtons();
711 }
713 private setView(v: ViewState): void {
714 this.view = v;
715 if (v === "design") { this.renderer.setField(null); this.overlay.textContent = this.plate ? "Stress-free shape" : ""; }
716 else if (v === "live") { this.overlay.textContent = "Live"; if (this.sim) this.renderer.setField(this.sim.frames[this.sim.frames.length - 1]); }
717 else if (v === "movie") this.showFrame();
718 }
720 private showFrame(): void {
721 const rec = this.rec;
722 if (!rec || rec.frames.length === 0) return;
723 const f = Math.max(0, Math.min(rec.frames.length - 1, this.frameIndex));
724 this.frameIndex = f;
725 this.scrub.value = String(f);
726 this.timeLabel.textContent = `${(f / rec.fps).toFixed(3)} s`;
727 this.renderer.setField(rec.frames[f]);
728 this.overlay.textContent = `${rec.source === "linear" ? "Linear" : "Full"} model, t = ${(f / rec.fps).toFixed(3)} s`;
729 }
731 private playable(): Float32Array | null {
732 const rec = this.rec;
733 if (!rec || rec.samples === 0) return null;
734 const sig = rec.audio.subarray(0, rec.samples);
735 return applyLevel(sig, levelGain(sig, rec.fs, this.levelMode, this.gainDb));
736 }
738 private updatePeak(): void {
739 const s = this.playable();
740 if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; }
741 const db = peakDbfs(s);
742 this.peakLabel.textContent = `Peak ${db.toFixed(1)} dBFS${db > -0.5 ? " (clipping)" : ""}`;
743 this.peakLabel.className = db > -0.5 ? "badge warn" : "badge";
744 this.downloadBtn.disabled = false;
745 this.playBtn.disabled = false;
746 }
748 private togglePlay(): void {
749 if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
750 const s = this.playable(), rec = this.rec;
751 if (!s || !rec) return;
752 const start = rec.frames.length > 1 ? this.frameIndex / rec.fps : 0;
753 this.playBtn.textContent = "Stop";
754 const prevView = this.view;
755 this.player.play(s, rec.fs, start, () => { this.playBtn.textContent = "Play"; this.spec.setCursor(null); if (prevView === "live") this.setView("live"); });
756 const tick = () => {
757 if (!this.player.playing) return;
758 const t = this.player.position();
759 this.spec.setCursor(t);
760 if (rec.frames.length > 1) { this.frameIndex = Math.round(t * rec.fps); this.view = "movie"; this.showFrame(); }
761 requestAnimationFrame(tick);
762 };
763 requestAnimationFrame(tick);
764 }
766 private download(): void {
767 const s = this.playable(), rec = this.rec;
768 if (!s || !rec) return;
769 const blob = encodeWav(s, rec.fs);
770 const a = document.createElement("a");
771 a.href = URL.createObjectURL(blob);
772 a.download = `${(this.plate?.name ?? "cymbalsim").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${rec.source}.wav`;
773 a.click();
774 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
775 }
777 private updateTicks(): void {
778 this.spec.setTicks(this.showTicks && this.modes ? Array.from(this.modes.freqs) : null);
779 }
781 // ---------- Tests ----------
783 async selfTest(): Promise<string> {
784 if (!this.device) return "no WebGPU";
785 const lines: string[] = [];
786 for (const id of ["flat-rectangle", "bowed-rectangle", "crash-16", "crash-bell-16", "ellipse"]) {
787 const pr = presetById(id)!;
788 const d = compileDesign(pr.source);
789 const pl = evaluatePlate(d, 0.012);
790 const r = await runSelfTest(this.device, pl, this.form.get());
791 lines.push(`${id}: max relative error ${r.maxRel.toExponential(2)} (${r.n} nodes)`);
792 }
793 return lines.join("\n");
794 }
796 private testStatus(): unknown {
797 return {
798 design: this.design?.name ?? null,
799 modes: this.modes ? { count: this.modes.count, missing: this.modes.missing, first: Array.from(this.modes.freqs.slice(0, 5)) } : null,
800 synthesized: this.synthesized,
801 running: this.running,
802 samples: this.rec?.samples ?? 0,
803 frames: this.rec?.frames.length ?? 0,
804 source: this.rec?.source ?? null,
805 peakPressure: this.rec ? Array.from(this.rec.audio.subarray(0, this.rec.samples)).reduce((m, v) => Math.max(m, Math.abs(v)), 0) : 0,
806 sim: this.sim ? this.sim.status() : null,
807 contact: this.lastContact ? { peakForce: this.lastContact.peakForce, contactTime: this.lastContact.contactTime } : null,
808 editorStatus: ($("design").querySelector(".editor-status") as HTMLElement | null)?.textContent ?? "",
809 };
810 }
811}
813const app = new App();
814app.init().catch((e) => {
815 console.error(e);
816 $("viewOverlay").textContent = `Failed to start: ${e instanceof Error ? e.message : String(e)}`;
817});
819export { app, PRESETS, isFlat, formatNumber };