// The application: the design script, the two run modes (the linear modal // model on the CPU workers and the full nonlinear simulation on the GPU), // the plate view, the movie, the audio, and the spectrogram. import { compileDesign, evaluatePlate, isFlat, profileStats, type Design, DesignError } from "./design.ts"; import { PRESETS, DEFAULT_PRESET, presetById } from "./presets.ts"; import { type Plate, type SimParams, defaultParams, countInside, massProperties, meanThickness, thicknessRange, standRates, strikePoint, strikePatch, listenerPosition, dispersionFrequency, inPlaneSpeed, CFL, } from "./params.ts"; import { fitSigma, fittedRate, t60At, LN1000, decayRate } from "./loss.ts"; import { assemble, type LinearSystem } from "./model/assemble.ts"; import { maxAngularFrequency, fullLoss, type FullLoss } from "./model/reference.ts"; import { type ModeSet } from "./modal/modes.ts"; import { WorkerPool } from "./modal/pool.ts"; import { modalModel, modeShapeOnGrid, type ModalModel, type ContactResult } from "./modal/synth.ts"; import { Simulation } from "./sim/simulation.ts"; import { runSelfTest } from "./sim/selftest.ts"; import { PlateRenderer, type ViewMode, type ColorMode } from "./render/renderer.ts"; import { Spectrogram } from "./audio/spectrogram.ts"; import { Player } from "./audio/player.ts"; import { encodeWav } from "./audio/wav.ts"; import { levelGain, applyLevel, peakDbfs, peakSpl, loudness, type LevelMode } from "./audio/level.ts"; import { DesignEditor } from "./ui/editor.ts"; import { ParamForm, formatNumber } from "./ui/form.ts"; import { ModeBrowser } from "./ui/modes.ts"; import { Tutorial } from "./tutorial/tutorial.ts"; import { type Memo, type TutorialContext, type TutorialHost } from "./tutorial/steps.ts"; type RunMode = "linear" | "full"; type ViewState = "design" | "mode" | "live" | "movie"; const $ = (id: string): T => document.getElementById(id) as T; /** A recording: the listener signal and the movie of one run, from either model. */ interface Recording { audio: Float32Array; samples: number; fs: number; frames: Float32Array[]; fps: number; source: RunMode; } class App { // GPU device: GPUDevice | null = null; // Design and plate design: Design | null = null; plate: Plate | null = null; plateSpacing = 0; designGeneration = 0; // Linear model pool: WorkerPool | null = null; sys: LinearSystem | null = null; modes: ModeSet | null = null; model: ModalModel | null = null; modesBusy: Promise = Promise.resolve(); /** Modes of recent designs, keyed by the script, the spacing and the cutoff; the newest entry is last. */ modesCache = new Map(); headProgress!: HTMLDivElement; /** The cache key of the modes the pool currently holds. */ currentModesKey: string | null = null; headProgressBar!: HTMLDivElement; synthBusy = false; synthPending = false; synthesized = false; lastContact: ContactResult | null = null; // Full simulation sim: Simulation | null = null; running = false; paused = false; // Recording and view rec: Recording | null = null; view: ViewState = "design"; frameIndex = 0; runMode: RunMode = "linear"; // UI editor!: DesignEditor; form!: ParamForm; modeBrowser!: ModeBrowser; renderer!: PlateRenderer; spec!: Spectrogram; tutorial: Tutorial | null = null; tutorialMemo: Memo = {}; specOpts = { window: 2048, logFreq: false }; specWinSel!: HTMLSelectElement; specLogChk!: HTMLInputElement; colorSel!: HTMLSelectElement; player = new Player(); levelMode: LevelMode = "absolute"; gainDb = -40; showTicks = true; viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 1, range: 0 }; // Controls runModeSel!: HTMLSelectElement; durationInput!: HTMLInputElement; startBtn!: HTMLButtonElement; pauseBtn!: HTMLButtonElement; stopBtn!: HTMLButtonElement; continueBtn!: HTMLButtonElement; runStatus!: HTMLDivElement; scrub!: HTMLInputElement; timeLabel!: HTMLSpanElement; playBtn!: HTMLButtonElement; liveBtn!: HTMLButtonElement; designBtn!: HTMLButtonElement; peakLabel!: HTMLSpanElement; downloadBtn!: HTMLButtonElement; overlay!: HTMLDivElement; synthTimer: number | null = null; // Loudness warning loudWarn!: HTMLDivElement; loudWarnText!: HTMLSpanElement; loudPlayBtn!: HTMLButtonElement; loudGo: (() => void) | null = null; /** Whether the user has agreed to play the current signal; any change of the recording, the level mode or the gain resets it. */ loudAccepted = false; async init(): Promise { const url = new URLSearchParams(location.search); if (!url.has("nogpu") && navigator.gpu) { try { const adapter = await navigator.gpu.requestAdapter(); if (adapter) this.device = await adapter.requestDevice(); } catch (e) { console.warn("WebGPU unavailable", e); } } if (typeof Worker !== "undefined") this.pool = new WorkerPool(WorkerPool.defaultSize()); this.buildControls(); this.buildForm(); this.buildViewToolbar(); this.buildMovieToolbar(); this.buildSpecToolbar(); this.buildSplitter(); this.renderer = new PlateRenderer($("view"), this.device); if (!this.renderer.has3d) this.viewOpts.mode = "2d"; this.renderer.setOptions(this.viewOpts); this.overlay = $("viewOverlay"); const p = this.form.get(); this.spec = new Spectrogram($("spec"), { fs: p.fs, window: 2048, hop: 256, maxHz: 12000, dbRange: 80, logFreq: false }); this.spec.setDuration(p.duration); this.modeBrowser = new ModeBrowser($("modes"), (m) => this.showMode(m)); this.editor = new DesignEditor($("design"), { onSource: (src) => this.onSource(src) }); this.tutorial = new Tutorial($("tutorial"), this.tutorialHost(), (open) => { $("tutorialBtn").classList.toggle("active", open); this.renderer.resize(); this.spec.resize(); }); $("tutorialBtn").addEventListener("click", () => this.tutorial?.toggle()); if (url.has("mode")) this.setRunMode(url.get("mode") === "full" ? "full" : "linear"); else this.setRunMode(this.device ? "linear" : "linear"); // The parameters come first, so that the preset is evaluated at the requested spacing. this.form.applyUrl(url); this.durationInput.value = String(this.form.get().duration); const preset = url.get("preset"); this.editor.selectPreset(preset && presetById(preset) ? preset : DEFAULT_PRESET); // The tutorial opens on request (?tutorial=1 or ?tutorial=) or when it was open last time. const tut = url.get("tutorial"); if (tut !== null) this.tutorial.open(tut === "1" || tut === "" ? undefined : tut); else if (this.tutorial.wasOpen()) this.tutorial.open(); const loop = () => { this.renderer.render(); requestAnimationFrame(loop); }; requestAnimationFrame(loop); window.addEventListener("resize", () => { this.renderer.resize(); this.spec.resize(); }); this.renderer.resize(); this.spec.resize(); // Test hooks. (window as unknown as { cymbalsim: unknown }).cymbalsim = { app: this, selftest: () => this.selfTest(), runLinear: () => this.runLinear(), runFull: () => this.runFull(), modesReady: () => this.modesBusy, status: () => this.testStatus(), tutorial: this.tutorial, }; if (url.has("selftest")) { const r = await this.selfTest(); this.overlay.textContent = r; } if (url.has("autostart")) { await this.modesBusy; this.start(); } } // ---------- Controls ---------- private buildControls(): void { const c = $("controls"); this.runModeSel = document.createElement("select"); this.runModeSel.innerHTML = ``; this.runModeSel.addEventListener("change", () => this.setRunMode(this.runModeSel.value as RunMode)); const durLabel = document.createElement("label"); durLabel.textContent = "Duration"; this.durationInput = document.createElement("input"); this.durationInput.type = "number"; this.durationInput.min = "0.1"; this.durationInput.step = "0.5"; this.durationInput.style.width = "60px"; this.durationInput.addEventListener("change", () => { const d = Math.max(0.1, Number(this.durationInput.value) || 1); this.form.set("duration", d); this.spec.setDuration(d); if (this.sim) this.sim.setDuration(d); this.updateButtons(); }); const s = document.createElement("span"); s.textContent = " s"; durLabel.append(this.durationInput, s); this.startBtn = this.button("Start", () => this.start(), "primary"); this.pauseBtn = this.button("Pause", () => this.pause()); this.stopBtn = this.button("Stop", () => this.stopRun()); this.continueBtn = this.button("Continue", () => this.continueRun()); c.append(this.runModeSel, durLabel, this.startBtn, this.pauseBtn, this.stopBtn, this.continueBtn); this.headProgress = document.createElement("div"); this.headProgress.className = "progress"; this.headProgress.hidden = true; this.headProgressBar = document.createElement("div"); this.headProgress.append(this.headProgressBar); this.runStatus = document.createElement("div"); this.runStatus.className = "status"; $("sidebarHead").append(this.headProgress, this.runStatus); } /** The progress bar under the run controls (fraction 0..1, or null to hide) with a status message. */ private setProgress(fraction: number | null, message?: string): void { this.headProgress.hidden = fraction === null; if (fraction !== null) this.headProgressBar.style.width = `${Math.round(100 * fraction)}%`; if (message !== undefined) this.runStatus.textContent = message; } private button(label: string, onClick: () => void, cls = ""): HTMLButtonElement { const b = document.createElement("button"); b.textContent = label; if (cls) b.className = cls; b.addEventListener("click", onClick); return b; } private buildForm(): void { this.form = new ParamForm( $("params"), [ { title: "Strike", notes: true, fields: [ { key: "strikeFracX", label: "Position x (fraction of width)", step: 0.05, min: 0, max: 1 }, { key: "strikeFracY", label: "Position y (fraction of height)", step: 0.05, min: 0, max: 1 }, { key: "strikeVelocity", label: "Stick speed", unit: "m/s", step: 0.5, min: 0.01 }, { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, min: 1 }, { key: "contactStiffness", label: "Contact stiffness K", unit: "N/m^a", step: 1e8, min: 1e5 }, { key: "contactExponent", label: "Contact exponent a", step: 0.1, min: 1, max: 3 }, { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, min: 0.5 }, ], }, { title: "Listener", fields: [ { key: "listenerDist", label: "Distance", unit: "m", step: 0.1, min: 0.05, live: true }, { key: "listenerAngle", label: "Angle from the normal", unit: "deg", step: 5, min: 0, max: 90, live: true }, { key: "listenerAzimuth", label: "Azimuth", unit: "deg", step: 15, min: -180, max: 180, live: true }, ], }, { title: "Grid and numerics", notes: true, fields: [ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 1, min: 2 }, { key: "modeMaxFreq", label: "Modes up to", unit: "Hz", step: 1000, min: 200 }, { key: "padLiftoff", label: "Pads lift off (full model)", kind: "checkbox" }, { key: "fs", label: "Sample rate", kind: "select", options: [{ value: 44100, label: "44.1 kHz" }, { value: 48000, label: "48 kHz" }, { value: 96000, label: "96 kHz" }] }, { key: "fps", label: "Movie frame rate", kind: "select", options: [{ value: 60, label: "60" }, { value: 120, label: "120" }, { value: 240, label: "240" }] }, ], }, ], defaultParams, (key, _value, live) => this.onParam(key, live), ); } private onParam(key: keyof SimParams, live: boolean): void { const p = this.form.get(); if (key === "spacing" || key === "modeMaxFreq" || key === "fs") { if (this.design) this.rebuildPlate(this.design); return; } if (key === "fps") { this.updateNotes(); return; } if (key === "padLiftoff") { this.updateNotes(); return; } if (live && this.sim) this.sim.setListener(p); if (key.startsWith("strike") || key.startsWith("stick") || key.startsWith("contact") || key.startsWith("listener")) { this.updateMarkers(); this.updateNotes(); if (this.runMode === "linear" && this.synthesized) this.scheduleSynth(); } this.tutorial?.refresh(); } private buildViewToolbar(): void { const t = $("viewToolbar"); const modeSel = document.createElement("select"); modeSel.innerHTML = ``; modeSel.addEventListener("change", () => { this.viewOpts.mode = modeSel.value as ViewMode; this.renderer.setOptions(this.viewOpts); }); const colorSel = document.createElement("select"); colorSel.innerHTML = ``; colorSel.addEventListener("change", () => { this.viewOpts.color = colorSel.value as ColorMode; this.renderer.setOptions(this.viewOpts); }); this.colorSel = colorSel; const exag = document.createElement("input"); exag.type = "number"; exag.min = "1"; exag.step = "5"; exag.value = String(this.viewOpts.exaggeration); exag.style.width = "60px"; exag.addEventListener("change", () => { this.viewOpts.exaggeration = Math.max(0.1, Number(exag.value) || 1); this.renderer.setOptions(this.viewOpts); }); const range = document.createElement("input"); range.type = "number"; range.min = "0"; range.step = "0.1"; range.value = "0"; range.style.width = "60px"; range.addEventListener("change", () => { this.viewOpts.range = Math.max(0, Number(range.value) || 0) * 1e-3; this.renderer.setOptions(this.viewOpts); }); const prof = document.createElement("input"); prof.type = "checkbox"; prof.checked = this.viewOpts.showProfile; prof.addEventListener("change", () => { this.viewOpts.showProfile = prof.checked; this.renderer.setOptions(this.viewOpts); }); const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; }; t.append(lab("View", modeSel), lab("Color", colorSel), lab("Exaggeration", exag), lab("Range (mm, 0 = auto)", range), lab("Show profile", prof)); if (!this.device) { modeSel.value = "2d"; modeSel.disabled = true; const w = document.createElement("span"); w.id = "nogpu"; w.textContent = "WebGPU is not available: the 2D view and the linear model only."; t.append(w); } } private buildMovieToolbar(): void { const t = $("movieToolbar"); this.playBtn = this.button("Play", () => this.togglePlay()); this.liveBtn = this.button("Live", () => this.setView("live")); this.designBtn = this.button("Design", () => this.setView("design")); this.scrub = document.createElement("input"); this.scrub.type = "range"; this.scrub.className = "scrub"; this.scrub.min = "0"; this.scrub.max = "0"; this.scrub.value = "0"; this.scrub.addEventListener("input", () => { this.frameIndex = Number(this.scrub.value); this.setView("movie"); this.showFrame(); }); this.timeLabel = document.createElement("span"); this.timeLabel.textContent = "0.000 s"; const levelSel = document.createElement("select"); levelSel.innerHTML = ``; levelSel.addEventListener("change", () => { this.levelMode = levelSel.value as LevelMode; this.updatePeak(); }); const gain = document.createElement("input"); gain.type = "range"; gain.min = "-60"; gain.max = "40"; gain.step = "1"; gain.value = String(this.gainDb); const gainLabel = document.createElement("span"); gainLabel.textContent = `${this.gainDb} dB`; gain.addEventListener("input", () => { this.gainDb = Number(gain.value); gainLabel.textContent = `${this.gainDb} dB`; this.updatePeak(); }); this.peakLabel = document.createElement("span"); this.downloadBtn = this.button("Download .wav", () => this.download()); const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; }; t.append(this.playBtn, this.liveBtn, this.designBtn, this.scrub, this.timeLabel, levelSel, lab("Gain", gain, gainLabel), this.peakLabel, this.downloadBtn); // The loudness warning, shown in place of playing when the signal peaks near full scale. this.loudWarn = document.createElement("div"); this.loudWarn.className = "loud-warn"; this.loudWarn.hidden = true; this.loudWarnText = document.createElement("span"); this.loudPlayBtn = this.button("Play anyway", () => { const go = this.loudGo; this.hideLoudWarning(); go?.(); }); const cancel = this.button("Cancel", () => this.hideLoudWarning()); this.loudWarn.append(this.loudWarnText, this.loudPlayBtn, cancel); t.append(this.loudWarn); } /** Shows the loudness warning; `go` starts the playback if the user accepts. */ private showLoudWarning(peakDb: number, shortTermDb: number, go: () => void): void { this.loudGo = go; 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.`; this.loudWarn.hidden = false; this.loudPlayBtn.focus(); } private hideLoudWarning(): void { this.loudWarn.hidden = true; this.loudGo = null; } private buildSpecToolbar(): void { const t = $("specToolbar"); const sel = (opts: [string, string][], value: string, onChange: (v: string) => void) => { const s = document.createElement("select"); for (const [v, l] of opts) { const o = document.createElement("option"); o.value = v; o.textContent = l; s.append(o); } s.value = value; s.addEventListener("change", () => onChange(s.value)); return s; }; const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; }; 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(); }); 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) })); const db = sel([["60", "60 dB"], ["80", "80 dB"], ["100", "100 dB"]], "80", (v) => this.spec.setOptions({ dbRange: Number(v) })); const log = document.createElement("input"); log.type = "checkbox"; log.addEventListener("change", () => { this.specOpts.logFreq = log.checked; this.spec.setOptions({ logFreq: log.checked }); this.tutorial?.refresh(); }); this.specWinSel = win; this.specLogChk = log; const ticks = document.createElement("input"); ticks.type = "checkbox"; ticks.checked = this.showTicks; ticks.addEventListener("change", () => { this.showTicks = ticks.checked; this.updateTicks(); }); t.append(lab("Spectrogram window", win), lab("Max", maxHz), lab("Range", db), lab("Log frequency", log), lab("Modal frequencies", ticks)); } private buildSplitter(): void { const sp = $("splitter"), sb = $("sidebar"); const saved = localStorage.getItem("cymbalsim.sidebar"); if (saved) sb.style.width = saved; let dragging = false; sp.addEventListener("mousedown", (e) => { dragging = true; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; const w = Math.max(280, Math.min(window.innerWidth - 300, e.clientX)); sb.style.width = `${w}px`; this.renderer.resize(); this.spec.resize(); }); window.addEventListener("mouseup", () => { if (dragging) { dragging = false; localStorage.setItem("cymbalsim.sidebar", sb.style.width); } }); sp.addEventListener("dblclick", () => { sb.style.width = ""; localStorage.removeItem("cymbalsim.sidebar"); this.renderer.resize(); this.spec.resize(); }); } // ---------- Design ---------- private onSource(source: string): void { if (this.running) return; let d: Design; try { d = compileDesign(source); } catch (e) { this.editor.setStatus(e instanceof Error ? e.message : String(e), "error"); return; } this.editor.setStatus(`Compiled: ${d.name}`, "ok"); this.rebuildPlate(d); } private rebuildPlate(d: Design): void { const p = this.form.get(); let plate: Plate; try { plate = evaluatePlate(d, p.spacing); } catch (e) { this.editor.setStatus(e instanceof DesignError ? e.message : String(e), "error"); return; } this.design = d; this.plate = plate; this.plateSpacing = p.spacing; this.designGeneration++; this.modes = null; this.model = null; this.sys = null; this.synthesized = false; this.lastContact = null; this.rec = null; this.spec.reset(); this.spec.setOptions({ fs: p.fs }); this.renderer.setPlate(plate); this.setView("design"); this.updateMarkers(); this.updateDesignInfo(); this.updateNotes(); this.updateButtons(); if (this.runMode === "linear") this.useCachedModes(); else this.modeBrowser.clear("Switch to the linear model to compute the modes."); this.tutorial?.refresh(); } /** The cache key of the modes of the current design and numerics. */ private modesKey(): string | null { if (!this.design || !this.plate) return null; const p = this.form.get(); return `${this.plateSpacing}|${Math.min(p.modeMaxFreq, p.fs / 2)}|${this.design.source}`; } /** Installs cached modes for the current design if there are any; otherwise leaves the browser empty. */ private useCachedModes(): void { const key = this.modesKey(); const hit = key ? this.modesCache.get(key) : undefined; if (hit && this.plate) { this.installModes(hit.sys, hit.modes, hit.model, null); if (this.pool) void this.pool.setModel(hit.model); } else this.modeBrowser.clear("Press Synthesize to compute the modes of this design."); } /** Shows a mode set in the browser, the notes and the spectrogram ticks. */ private installModes(sys: LinearSystem, modes: ModeSet, model: ModalModel, secs: number | null): void { const pl = this.plate!; const p = this.form.get(); const fMax = Math.min(p.modeMaxFreq, p.fs / 2); this.sys = sys; this.modes = modes; this.model = model; const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: LN1000 / Math.min(decayRate(pl.loss, f) + model.padSigma[m], 0.99 * 2 * Math.PI * f) })); const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)"; const when = secs === null ? "cached" : `in ${secs.toFixed(1)} s`; 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.`); this.updateNotes(); this.updateTicks(); this.updateButtons(); this.tutorial?.refresh(); } private updateDesignInfo(): void { const pl = this.plate; if (!pl) return; const p = this.form.get(); const g = pl.grid; const { mass } = massProperties(pl); const hr = thicknessRange(pl); const ps = profileStats(pl); const sr = standRates(pl); const lines: string[] = []; 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`); 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)`); if (ps.peakToPeak > 0) { const warn = ps.maxSlope > 0.1 ? ` (above the shallow-shell range of about 0.1)` : ""; lines.push(`Profile ${(ps.peakToPeak * 1e3).toFixed(1)} mm peak to peak, largest slope ${ps.maxSlope.toFixed(3)}${warn}`); } else lines.push("Flat plate (no profile): the linear model is bending only"); if (pl.stand && sr) { const pad = pl.stand.pad; 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`); } else lines.push("Free plate (no stand)"); 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`); void p; $("designInfo").innerHTML = lines.join("\n"); } private updateNotes(): void { const pl = this.plate; if (!pl) return; const p = this.form.get(); const g = pl.grid; const hm = meanThickness(pl); const f3 = dispersionFrequency(pl.mat, hm, g.hx, 0.03), f10 = dispersionFrequency(pl.mat, hm, g.hx, 0.1); const wmax = maxAngularFrequency(g, pl.mat, thicknessRange(pl).max); const dtMax = (CFL * 2) / wmax; const sps = Math.ceil(1 / (p.fs * dtMax)); const dt = 1 / (p.fs * sps); const fit = fitSigma(pl.loss, pl.mat, hm); const t60f = (f: number) => (LN1000 / fittedRate(fit, f)).toFixed(2); const lines = [ `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).`, `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 ? ` (${this.modes.missing} missing)` : ""}.`, `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)}).`, ]; this.form.setNotes("Grid and numerics", lines.join("\n")); const patch = strikePatch(pl, p); const sp = strikePoint(pl, p); 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).`]; if (this.lastContact) sl.push(`Last linear strike: peak force ${this.lastContact.peakForce.toFixed(0)} N, contact ${(this.lastContact.contactTime * 1e3).toFixed(2)} ms.`); if (this.sim) { const st = this.sim.status().stick; sl.push(`Full simulation: peak force ${st.maxForce.toFixed(0)} N${st.withdrawn ? ", stick withdrawn" : ""}.`); } this.form.setNotes("Strike", sl.join("\n")); } private updateMarkers(): void { const pl = this.plate; if (!pl) return; const p = this.form.get(); const sp = strikePoint(pl, p); const L = listenerPosition(p); this.renderer.setMarkers({ strike: { x: sp.x, y: sp.y, radius: p.contactRadius }, stand: pl.stand ? { x: pl.stand.x, y: pl.stand.y, innerRadius: pl.stand.pad.innerRadius, outerRadius: pl.stand.pad.outerRadius } : null, listener: L, }); } // ---------- Linear model ---------- /** Computes the modes of the current design unless they are cached; resolves when they are installed (or with an error). */ private computeModes(): Promise { const pl = this.plate, gen = this.designGeneration, key = this.modesKey(); if (!pl || !key) return Promise.resolve(); const hit = this.modesCache.get(key); if (hit) { if (this.model !== hit.model) { this.installModes(hit.sys, hit.modes, hit.model, null); if (this.pool) return this.pool.setModel(hit.model); } return Promise.resolve(); } const p = this.form.get(); const fMax = Math.min(p.modeMaxFreq, p.fs / 2); this.modeBrowser.clear("Computing the modes..."); this.setProgress(0, "Computing the modes..."); this.updateButtons(); this.modesBusy = this.modesBusy.then(async () => { if (gen !== this.designGeneration) return; const t0 = performance.now(); let sys: LinearSystem; try { sys = assemble(pl); } catch (e) { this.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`); throw e; } let modes: ModeSet; try { 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}`); }); else { const { computeModes, localRunner } = await import("./modal/modes.ts"); modes = await computeModes(sys, fMax, localRunner(sys)); } } catch (e) { this.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`); throw e; } const model = modalModel(pl, sys, modes); this.modesCache.set(key, { sys, modes, model }); while (this.modesCache.size > 4) this.modesCache.delete(this.modesCache.keys().next().value!); if (gen !== this.designGeneration) { this.setProgress(null); return; } if (this.pool) await this.pool.setModel(model); this.setProgress(null, ""); this.installModes(sys, modes, model, (performance.now() - t0) / 1000); }); return this.modesBusy; } private showMode(m: number | null): void { if (m === null) { this.setView(this.rec ? "movie" : "design"); return; } if (!this.model) return; this.view = "mode"; const shape = modeShapeOnGrid(this.model, m); // Scale the mass-normalized shape to a visible amplitude: 1 mm peak. let peak = 0; for (const v of shape) peak = Math.max(peak, Math.abs(v)); const w = new Float32Array(shape.length); for (let k = 0; k < w.length; k++) w[k] = (shape[k] / (peak || 1)) * 1e-3; this.renderer.setField(w); this.overlay.textContent = `Mode ${m + 1}: ${this.model.freqs[m].toFixed(1)} Hz (shape scaled to 1 mm)`; } private scheduleSynth(): void { if (this.synthTimer !== null) clearTimeout(this.synthTimer); this.synthTimer = window.setTimeout(() => { this.synthTimer = null; void this.runLinear(); }, 250); } /** Makes sure the modes of the current design are computed and installed in the pool. */ async ensureModes(): Promise { if (!this.plate) return; if (!this.model || this.modesKey() !== this.currentModesKey) { await this.computeModes(); this.currentModesKey = this.modesKey(); } } /** Synthesizes the strike with the current parameters (linear model). */ async runLinear(): Promise { if (!this.plate) return; if (this.synthBusy) { this.synthPending = true; return; } try { await this.ensureModes(); } catch { return; } const pl = this.plate, model = this.model; if (!pl || !model) return; this.synthBusy = true; const p = this.form.get(); const gen = this.designGeneration; const t0 = performance.now(); this.runStatus.textContent = "Synthesizing..."; try { let audio: Float32Array, frames: Float32Array, nFrames: number, contact: ContactResult; if (this.pool) { const r = await this.pool.synthesize(model, pl, pl.loss, p, (a, c) => { if (gen !== this.designGeneration) return; this.installRecording(a, [], p, "linear"); this.lastContact = c; this.runStatus.textContent = `Audio ready in ${((performance.now() - t0) / 1000).toFixed(2)} s; movie frames...`; }); audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact; } else { const { synthesize } = await import("./modal/synth.ts"); const r = synthesize(model, pl, pl.loss, p, true); audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact; } if (gen !== this.designGeneration) return; const n = pl.grid.n; const frameList: Float32Array[] = []; for (let f = 0; f < nFrames; f++) frameList.push(frames.subarray(f * n, (f + 1) * n)); this.installRecording(audio, frameList, p, "linear"); this.lastContact = contact; this.synthesized = true; const secs = (performance.now() - t0) / 1000; 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.`; this.updateNotes(); } catch (e) { this.runStatus.textContent = `Synthesis failed: ${e instanceof Error ? e.message : String(e)}`; } finally { this.synthBusy = false; this.tutorial?.refresh(); if (this.synthPending) { this.synthPending = false; void this.runLinear(); } } } // ---------- Full simulation ---------- async runFull(): Promise { const pl = this.plate; if (!pl || !this.device || this.running) return; const p = this.form.get(); this.sim?.destroy(); const hm = meanThickness(pl); const fit = fitSigma(pl.loss, pl.mat, hm); const loss: FullLoss = fullLoss(fit, standRates(pl), decayRate(pl.loss, 5000)); this.sim = new Simulation(this.device, pl, p, loss); this.rec = { audio: new Float32Array(0), samples: 0, fs: p.fs, frames: [new Float32Array(pl.grid.n)], fps: p.fps, source: "full" }; this.spec.reset(); this.spec.setOptions({ fs: p.fs }); this.spec.setDuration(p.duration); this.running = true; this.paused = false; this.setLocked(true); this.setView("live"); this.updateButtons(); await this.fullLoop(); } private async fullLoop(): Promise { const sim = this.sim; if (!sim) return; const t0 = performance.now(); let lastUi = 0; while (this.running && sim.phase === "recording") { if (this.paused) { await new Promise((r) => setTimeout(r, 50)); continue; } await sim.runBatch(); if (!this.rec || this.sim !== sim) break; // New audio and frames. const rec = this.rec; const newSamples = sim.samples - rec.samples; if (newSamples > 0) { const chunk = sim.audio.subarray(rec.samples, sim.samples); this.spec.append(chunk); rec.audio = sim.audio; rec.samples = sim.samples; } rec.frames = sim.frames; if (this.view === "live") this.renderer.setField(sim.frames[sim.frames.length - 1]); const now = performance.now(); if (now - lastUi > 100) { lastUi = now; const st = sim.status(); const elapsed = (now - t0) / 1000; 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" : ""}`; this.scrub.max = String(Math.max(0, rec.frames.length - 1)); this.updatePeak(); } } if (this.sim === sim) { this.running = false; this.setLocked(false); const st = sim.status(); 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.`; if (this.rec) { this.rec.audio = sim.recordedAudio(); this.rec.samples = sim.samples; this.rec.frames = sim.frames; } this.updateNotes(); this.updateButtons(); this.updatePeak(); this.tutorial?.refresh(); } } // ---------- Run control ---------- private setRunMode(mode: RunMode): void { if (mode === "full" && !this.device) mode = "linear"; this.runMode = mode; this.runModeSel.value = mode; if (mode === "linear" && this.plate && !this.modes) this.useCachedModes(); if (mode === "full") this.modeBrowser.clear("Switch to the linear model to compute the modes."); this.updateButtons(); this.tutorial?.refresh(); } private start(): void { if (this.runMode === "linear") void this.runLinear(); else void this.runFull(); } private pause(): void { if (!this.running) return; this.paused = !this.paused; this.pauseBtn.textContent = this.paused ? "Resume" : "Pause"; } private stopRun(): void { if (this.sim && this.running) { this.sim.stop(); this.running = false; } this.paused = false; this.pauseBtn.textContent = "Pause"; this.setLocked(false); this.updateButtons(); } private continueRun(): void { const sim = this.sim; if (!sim || this.running || !sim.canContinue()) return; sim.setDuration(this.form.get().duration); this.running = true; this.setLocked(true); this.setView("live"); this.updateButtons(); void this.fullLoop(); } private setLocked(locked: boolean): void { this.editor.setLocked(locked); this.form.setLocked(locked); this.runModeSel.disabled = locked; } private updateButtons(): void { const linear = this.runMode === "linear"; const canLinear = linear && !!this.plate && !this.synthBusy; const canFull = !linear && !!this.plate && !!this.device && !this.running; this.startBtn.disabled = !(canLinear || canFull); this.startBtn.textContent = linear ? "Synthesize" : "Start"; this.pauseBtn.disabled = linear || !this.running; this.stopBtn.disabled = linear || !this.running; this.continueBtn.disabled = linear || this.running || !this.sim || !this.sim.canContinue() || this.sim.samples >= Math.round(this.form.get().duration * this.form.get().fs); this.pauseBtn.hidden = linear; this.stopBtn.hidden = linear; this.continueBtn.hidden = linear; this.liveBtn.hidden = linear; } // ---------- Recording, view, playback ---------- private installRecording(audio: Float32Array, frames: Float32Array[], p: SimParams, source: RunMode): void { this.rec = { audio, samples: audio.length, fs: p.fs, frames, fps: p.fps, source }; this.spec.setOptions({ fs: p.fs }); this.spec.setDuration(p.duration); this.spec.setSignal(audio); this.scrub.max = String(Math.max(0, frames.length - 1)); if (frames.length > 0) { this.frameIndex = 0; if (this.view !== "mode") { this.setView("movie"); this.showFrame(); } } this.updatePeak(); this.updateButtons(); } private setView(v: ViewState): void { this.view = v; if (v === "design") { this.renderer.setField(null); this.overlay.textContent = this.plate ? "Stress-free shape" : ""; } else if (v === "live") { this.overlay.textContent = "Live"; if (this.sim) this.renderer.setField(this.sim.frames[this.sim.frames.length - 1]); } else if (v === "movie") this.showFrame(); } private showFrame(): void { const rec = this.rec; if (!rec || rec.frames.length === 0) return; const f = Math.max(0, Math.min(rec.frames.length - 1, this.frameIndex)); this.frameIndex = f; this.scrub.value = String(f); this.timeLabel.textContent = `${(f / rec.fps).toFixed(3)} s`; this.renderer.setField(rec.frames[f]); this.overlay.textContent = `${rec.source === "linear" ? "Linear" : "Full"} model, t = ${(f / rec.fps).toFixed(3)} s`; } private playable(): Float32Array | null { const rec = this.rec; if (!rec || rec.samples === 0) return null; const sig = rec.audio.subarray(0, rec.samples); return applyLevel(sig, levelGain(sig, rec.fs, this.levelMode, this.gainDb)); } private updatePeak(): void { // Anything that changes the signal to be played (a new recording, the level mode, the gain) comes through here. this.hideLoudWarning(); this.loudAccepted = false; const s = this.playable(); if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; } const db = peakDbfs(s); this.peakLabel.textContent = `Peak ${db.toFixed(1)} dBFS${db > -0.5 ? " (clipping)" : ""}`; this.peakLabel.className = db > -0.5 ? "badge warn" : "badge"; this.downloadBtn.disabled = false; this.playBtn.disabled = false; } private togglePlay(): void { if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; } const s = this.playable(), rec = this.rec; if (!s || !rec) return; // A loud signal can hurt in headphones: warn before playing it, once per signal; any // change of the recording, the level mode or the gain asks again. const l = loudness(s, rec.fs); if (l.loud && !this.loudAccepted) { this.showLoudWarning(l.peakDb, l.shortTermDb, () => { this.loudAccepted = true; this.startPlayback(s, rec); }); return; } this.hideLoudWarning(); this.startPlayback(s, rec); } private startPlayback(s: Float32Array, rec: Recording): void { // Play from the scrubbed position only when the user has scrubbed to one; from the live // view, or from the end of the recording, start over. const atEnd = rec.frames.length > 1 && this.frameIndex >= rec.frames.length - 1; const start = this.view === "movie" && rec.frames.length > 1 && !atEnd ? this.frameIndex / rec.fps : 0; this.playBtn.textContent = "Stop"; const prevView = this.view; this.player.play(s, rec.fs, start, () => { this.playBtn.textContent = "Play"; this.spec.setCursor(null); if (prevView === "live") this.setView("live"); }); const tick = () => { if (!this.player.playing) return; const t = this.player.position(); this.spec.setCursor(t); if (rec.frames.length > 1) { this.frameIndex = Math.round(t * rec.fps); this.view = "movie"; this.showFrame(); } requestAnimationFrame(tick); }; requestAnimationFrame(tick); } private download(): void { const s = this.playable(), rec = this.rec; if (!s || !rec) return; const blob = encodeWav(s, rec.fs); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${(this.plate?.name ?? "cymbalsim").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${rec.source}.wav`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } private updateTicks(): void { this.spec.setTicks(this.showTicks && this.modes ? Array.from(this.modes.freqs) : null); } // ---------- Tutorial ---------- /** The state the tutorial's pages read. */ private tutorialContext(): TutorialContext { const rec = this.rec; const st = this.sim ? this.sim.status() : null; return { plate: this.plate, design: this.design, params: this.form.get(), spacing: this.plateSpacing, sys: this.sys, modes: this.modes, model: this.model, lastContact: this.lastContact, recording: rec && rec.samples > 0 ? { source: rec.source, seconds: rec.samples / rec.fs } : null, full: st && st.totalSteps > 0 ? { peakForce: st.stick.maxForce, wmax: st.wmax, t: st.t, done: st.phase === "done" } : null, gpu: this.device !== null, runMode: this.runMode, spectrogram: { ...this.specOpts }, memo: this.tutorialMemo, }; } /** The actions the tutorial's pages can take. */ private tutorialHost(): TutorialHost { return { ctx: () => this.tutorialContext(), loadPreset: async (id) => { this.editor.selectPreset(id); }, loadScript: async (src) => { this.editor.setSource(src); this.onSource(src); }, setParam: (key, value) => { if (key === "duration") { this.durationInput.value = String(value); this.durationInput.dispatchEvent(new Event("change")); return; } this.form.set(key, value); this.onParam(key, false); }, computeModes: () => this.ensureModes(), showMode: (m) => this.modeBrowser.select(m), synthesize: async () => { if (this.runMode !== "linear") this.setRunMode("linear"); await this.runLinear(); }, play: () => { if (!this.player.playing) this.togglePlay(); }, setRunMode: (mode) => this.setRunMode(mode), runFull: () => this.runFull(), setSpectrogram: (o) => { 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 }); } if (o.logFreq !== undefined) { this.specOpts.logFreq = o.logFreq; this.specLogChk.checked = o.logFreq; this.spec.setOptions({ logFreq: o.logFreq }); } this.tutorial?.refresh(); }, setViewOptions: (o) => { if (o.color !== undefined) { this.viewOpts.color = o.color; this.colorSel.value = o.color; } if (o.exaggeration !== undefined) this.viewOpts.exaggeration = o.exaggeration; this.renderer.setOptions(this.viewOpts); }, }; } // ---------- Tests ---------- async selfTest(): Promise { if (!this.device) return "no WebGPU"; const lines: string[] = []; for (const id of ["flat-rectangle", "bowed-rectangle", "crash-16", "crash-bell-16", "ellipse"]) { const pr = presetById(id)!; const d = compileDesign(pr.source); const pl = evaluatePlate(d, 0.012); const r = await runSelfTest(this.device, pl, this.form.get()); lines.push(`${id}: max relative error ${r.maxRel.toExponential(2)} (${r.n} nodes)`); } return lines.join("\n"); } private testStatus(): unknown { return { design: this.design?.name ?? null, modes: this.modes ? { count: this.modes.count, missing: this.modes.missing, first: Array.from(this.modes.freqs.slice(0, 5)) } : null, synthesized: this.synthesized, running: this.running, samples: this.rec?.samples ?? 0, frames: this.rec?.frames.length ?? 0, source: this.rec?.source ?? null, peakPressure: this.rec ? Array.from(this.rec.audio.subarray(0, this.rec.samples)).reduce((m, v) => Math.max(m, Math.abs(v)), 0) : 0, sim: this.sim ? this.sim.status() : null, contact: this.lastContact ? { peakForce: this.lastContact.peakForce, contactTime: this.lastContact.contactTime } : null, editorStatus: ($("design").querySelector(".editor-status") as HTMLElement | null)?.textContent ?? "", }; } } const app = new App(); app.init().catch((e) => { console.error(e); $("viewOverlay").textContent = `Failed to start: ${e instanceof Error ? e.message : String(e)}`; }); export { app, PRESETS, isFlat, formatNumber };