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 standFromParams,
17 strikePoint,
18 strikePatch,
19 listenerPosition,
20 dispersionFrequency,
21 inPlaneSpeed,
22 CFL,
23} from "./params.ts";
24import { fitSigma, fittedRate, LN1000, plateGeometry, estimatedRate, inPlaneRate, rateText } from "./loss.ts";
25import { dampingPlate, computeDamping } from "./modal/damping.ts";
26import { assemble, type LinearSystem } from "./model/assemble.ts";
27import { maxAngularFrequency, fullLoss, type FullLoss } from "./model/reference.ts";
28import { type ModeSet } from "./modal/modes.ts";
29import { WorkerPool } from "./modal/pool.ts";
30import { modalModel, modalRates, modeShapeOnGrid, type ModalModel, type ContactResult } from "./modal/synth.ts";
31import { Simulation } from "./sim/simulation.ts";
32import { runSelfTest } from "./sim/selftest.ts";
33import { PlateRenderer, type ViewMode, type ColorMode } from "./render/renderer.ts";
34import { Spectrogram } from "./audio/spectrogram.ts";
35import { Player } from "./audio/player.ts";
36import { encodeWav } from "./audio/wav.ts";
37import { levelGain, applyLevel, peakDbfs, peakSpl, loudness, type LevelMode } from "./audio/level.ts";
38import { DesignEditor } from "./ui/editor.ts";
39import { ParamForm, formatNumber } from "./ui/form.ts";
40import { ModeBrowser } from "./ui/modes.ts";
41import { Tutorial } from "./tutorial/tutorial.ts";
42import { type Memo, type TutorialContext, type TutorialHost } from "./tutorial/steps.ts";
44type RunMode = "linear" | "full";
45type ViewState = "design" | "mode" | "live" | "movie";
47const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
49/** The parameters that describe the stand; a change of any of them rebuilds the plate. */
50const STAND_KEYS = new Set<keyof SimParams>(["standOn", "standX", "standY", "padInnerRadius", "padOuterRadius", "padThickness", "padE", "padLossFactor", "topPad", "preload"]);
53/** A recording: the listener signal and the movie of one run, from either model. */
54interface Recording {
55 audio: Float32Array;
56 samples: number;
57 fs: number;
58 frames: Float32Array[];
59 fps: number;
60 source: RunMode;
61}
63class App {
64 // GPU
65 device: GPUDevice | null = null;
66 // Design and plate
67 design: Design | null = null;
68 plate: Plate | null = null;
69 plateSpacing = 0;
70 designGeneration = 0;
71 // Linear model
72 pool: WorkerPool | null = null;
73 sys: LinearSystem | null = null;
74 modes: ModeSet | null = null;
75 model: ModalModel | null = null;
76 modesBusy: Promise<void> = Promise.resolve();
77 /** Modes of recent designs, keyed by the script, the spacing and the cutoff; the newest entry is last. */
78 modesCache = new Map<string, { sys: LinearSystem; modes: ModeSet; model: ModalModel }>();
79 headProgress!: HTMLDivElement;
80 /** The cache key of the modes the pool currently holds. */
81 currentModesKey: string | null = null;
82 headProgressBar!: HTMLDivElement;
83 synthBusy = false;
84 synthPending = false;
85 synthesized = false;
86 lastContact: ContactResult | null = null;
87 // Full simulation
88 sim: Simulation | null = null;
89 running = false;
90 paused = false;
91 // Recording and view
92 rec: Recording | null = null;
93 view: ViewState = "design";
94 frameIndex = 0;
95 runMode: RunMode = "linear";
96 // UI
97 editor!: DesignEditor;
98 form!: ParamForm;
99 modeBrowser!: ModeBrowser;
100 renderer!: PlateRenderer;
101 spec!: Spectrogram;
102 tutorial: Tutorial | null = null;
103 tutorialMemo: Memo = {};
104 specOpts = { window: 2048, logFreq: false };
105 specWinSel!: HTMLSelectElement;
106 specLogChk!: HTMLInputElement;
107 colorSel!: HTMLSelectElement;
108 player = new Player();
109 levelMode: LevelMode = "absolute";
110 gainDb = 0;
111 showTicks = true;
112 viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 1, range: 0 };
113 // Controls
114 runModeSel!: HTMLSelectElement;
115 durationInput!: HTMLInputElement;
116 startBtn!: HTMLButtonElement;
117 pauseBtn!: HTMLButtonElement;
118 stopBtn!: HTMLButtonElement;
119 continueBtn!: HTMLButtonElement;
120 runStatus!: HTMLDivElement;
121 scrub!: HTMLInputElement;
122 timeLabel!: HTMLSpanElement;
123 playBtn!: HTMLButtonElement;
124 liveBtn!: HTMLButtonElement;
125 designBtn!: HTMLButtonElement;
126 peakLabel!: HTMLSpanElement;
127 downloadBtn!: HTMLButtonElement;
128 overlay!: HTMLDivElement;
129 synthTimer: number | null = null;
130 // Loudness warning
131 loudWarn!: HTMLDivElement;
132 loudWarnText!: HTMLSpanElement;
133 loudPlayBtn!: HTMLButtonElement;
134 loudGo: (() => void) | null = null;
135 /** Whether the user has agreed to play the current signal; any change of the recording, the level mode or the gain resets it. */
136 loudAccepted = false;
138 async init(): Promise<void> {
139 const url = new URLSearchParams(location.search);
140 if (!url.has("nogpu") && navigator.gpu) {
141 try {
142 const adapter = await navigator.gpu.requestAdapter();
143 if (adapter) this.device = await adapter.requestDevice();
144 } catch (e) {
145 console.warn("WebGPU unavailable", e);
146 }
147 }
148 if (typeof Worker !== "undefined") this.pool = new WorkerPool(WorkerPool.defaultSize());
150 this.buildControls();
151 this.buildForm();
152 this.buildViewToolbar();
153 this.buildMovieToolbar();
154 this.buildSpecToolbar();
155 this.buildSplitter();
157 this.renderer = new PlateRenderer($("view"), this.device);
158 if (!this.renderer.has3d) this.viewOpts.mode = "2d";
159 this.renderer.setOptions(this.viewOpts);
160 this.overlay = $("viewOverlay");
161 const p = this.form.get();
162 this.spec = new Spectrogram($("spec"), { fs: p.fs, window: 2048, hop: 256, maxHz: 12000, dbRange: 80, logFreq: false });
163 this.spec.setDuration(p.duration);
164 this.modeBrowser = new ModeBrowser($("modes"), (m) => this.showMode(m));
165 this.editor = new DesignEditor($("design"), { onSource: (src) => this.onSource(src) });
166 this.tutorial = new Tutorial($("tutorial"), this.tutorialHost(), (open) => {
167 $("tutorialBtn").classList.toggle("active", open);
168 this.renderer.resize(); this.spec.resize();
169 });
170 $("tutorialBtn").addEventListener("click", () => this.tutorial?.toggle());
172 if (url.has("mode")) this.setRunMode(url.get("mode") === "full" ? "full" : "linear");
173 else this.setRunMode(this.device ? "linear" : "linear");
174 // The parameters come first, so that the preset is evaluated at the requested spacing.
175 this.form.applyUrl(url);
176 this.durationInput.value = String(this.form.get().duration);
177 const preset = url.get("preset");
178 this.editor.selectPreset(preset && presetById(preset) ? preset : DEFAULT_PRESET);
179 // The tutorial opens on request (?tutorial=1 or ?tutorial=<step id>) or when it was open last time.
180 const tut = url.get("tutorial");
181 if (tut !== null) this.tutorial.open(tut === "1" || tut === "" ? undefined : tut);
182 else if (this.tutorial.wasOpen()) this.tutorial.open();
184 const loop = () => {
185 this.renderer.render();
186 requestAnimationFrame(loop);
187 };
188 requestAnimationFrame(loop);
189 window.addEventListener("resize", () => { this.renderer.resize(); this.spec.resize(); });
190 this.renderer.resize();
191 this.spec.resize();
193 // Test hooks.
194 (window as unknown as { cymbalsim: unknown }).cymbalsim = {
195 app: this,
196 selftest: () => this.selfTest(),
197 runLinear: () => this.runLinear(),
198 runFull: () => this.runFull(),
199 modesReady: () => this.modesBusy,
200 status: () => this.testStatus(),
201 tutorial: this.tutorial,
202 };
203 if (url.has("selftest")) {
204 const r = await this.selfTest();
205 this.overlay.textContent = r;
206 }
207 if (url.has("autostart")) {
208 await this.modesBusy;
209 this.start();
210 }
211 }
213 // ---------- Controls ----------
215 private buildControls(): void {
216 const c = $("controls");
217 this.runModeSel = document.createElement("select");
218 this.runModeSel.innerHTML = `<option value="linear">Linear (modal)</option><option value="full">Full (nonlinear, GPU)</option>`;
219 this.runModeSel.addEventListener("change", () => this.setRunMode(this.runModeSel.value as RunMode));
220 const durLabel = document.createElement("label");
221 durLabel.textContent = "Duration";
222 this.durationInput = document.createElement("input");
223 this.durationInput.type = "number"; this.durationInput.min = "0.1"; this.durationInput.step = "0.5"; this.durationInput.style.width = "60px";
224 this.durationInput.addEventListener("change", () => {
225 const d = Math.max(0.1, Number(this.durationInput.value) || 1);
226 this.form.set("duration", d);
227 this.spec.setDuration(d);
228 if (this.sim) this.sim.setDuration(d);
229 this.updateButtons();
230 });
231 const s = document.createElement("span"); s.textContent = " s";
232 durLabel.append(this.durationInput, s);
233 this.startBtn = this.button("Start", () => this.start(), "primary");
234 this.pauseBtn = this.button("Pause", () => this.pause());
235 this.stopBtn = this.button("Stop", () => this.stopRun());
236 this.continueBtn = this.button("Continue", () => this.continueRun());
237 c.append(this.runModeSel, durLabel, this.startBtn, this.pauseBtn, this.stopBtn, this.continueBtn);
238 this.headProgress = document.createElement("div");
239 this.headProgress.className = "progress";
240 this.headProgress.hidden = true;
241 this.headProgressBar = document.createElement("div");
242 this.headProgress.append(this.headProgressBar);
243 this.runStatus = document.createElement("div");
244 this.runStatus.className = "status";
245 $("sidebarHead").append(this.headProgress, this.runStatus);
246 }
248 /** The progress bar under the run controls (fraction 0..1, or null to hide) with a status message. */
249 private setProgress(fraction: number | null, message?: string): void {
250 this.headProgress.hidden = fraction === null;
251 if (fraction !== null) this.headProgressBar.style.width = `${Math.round(100 * fraction)}%`;
252 if (message !== undefined) this.runStatus.textContent = message;
253 }
255 private button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
256 const b = document.createElement("button");
257 b.textContent = label;
258 if (cls) b.className = cls;
259 b.addEventListener("click", onClick);
260 return b;
261 }
263 private buildForm(): void {
264 this.form = new ParamForm(
265 $("params"),
266 [
267 {
268 title: "Strike",
269 notes: true,
270 fields: [
271 { key: "strikeFracX", label: "Position x (fraction of width)", step: 0.05, min: 0, max: 1 },
272 { key: "strikeFracY", label: "Position y (fraction of height)", step: 0.05, min: 0, max: 1 },
273 { key: "strikeVelocity", label: "Stick speed", unit: "m/s", step: 0.5, min: 0.01 },
274 { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, min: 1 },
275 { key: "contactStiffness", label: "Contact stiffness K", unit: "N/m^a", step: 1e6, min: 1e5 },
276 { key: "contactExponent", label: "Contact exponent a", step: 0.1, min: 1, max: 3 },
277 { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, min: 0.5 },
278 ],
279 },
280 {
281 title: "Listener",
282 fields: [
283 { key: "listenerDist", label: "Distance", unit: "m", step: 0.1, min: 0.05, live: true },
284 { key: "listenerAngle", label: "Angle from the normal", unit: "deg", step: 5, min: 0, max: 90, live: true },
285 { key: "listenerAzimuth", label: "Azimuth", unit: "deg", step: 15, min: -180, max: 180, live: true },
286 ],
287 },
288 {
289 title: "Stand",
290 notes: true,
291 fields: [
292 { key: "standOn", label: "On a stand (off: free plate)", kind: "checkbox" },
293 { key: "padInnerRadius", label: "Felt inner radius", unit: "mm", scale: 1e-3, step: 1, min: 0 },
294 { key: "padOuterRadius", label: "Felt outer radius", unit: "mm", scale: 1e-3, step: 1, min: 1 },
295 { key: "padThickness", label: "Felt thickness", unit: "mm", scale: 1e-3, step: 1, min: 0.5 },
296 { key: "padE", label: "Felt modulus E", unit: "MPa", scale: 1e6, step: 0.1, min: 0.01 },
297 { key: "padLossFactor", label: "Felt loss factor", step: 0.05, min: 0, max: 2 },
298 { key: "topPad", label: "Top pad under the wing nut", kind: "checkbox" },
299 { key: "preload", label: "Preload of the top pad", unit: "mm", scale: 1e-3, step: 0.25, min: 0 },
300 { key: "standX", label: "Rod position x", unit: "mm", scale: 1e-3, step: 5 },
301 { key: "standY", label: "Rod position y", unit: "mm", scale: 1e-3, step: 5 },
302 ],
303 },
304 {
305 title: "Grid and numerics",
306 notes: true,
307 fields: [
308 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 1, min: 2 },
309 { key: "modeMaxFreq", label: "Modes up to", unit: "Hz", step: 1000, min: 200 },
310 { key: "padLiftoff", label: "Pads lift off (full model)", kind: "checkbox" },
311 { key: "fs", label: "Sample rate", kind: "select", options: [{ value: 44100, label: "44.1 kHz" }, { value: 48000, label: "48 kHz" }, { value: 96000, label: "96 kHz" }] },
312 { key: "fps", label: "Movie frame rate", kind: "select", options: [{ value: 60, label: "60" }, { value: 120, label: "120" }, { value: 240, label: "240" }] },
313 ],
314 },
315 ],
316 defaultParams,
317 (key, _value, live) => this.onParam(key, live),
318 );
319 }
321 private onParam(key: keyof SimParams, live: boolean): void {
322 const p = this.form.get();
323 if (key === "spacing" || key === "modeMaxFreq" || key === "fs" || STAND_KEYS.has(key)) {
324 // The stand is part of the plate the solvers see, so it rebuilds the plate like the grid does.
325 if (this.design) this.rebuildPlate(this.design);
326 return;
327 }
328 if (key === "fps") { this.updateNotes(); return; }
329 if (key === "padLiftoff") { this.updateNotes(); return; }
330 if (live && this.sim) this.sim.setListener(p);
331 if (key.startsWith("strike") || key.startsWith("stick") || key.startsWith("contact") || key.startsWith("listener")) {
332 this.updateMarkers();
333 this.updateNotes();
334 if (this.runMode === "linear" && this.synthesized) this.scheduleSynth();
335 }
336 this.tutorial?.refresh();
337 }
339 private buildViewToolbar(): void {
340 const t = $("viewToolbar");
341 const modeSel = document.createElement("select");
342 modeSel.innerHTML = `<option value="3d">3D</option><option value="2d">2D</option>`;
343 modeSel.addEventListener("change", () => { this.viewOpts.mode = modeSel.value as ViewMode; this.renderer.setOptions(this.viewOpts); });
344 const colorSel = document.createElement("select");
345 colorSel.innerHTML = `<option value="height">Height</option><option value="thickness">Thickness</option>`;
346 colorSel.addEventListener("change", () => { this.viewOpts.color = colorSel.value as ColorMode; this.renderer.setOptions(this.viewOpts); });
347 this.colorSel = colorSel;
348 const exag = document.createElement("input");
349 exag.type = "number"; exag.min = "1"; exag.step = "5"; exag.value = String(this.viewOpts.exaggeration); exag.style.width = "60px";
350 exag.addEventListener("change", () => { this.viewOpts.exaggeration = Math.max(0.1, Number(exag.value) || 1); this.renderer.setOptions(this.viewOpts); });
351 const range = document.createElement("input");
352 range.type = "number"; range.min = "0"; range.step = "0.1"; range.value = "0"; range.style.width = "60px";
353 range.addEventListener("change", () => { this.viewOpts.range = Math.max(0, Number(range.value) || 0) * 1e-3; this.renderer.setOptions(this.viewOpts); });
354 const prof = document.createElement("input");
355 prof.type = "checkbox"; prof.checked = this.viewOpts.showProfile;
356 prof.addEventListener("change", () => { this.viewOpts.showProfile = prof.checked; this.renderer.setOptions(this.viewOpts); });
357 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
358 t.append(lab("View", modeSel), lab("Color", colorSel), lab("Exaggeration", exag), lab("Range (mm, 0 = auto)", range), lab("Show profile", prof));
359 if (!this.device) {
360 modeSel.value = "2d"; modeSel.disabled = true;
361 const w = document.createElement("span"); w.id = "nogpu"; w.textContent = "WebGPU is not available: the 2D view and the linear model only.";
362 t.append(w);
363 }
364 }
366 private buildMovieToolbar(): void {
367 const t = $("movieToolbar");
368 this.playBtn = this.button("Play", () => this.togglePlay());
369 this.liveBtn = this.button("Live", () => this.setView("live"));
370 this.designBtn = this.button("Design", () => this.setView("design"));
371 this.scrub = document.createElement("input");
372 this.scrub.type = "range"; this.scrub.className = "scrub"; this.scrub.min = "0"; this.scrub.max = "0"; this.scrub.value = "0";
373 this.scrub.addEventListener("input", () => { this.frameIndex = Number(this.scrub.value); this.setView("movie"); this.showFrame(); });
374 this.timeLabel = document.createElement("span");
375 this.timeLabel.textContent = "0.000 s";
376 const levelSel = document.createElement("select");
377 levelSel.innerHTML = `<option value="absolute">Absolute level</option><option value="loudness">Normalize loudness</option><option value="peak">Normalize peak</option>`;
378 levelSel.addEventListener("change", () => { this.levelMode = levelSel.value as LevelMode; this.resetLoudness(); this.updatePeak(); });
379 const gain = document.createElement("input");
380 gain.type = "range"; gain.min = "-60"; gain.max = "40"; gain.step = "1"; gain.value = String(this.gainDb);
381 const gainLabel = document.createElement("span"); gainLabel.textContent = `${this.gainDb} dB`;
382 gain.addEventListener("input", () => { this.gainDb = Number(gain.value); gainLabel.textContent = `${this.gainDb} dB`; this.resetLoudness(); this.updatePeak(); });
383 this.peakLabel = document.createElement("span");
384 this.downloadBtn = this.button("Download .wav", () => this.download());
385 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
386 t.append(this.playBtn, this.liveBtn, this.designBtn, this.scrub, this.timeLabel, levelSel, lab("Gain", gain, gainLabel), this.peakLabel, this.downloadBtn);
387 // The loudness warning, shown in place of playing when the signal peaks near full scale.
388 this.loudWarn = document.createElement("div");
389 this.loudWarn.className = "loud-warn";
390 this.loudWarn.hidden = true;
391 this.loudWarnText = document.createElement("span");
392 this.loudPlayBtn = this.button("Play anyway", () => { const go = this.loudGo; this.hideLoudWarning(); go?.(); });
393 const cancel = this.button("Cancel", () => this.hideLoudWarning());
394 this.loudWarn.append(this.loudWarnText, this.loudPlayBtn, cancel);
395 t.append(this.loudWarn);
396 }
398 /** Shows the loudness warning; `go` starts the playback if the user accepts. */
399 private showLoudWarning(peakDb: number, shortTermDb: number, go: () => void): void {
400 this.loudGo = go;
401 this.loudWarnText.textContent = `This recording is loud (peak ${peakDb.toFixed(1)} dBFS, short-term level ${shortTermDb.toFixed(1)} dBFS), enough to hurt in headphones. Turn your volume down before playing.`;
402 this.loudWarn.hidden = false;
403 this.loudPlayBtn.focus();
404 }
406 private hideLoudWarning(): void {
407 this.loudWarn.hidden = true;
408 this.loudGo = null;
409 }
411 private buildSpecToolbar(): void {
412 const t = $("specToolbar");
413 const sel = (opts: [string, string][], value: string, onChange: (v: string) => void) => {
414 const s = document.createElement("select");
415 for (const [v, l] of opts) { const o = document.createElement("option"); o.value = v; o.textContent = l; s.append(o); }
416 s.value = value;
417 s.addEventListener("change", () => onChange(s.value));
418 return s;
419 };
420 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
421 const win = sel([["512", "512"], ["1024", "1024"], ["2048", "2048"], ["4096", "4096"]], "2048", (v) => { this.specOpts.window = Number(v); this.spec.setOptions({ window: Number(v), hop: Number(v) / 8 }); this.tutorial?.refresh(); });
422 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) }));
423 const db = sel([["60", "60 dB"], ["80", "80 dB"], ["100", "100 dB"]], "80", (v) => this.spec.setOptions({ dbRange: Number(v) }));
424 const log = document.createElement("input"); log.type = "checkbox";
425 log.addEventListener("change", () => { this.specOpts.logFreq = log.checked; this.spec.setOptions({ logFreq: log.checked }); this.tutorial?.refresh(); });
426 this.specWinSel = win; this.specLogChk = log;
427 const ticks = document.createElement("input"); ticks.type = "checkbox"; ticks.checked = this.showTicks;
428 ticks.addEventListener("change", () => { this.showTicks = ticks.checked; this.updateTicks(); });
429 t.append(lab("Spectrogram window", win), lab("Max", maxHz), lab("Range", db), lab("Log frequency", log), lab("Modal frequencies", ticks));
430 }
432 private buildSplitter(): void {
433 const sp = $("splitter"), sb = $("sidebar");
434 const saved = localStorage.getItem("cymbalsim.sidebar");
435 if (saved) sb.style.width = saved;
436 let dragging = false;
437 sp.addEventListener("mousedown", (e) => { dragging = true; e.preventDefault(); });
438 window.addEventListener("mousemove", (e) => {
439 if (!dragging) return;
440 const w = Math.max(280, Math.min(window.innerWidth - 300, e.clientX));
441 sb.style.width = `${w}px`;
442 this.renderer.resize(); this.spec.resize();
443 });
444 window.addEventListener("mouseup", () => { if (dragging) { dragging = false; localStorage.setItem("cymbalsim.sidebar", sb.style.width); } });
445 sp.addEventListener("dblclick", () => { sb.style.width = ""; localStorage.removeItem("cymbalsim.sidebar"); this.renderer.resize(); this.spec.resize(); });
446 }
448 // ---------- Design ----------
450 private onSource(source: string): void {
451 if (this.running) return;
452 let d: Design;
453 try {
454 d = compileDesign(source);
455 } catch (e) {
456 this.editor.setStatus(e instanceof Error ? e.message : String(e), "error");
457 return;
458 }
459 this.editor.setStatus(`Compiled: ${d.name}`, "ok");
460 this.rebuildPlate(d);
461 }
463 private rebuildPlate(d: Design): void {
464 const p = this.form.get();
465 let plate: Plate;
466 try {
467 plate = evaluatePlate(d, p.spacing, standFromParams(p));
468 } catch (e) {
469 this.editor.setStatus(e instanceof DesignError ? e.message : String(e), "error");
470 return;
471 }
472 this.design = d;
473 this.plate = plate;
474 this.plateSpacing = p.spacing;
475 this.designGeneration++;
476 this.modes = null; this.model = null; this.sys = null;
477 this.synthesized = false;
478 this.lastContact = null;
479 this.rec = null;
480 this.spec.reset();
481 this.spec.setOptions({ fs: p.fs });
482 this.renderer.setPlate(plate);
483 this.setView("design");
484 this.updateMarkers();
485 this.updateDesignInfo();
486 this.updateNotes();
487 this.updateButtons();
488 if (this.runMode === "linear") this.useCachedModes();
489 else this.modeBrowser.clear("Switch to the linear model to compute the modes.");
490 this.tutorial?.refresh();
491 }
493 /** The cache key of the modes of the current design and numerics. */
494 private modesKey(): string | null {
495 if (!this.design || !this.plate) return null;
496 const p = this.form.get();
497 const stand = Array.from(STAND_KEYS).map((k) => String(p[k])).join(",");
498 return `${this.plateSpacing}|${Math.min(p.modeMaxFreq, p.fs / 2)}|${stand}|${this.design.source}`;
499 }
501 /** Installs cached modes for the current design if there are any; otherwise leaves the browser empty. */
502 private useCachedModes(): void {
503 const key = this.modesKey();
504 const hit = key ? this.modesCache.get(key) : undefined;
505 if (hit && this.plate) {
506 this.installModes(hit.sys, hit.modes, hit.model, null);
507 if (this.pool) void this.pool.setModel(hit.model, null);
508 } else this.modeBrowser.clear("Press Synthesize to compute the modes of this design.");
509 }
511 /** Shows a mode set in the browser, the notes and the spectrogram ticks. */
512 private installModes(sys: LinearSystem, modes: ModeSet, model: ModalModel, secs: number | null): void {
513 const p = this.form.get();
514 const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
515 this.sys = sys; this.modes = modes; this.model = model;
516 const sigma = modalRates(model);
517 const t60 = (s: number) => (s > 0 ? LN1000 / s : Infinity);
518 const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: t60(sigma[m]), radiation: t60(model.sigmaRad[m]), material: t60(model.sigmaMat[m]), thermoelastic: t60(model.sigmaTe[m]), felt: t60(model.padSigma[m]) }));
519 const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)";
520 const when = secs === null ? "cached" : `in ${secs.toFixed(1)} s`;
521 this.modeBrowser.setModes(rows, `${modes.count} ${kind} up to ${(fMax / 1e3).toFixed(1)} kHz, ${when} (${sys.n} unknowns${modes.missing ? `, ${modes.missing} missing` : ""}). Click a mode to see its shape.`);
522 this.updateNotes();
523 this.updateTicks();
524 this.updateButtons();
525 this.tutorial?.refresh();
526 }
528 private updateDesignInfo(): void {
529 const pl = this.plate;
530 if (!pl) return;
531 const p = this.form.get();
532 const g = pl.grid;
533 const { mass } = massProperties(pl);
534 const hr = thicknessRange(pl);
535 const ps = profileStats(pl);
536 const sr = standRates(pl);
537 const lines: string[] = [];
538 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`);
539 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)`);
540 if (ps.peakToPeak > 0) {
541 const warn = ps.maxSlope > 0.1 ? ` <span class="warn">(above the shallow-shell range of about 0.1)</span>` : "";
542 lines.push(`Profile ${(ps.peakToPeak * 1e3).toFixed(1)} mm peak to peak, largest slope ${ps.maxSlope.toFixed(3)}${warn}`);
543 } else lines.push("Flat plate (no profile): the linear model is bending only");
544 if (pl.stand && sr) {
545 const pad = pl.stand.pad;
546 this.form.setNotes("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 in all, bounce of the rigid plate at ${sr.bounceHz.toFixed(0)} Hz, ${sr.nodes.length} pad nodes, preload ${(sr.preloadTop * 1e3).toFixed(2)} mm on top and ${(sr.preloadBottom * 1e3).toFixed(2)} mm below. The pads act on the plate nodes within the annulus whether or not the outline has a hole there.`);
547 lines.push(`On the stand (see the Stand parameters): bounce ${sr.bounceHz.toFixed(0)} Hz`);
548 } else {
549 this.form.setNotes("Stand", pl.stand ? "The felt annulus covers no plate node: move the rod or enlarge the pads." : "Free plate: the stand is off.");
550 lines.push("Free plate (the stand is off)");
551 }
552 const geom = plateGeometry(pl);
553 const est = (f: number) => estimatedRate(pl, geom, f);
554 const t60 = rateText;
555 const th = pl.mat.thermal;
556 lines.push(`Losses: loss factor ${pl.mat.eta.toExponential(1)}${th ? `; thermal constants alpha ${(th.alpha * 1e6).toFixed(1)}e-6 /K, cp ${th.cp.toFixed(0)} J/(kg K), k ${th.k.toFixed(0)} W/(m K)` : "; no thermal constants, so no thermoelastic damping"}; coincidence at ${(geom.fc / 1e3).toFixed(1)} kHz, perimeter ${(geom.perimeter * 1e3).toFixed(0)} mm`);
557 lines.push(`Estimated decay T60: ${t60(est(100).total)} / ${t60(est(1000).total)} / ${t60(est(5000).total)} s at 100 Hz / 1 kHz / 5 kHz, of which radiation alone ${t60(est(100).radiation)} / ${t60(est(1000).radiation)} / ${t60(est(5000).radiation)} s; the modes give the exact values.`);
558 void p;
559 $("designInfo").innerHTML = lines.join("\n");
560 }
562 private updateNotes(): void {
563 const pl = this.plate;
564 if (!pl) return;
565 const p = this.form.get();
566 const g = pl.grid;
567 const hm = meanThickness(pl);
568 const f3 = dispersionFrequency(pl.mat, hm, g.hx, 0.03), f10 = dispersionFrequency(pl.mat, hm, g.hx, 0.1);
569 const wmax = maxAngularFrequency(g, pl.mat, thicknessRange(pl).max);
570 const dtMax = (CFL * 2) / wmax;
571 const sps = Math.ceil(1 / (p.fs * dtMax));
572 const dt = 1 / (p.fs * sps);
573 const geom = plateGeometry(pl);
574 const fit = fitSigma(pl, geom);
575 const t60f = (f: number) => (LN1000 / fittedRate(fit, f)).toFixed(2);
576 const t60e = (f: number) => (LN1000 / estimatedRate(pl, geom, f).total).toFixed(2);
577 const lines = [
578 `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).`,
579 `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>` : ""}.`,
580 `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 to the estimate 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 (estimate ${t60e(100)} / ${t60e(1000)} / ${t60e(5000)}); in-plane rate ${inPlaneRate(pl).toFixed(2)} 1/s.`,
581 ];
582 this.form.setNotes("Grid and numerics", lines.join("\n"));
583 const patch = strikePatch(pl, p);
584 const sp = strikePoint(pl, p);
585 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).`];
586 if (this.lastContact) sl.push(`Last linear strike: peak force ${this.lastContact.peakForce.toFixed(0)} N, contact ${(this.lastContact.contactTime * 1e3).toFixed(2)} ms.`);
587 if (this.sim) { const st = this.sim.status().stick; sl.push(`Full simulation: peak force ${st.maxForce.toFixed(0)} N${st.withdrawn ? ", stick withdrawn" : ""}.`); }
588 this.form.setNotes("Strike", sl.join("\n"));
589 }
591 private updateMarkers(): void {
592 const pl = this.plate;
593 if (!pl) return;
594 const p = this.form.get();
595 const sp = strikePoint(pl, p);
596 const L = listenerPosition(p);
597 this.renderer.setMarkers({
598 strike: { x: sp.x, y: sp.y, radius: p.contactRadius },
599 stand: pl.stand ? { x: pl.stand.x, y: pl.stand.y, innerRadius: pl.stand.pad.innerRadius, outerRadius: pl.stand.pad.outerRadius } : null,
600 listener: L,
601 });
602 }
604 // ---------- Linear model ----------
606 /** Computes the modes of the current design unless they are cached; resolves when they are installed (or with an error). */
607 private computeModes(): Promise<void> {
608 const pl = this.plate, gen = this.designGeneration, key = this.modesKey();
609 if (!pl || !key) return Promise.resolve();
610 const hit = this.modesCache.get(key);
611 if (hit) {
612 if (this.model !== hit.model) { this.installModes(hit.sys, hit.modes, hit.model, null); if (this.pool) return this.pool.setModel(hit.model, null); }
613 return Promise.resolve();
614 }
615 const p = this.form.get();
616 const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
617 this.modeBrowser.clear("Computing the modes...");
618 this.setProgress(0, "Computing the modes...");
619 this.updateButtons();
620 this.modesBusy = this.modesBusy.then(async () => {
621 if (gen !== this.designGeneration) return;
622 const t0 = performance.now();
623 let sys: LinearSystem;
624 try {
625 sys = assemble(pl);
626 } catch (e) {
627 this.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`);
628 throw e;
629 }
630 let modes: ModeSet;
631 try {
632 if (this.pool) modes = await this.pool.modes(sys, fMax, (d, t) => { if (gen === this.designGeneration) this.setProgress(t ? d / t : 0, `Computing the modes: slice ${d} of ${t}`); });
633 else {
634 const { computeModes, localRunner } = await import("./modal/modes.ts");
635 modes = await computeModes(sys, fMax, localRunner(sys));
636 }
637 } catch (e) {
638 this.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`);
639 throw e;
640 }
641 const model = modalModel(pl, sys, modes);
642 // The radiation and the thermoelastic loss of every mode, from its shape.
643 this.setProgress(1, "Computing the radiation of the modes...");
644 const dp = dampingPlate(pl);
645 try {
646 if (this.pool) { await this.pool.setModel(model, dp); await this.pool.rates(model); }
647 else computeDamping(model, dp);
648 } catch (e) {
649 this.setProgress(null, `Loss computation failed: ${e instanceof Error ? e.message : String(e)}`);
650 throw e;
651 }
652 this.modesCache.set(key, { sys, modes, model });
653 while (this.modesCache.size > 4) this.modesCache.delete(this.modesCache.keys().next().value!);
654 if (gen !== this.designGeneration) { this.setProgress(null); return; }
655 this.setProgress(null, "");
656 this.installModes(sys, modes, model, (performance.now() - t0) / 1000);
657 });
658 return this.modesBusy;
659 }
661 private showMode(m: number | null): void {
662 if (m === null) { this.setView(this.rec ? "movie" : "design"); return; }
663 if (!this.model) return;
664 this.view = "mode";
665 const shape = modeShapeOnGrid(this.model, m);
666 // Scale the mass-normalized shape to a visible amplitude: 1 mm peak.
667 let peak = 0;
668 for (const v of shape) peak = Math.max(peak, Math.abs(v));
669 const w = new Float32Array(shape.length);
670 for (let k = 0; k < w.length; k++) w[k] = (shape[k] / (peak || 1)) * 1e-3;
671 this.renderer.setField(w);
672 this.overlay.textContent = `Mode ${m + 1}: ${this.model.freqs[m].toFixed(1)} Hz (shape scaled to 1 mm)`;
673 }
675 private scheduleSynth(): void {
676 if (this.synthTimer !== null) clearTimeout(this.synthTimer);
677 this.synthTimer = window.setTimeout(() => { this.synthTimer = null; void this.runLinear(); }, 250);
678 }
680 /** Makes sure the modes of the current design are computed and installed in the pool. */
681 async ensureModes(): Promise<void> {
682 if (!this.plate) return;
683 if (!this.model || this.modesKey() !== this.currentModesKey) {
684 await this.computeModes();
685 this.currentModesKey = this.modesKey();
686 }
687 }
689 /** Synthesizes the strike with the current parameters (linear model). */
690 async runLinear(): Promise<void> {
691 if (!this.plate) return;
692 if (this.synthBusy) { this.synthPending = true; return; }
693 try {
694 await this.ensureModes();
695 } catch {
696 return;
697 }
698 const pl = this.plate, model = this.model;
699 if (!pl || !model) return;
700 this.synthBusy = true;
701 const p = this.form.get();
702 const gen = this.designGeneration;
703 const t0 = performance.now();
704 this.runStatus.textContent = "Synthesizing...";
705 try {
706 let audio: Float32Array, frames: Float32Array, nFrames: number, contact: ContactResult;
707 if (this.pool) {
708 const r = await this.pool.synthesize(model, pl, p, (a, c) => {
709 if (gen !== this.designGeneration) return;
710 this.installRecording(a, [], p, "linear");
711 this.lastContact = c;
712 this.runStatus.textContent = `Audio ready in ${((performance.now() - t0) / 1000).toFixed(2)} s; movie frames...`;
713 });
714 audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
715 } else {
716 const { synthesize } = await import("./modal/synth.ts");
717 const r = synthesize(model, pl, p, true);
718 audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
719 }
720 if (gen !== this.designGeneration) return;
721 const n = pl.grid.n;
722 const frameList: Float32Array[] = [];
723 for (let f = 0; f < nFrames; f++) frameList.push(frames.subarray(f * n, (f + 1) * n));
724 this.installRecording(audio, frameList, p, "linear");
725 this.lastContact = contact;
726 this.synthesized = true;
727 const secs = (performance.now() - t0) / 1000;
728 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.`;
729 this.updateNotes();
730 } catch (e) {
731 this.runStatus.textContent = `Synthesis failed: ${e instanceof Error ? e.message : String(e)}`;
732 } finally {
733 this.synthBusy = false;
734 this.tutorial?.refresh();
735 if (this.synthPending) { this.synthPending = false; void this.runLinear(); }
736 }
737 }
739 // ---------- Full simulation ----------
741 async runFull(): Promise<void> {
742 const pl = this.plate;
743 if (!pl || !this.device || this.running) return;
744 const p = this.form.get();
745 this.sim?.destroy();
746 const fit = fitSigma(pl);
747 const loss: FullLoss = fullLoss(fit, standRates(pl), inPlaneRate(pl));
748 this.sim = new Simulation(this.device, pl, p, loss);
749 this.rec = { audio: new Float32Array(0), samples: 0, fs: p.fs, frames: [new Float32Array(pl.grid.n)], fps: p.fps, source: "full" };
750 this.resetLoudness();
751 this.spec.reset();
752 this.spec.setOptions({ fs: p.fs });
753 this.spec.setDuration(p.duration);
754 this.running = true;
755 this.paused = false;
756 this.setLocked(true);
757 this.setView("live");
758 this.updateButtons();
759 await this.fullLoop();
760 }
762 private async fullLoop(): Promise<void> {
763 const sim = this.sim;
764 if (!sim) return;
765 const t0 = performance.now();
766 let lastUi = 0;
767 while (this.running && sim.phase === "recording") {
768 if (this.paused) { await new Promise((r) => setTimeout(r, 50)); continue; }
769 await sim.runBatch();
770 if (!this.rec || this.sim !== sim) break;
771 // New audio and frames.
772 const rec = this.rec;
773 const newSamples = sim.samples - rec.samples;
774 if (newSamples > 0) {
775 const chunk = sim.audio.subarray(rec.samples, sim.samples);
776 this.spec.append(chunk);
777 rec.audio = sim.audio; rec.samples = sim.samples;
778 }
779 rec.frames = sim.frames;
780 if (this.view === "live") this.renderer.setField(sim.frames[sim.frames.length - 1]);
781 const now = performance.now();
782 if (now - lastUi > 100) {
783 lastUi = now;
784 const st = sim.status();
785 const elapsed = (now - t0) / 1000;
786 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" : ""}`;
787 this.scrub.max = String(Math.max(0, rec.frames.length - 1));
788 this.updatePeak();
789 }
790 }
791 if (this.sim === sim) {
792 this.running = false;
793 this.setLocked(false);
794 const st = sim.status();
795 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.`;
796 if (this.rec) { this.rec.audio = sim.recordedAudio(); this.rec.samples = sim.samples; this.rec.frames = sim.frames; }
797 this.updateNotes();
798 this.updateButtons();
799 this.updatePeak();
800 this.tutorial?.refresh();
801 }
802 }
804 // ---------- Run control ----------
806 private setRunMode(mode: RunMode): void {
807 if (mode === "full" && !this.device) mode = "linear";
808 this.runMode = mode;
809 this.runModeSel.value = mode;
810 if (mode === "linear" && this.plate && !this.modes) this.useCachedModes();
811 if (mode === "full") this.modeBrowser.clear("Switch to the linear model to compute the modes.");
812 this.updateButtons();
813 this.tutorial?.refresh();
814 }
816 private start(): void {
817 if (this.runMode === "linear") void this.runLinear();
818 else void this.runFull();
819 }
820 private pause(): void {
821 if (!this.running) return;
822 this.paused = !this.paused;
823 this.pauseBtn.textContent = this.paused ? "Resume" : "Pause";
824 }
825 private stopRun(): void {
826 if (this.sim && this.running) { this.sim.stop(); this.running = false; }
827 this.paused = false;
828 this.pauseBtn.textContent = "Pause";
829 this.setLocked(false);
830 this.updateButtons();
831 }
832 private continueRun(): void {
833 const sim = this.sim;
834 if (!sim || this.running || !sim.canContinue()) return;
835 sim.setDuration(this.form.get().duration);
836 this.running = true;
837 this.setLocked(true);
838 this.setView("live");
839 this.updateButtons();
840 void this.fullLoop();
841 }
843 private setLocked(locked: boolean): void {
844 this.editor.setLocked(locked);
845 this.form.setLocked(locked);
846 this.runModeSel.disabled = locked;
847 }
849 private updateButtons(): void {
850 const linear = this.runMode === "linear";
851 const canLinear = linear && !!this.plate && !this.synthBusy;
852 const canFull = !linear && !!this.plate && !!this.device && !this.running;
853 this.startBtn.disabled = !(canLinear || canFull);
854 this.startBtn.textContent = linear ? "Synthesize" : "Start";
855 this.pauseBtn.disabled = linear || !this.running;
856 this.stopBtn.disabled = linear || !this.running;
857 this.continueBtn.disabled = linear || this.running || !this.sim || !this.sim.canContinue() || this.sim.samples >= Math.round(this.form.get().duration * this.form.get().fs);
858 this.pauseBtn.hidden = linear; this.stopBtn.hidden = linear; this.continueBtn.hidden = linear;
859 this.liveBtn.hidden = linear;
860 }
862 // ---------- Recording, view, playback ----------
864 private installRecording(audio: Float32Array, frames: Float32Array[], p: SimParams, source: RunMode): void {
865 this.rec = { audio, samples: audio.length, fs: p.fs, frames, fps: p.fps, source };
866 this.resetLoudness();
867 this.spec.setOptions({ fs: p.fs });
868 this.spec.setDuration(p.duration);
869 this.spec.setSignal(audio);
870 this.scrub.max = String(Math.max(0, frames.length - 1));
871 if (frames.length > 0) {
872 this.frameIndex = 0;
873 if (this.view !== "mode") { this.setView("movie"); this.showFrame(); }
874 }
875 this.updatePeak();
876 this.updateButtons();
877 }
879 private setView(v: ViewState): void {
880 this.view = v;
881 if (v === "design") { this.renderer.setField(null); this.overlay.textContent = this.plate ? "Stress-free shape" : ""; }
882 else if (v === "live") { this.overlay.textContent = "Live"; if (this.sim) this.renderer.setField(this.sim.frames[this.sim.frames.length - 1]); }
883 else if (v === "movie") this.showFrame();
884 }
886 private showFrame(): void {
887 const rec = this.rec;
888 if (!rec || rec.frames.length === 0) return;
889 const f = Math.max(0, Math.min(rec.frames.length - 1, this.frameIndex));
890 this.frameIndex = f;
891 this.scrub.value = String(f);
892 this.timeLabel.textContent = `${(f / rec.fps).toFixed(3)} s`;
893 this.renderer.setField(rec.frames[f]);
894 this.overlay.textContent = `${rec.source === "linear" ? "Linear" : "Full"} model, t = ${(f / rec.fps).toFixed(3)} s`;
895 }
897 private playable(): Float32Array | null {
898 const rec = this.rec;
899 if (!rec || rec.samples === 0) return null;
900 const sig = rec.audio.subarray(0, rec.samples);
901 return applyLevel(sig, levelGain(sig, rec.fs, this.levelMode, this.gainDb));
902 }
904 /** Forgets the user's acceptance of the loudness warning: called when the signal to be played changes (a new recording, the level mode, the gain), not when a live recording merely grows. */
905 private resetLoudness(): void {
906 this.hideLoudWarning();
907 this.loudAccepted = false;
908 }
910 private updatePeak(): void {
911 const s = this.playable();
912 if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; }
913 const db = peakDbfs(s);
914 this.peakLabel.textContent = `Peak ${db.toFixed(1)} dBFS${db > -0.5 ? " (clipping)" : ""}`;
915 this.peakLabel.className = db > -0.5 ? "badge warn" : "badge";
916 this.downloadBtn.disabled = false;
917 this.playBtn.disabled = false;
918 }
920 private togglePlay(): void {
921 if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
922 const s = this.playable(), rec = this.rec;
923 if (!s || !rec) return;
924 // A loud signal can hurt in headphones: warn before playing it, once per signal; any
925 // change of the recording, the level mode or the gain asks again.
926 const l = loudness(s, rec.fs);
927 if (l.loud && !this.loudAccepted) {
928 this.showLoudWarning(l.peakDb, l.shortTermDb, () => { this.loudAccepted = true; this.startPlayback(s, rec); });
929 return;
930 }
931 this.hideLoudWarning();
932 this.startPlayback(s, rec);
933 }
935 private startPlayback(s: Float32Array, rec: Recording): void {
936 // Play from the scrubbed position only when the user has scrubbed to one; from the live
937 // view, or from the end of the recording, start over.
938 const atEnd = rec.frames.length > 1 && this.frameIndex >= rec.frames.length - 1;
939 const start = this.view === "movie" && rec.frames.length > 1 && !atEnd ? this.frameIndex / rec.fps : 0;
940 this.playBtn.textContent = "Stop";
941 const prevView = this.view;
942 this.player.play(s, rec.fs, start, () => { this.playBtn.textContent = "Play"; this.spec.setCursor(null); if (prevView === "live") this.setView("live"); });
943 const tick = () => {
944 if (!this.player.playing) return;
945 const t = this.player.position();
946 this.spec.setCursor(t);
947 if (rec.frames.length > 1) { this.frameIndex = Math.round(t * rec.fps); this.view = "movie"; this.showFrame(); }
948 requestAnimationFrame(tick);
949 };
950 requestAnimationFrame(tick);
951 }
953 private download(): void {
954 const s = this.playable(), rec = this.rec;
955 if (!s || !rec) return;
956 const blob = encodeWav(s, rec.fs);
957 const a = document.createElement("a");
958 a.href = URL.createObjectURL(blob);
959 a.download = `${(this.plate?.name ?? "cymbalsim").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${rec.source}.wav`;
960 a.click();
961 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
962 }
964 private updateTicks(): void {
965 this.spec.setTicks(this.showTicks && this.modes ? Array.from(this.modes.freqs) : null);
966 }
968 // ---------- Tutorial ----------
970 /** The state the tutorial's pages read. */
971 private tutorialContext(): TutorialContext {
972 const rec = this.rec;
973 const st = this.sim ? this.sim.status() : null;
974 return {
975 plate: this.plate,
976 design: this.design,
977 params: this.form.get(),
978 spacing: this.plateSpacing,
979 sys: this.sys,
980 modes: this.modes,
981 model: this.model,
982 lastContact: this.lastContact,
983 recording: rec && rec.samples > 0 ? { source: rec.source, seconds: rec.samples / rec.fs } : null,
984 full: st && st.totalSteps > 0 ? { peakForce: st.stick.maxForce, wmax: st.wmax, t: st.t, done: st.phase === "done" } : null,
985 gpu: this.device !== null,
986 runMode: this.runMode,
987 spectrogram: { ...this.specOpts },
988 memo: this.tutorialMemo,
989 };
990 }
992 /** The actions the tutorial's pages can take. */
993 private tutorialHost(): TutorialHost {
994 return {
995 ctx: () => this.tutorialContext(),
996 loadPreset: async (id) => { this.editor.selectPreset(id); },
997 loadScript: async (src) => { this.editor.setSource(src); this.onSource(src); },
998 setParam: (key, value) => {
999 if (key === "duration") {
1000 this.durationInput.value = String(value);
1001 this.durationInput.dispatchEvent(new Event("change"));
1002 return;
1003 }
1004 this.form.set(key, value);
1005 this.onParam(key, false);
1006 },
1007 computeModes: () => this.ensureModes(),
1008 showMode: (m) => this.modeBrowser.select(m),
1009 synthesize: async () => {
1010 if (this.runMode !== "linear") this.setRunMode("linear");
1011 await this.runLinear();
1012 },
1013 play: () => { if (!this.player.playing) this.togglePlay(); },
1014 setRunMode: (mode) => this.setRunMode(mode),
1015 runFull: () => this.runFull(),
1016 setSpectrogram: (o) => {
1017 if (o.window !== undefined) { this.specOpts.window = o.window; this.specWinSel.value = String(o.window); this.spec.setOptions({ window: o.window, hop: o.window / 8 }); }
1018 if (o.logFreq !== undefined) { this.specOpts.logFreq = o.logFreq; this.specLogChk.checked = o.logFreq; this.spec.setOptions({ logFreq: o.logFreq }); }
1019 this.tutorial?.refresh();
1020 },
1021 setViewOptions: (o) => {
1022 if (o.color !== undefined) { this.viewOpts.color = o.color; this.colorSel.value = o.color; }
1023 if (o.exaggeration !== undefined) this.viewOpts.exaggeration = o.exaggeration;
1024 this.renderer.setOptions(this.viewOpts);
1025 },
1026 };
1027 }
1029 // ---------- Tests ----------
1031 async selfTest(): Promise<string> {
1032 if (!this.device) return "no WebGPU";
1033 const lines: string[] = [];
1034 for (const id of ["flat-rectangle", "bowed-rectangle", "crash-16", "crash-bell-16", "ellipse"]) {
1035 const pr = presetById(id)!;
1036 const d = compileDesign(pr.source);
1037 const pl = evaluatePlate(d, 0.012, standFromParams(this.form.get()));
1038 const r = await runSelfTest(this.device, pl, this.form.get());
1039 lines.push(`${id}: max relative error ${r.maxRel.toExponential(2)} (${r.n} nodes)`);
1040 }
1041 return lines.join("\n");
1042 }
1044 private testStatus(): unknown {
1045 return {
1046 design: this.design?.name ?? null,
1047 modes: this.modes ? { count: this.modes.count, missing: this.modes.missing, first: Array.from(this.modes.freqs.slice(0, 5)) } : null,
1048 synthesized: this.synthesized,
1049 running: this.running,
1050 samples: this.rec?.samples ?? 0,
1051 frames: this.rec?.frames.length ?? 0,
1052 source: this.rec?.source ?? null,
1053 peakPressure: this.rec ? Array.from(this.rec.audio.subarray(0, this.rec.samples)).reduce((m, v) => Math.max(m, Math.abs(v)), 0) : 0,
1054 sim: this.sim ? this.sim.status() : null,
1055 contact: this.lastContact ? { peakForce: this.lastContact.peakForce, contactTime: this.lastContact.contactTime } : null,
1056 editorStatus: ($("design").querySelector(".editor-status") as HTMLElement | null)?.textContent ?? "",
1057 };
1058 }
1059}
1061const app = new App();
1062app.init().catch((e) => {
1063 console.error(e);
1064 $("viewOverlay").textContent = `Failed to start: ${e instanceof Error ? e.message : String(e)}`;
1065});
1067export { app, PRESETS, isFlat, formatNumber };