/ proof-of-concept / cymmodes
Sign in
proof-of-concept / cymmodes
cymmodes: linear modes of a cymsim cymbal driven in the full nonlinear model, with the energy of every mode over time
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 83544826aa7a Browse files
48 changed files+6836−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules
2+dist
README.mdadded+51−0View file
@@ -0,0 +1,51 @@
1+# cymmodes
2+
3+A browser companion of [cymsim](../cymsim) for studying how the linear modes of a cymbal couple. The motivating question is the upward cascade: a struck cymbal receives most of its energy in a few low modes, and the geometric nonlinearity of the shell carries that energy into hundreds of higher modes, which is the crash. Time-domain simulation shows the cascade in the radiated sound; what it does not show directly is which mode fed which. Here we compute the linear modes of the shell, drive a chosen one in the full nonlinear model, and watch the energy of every mode over time.
4+
5+The pieces are taken from the two sibling apps and joined by a projection layer. The linear modes come from the eigensolve pipeline of [cymplay](../cymplay): the tangent stiffness of the Marguerre-von Karman shell, statically condensed onto the transverse DOFs, mass-normalised eigenmodes by Householder plus implicit QL, all in TypeScript in a Web Worker, with the same modes file format (a file saved here loads in cymplay and vice versa). The nonlinear run is the time loop of [cymsimweb](../cymsimweb): the same five-DOF discretization as the Python package, explicit leapfrog on the GPU through WebGPU (two compute dispatches per sub-step), with a float64 CPU fallback. New in this app are the drive (a modal initial condition or a distributed harmonic force), a projection pass that contracts the running state with the mass-weighted mode shapes, and the views that display the result per mode.
6+
7+## Running
8+
9+ npm install
10+ npm run dev # development server
11+ npm run build # static site in dist/
12+ npm test # CPU physics tests, then the GPU kernels under Dawn
13+
14+Requires Node 20 or later and, for the GPU time loop, a browser with WebGPU (Chrome or Edge 113+). Without WebGPU the run falls back to the CPU loop, which is fine at 15 mm and slow at 10 mm.
15+
16+## Using the app
17+
18+**1. The cymbal.** Start from a preset (the cymsim examples, plus a symmetric cymbal with the hammering removed, on which the mode pairs stay nearly degenerate and the internal resonances are easiest to read) or load a cymsim YAML. The design panel is cymplay's: material and damping are *live* (they rescale the frequencies analytically), the geometry and the imperfections are *structural* and mark the modes stale.
19+
20+**2. The modes.** Compute the eigenmodes at the chosen mesh size, once per shape, in the browser; they are cached in IndexedDB and can be saved and reloaded. The eigensolve is dense and costs about the cube of the node count: 15 mm takes seconds, 10 mm (the default, 1736 nodes and about 1500 modes below 20 kHz) about twenty seconds, 6 mm about a quarter of an hour. The mode table lists frequency, T60 and a heuristic (m, n) label (nodal diameters and circles, read off the discrete shape; on a hammered cymbal this is a description, not a mode number). Clicking a mode shows its shape in the 3D view, animated slowly at the amplitude set below.
21+
22+**3. Drive a mode.** Two mechanisms:
23+
24+- *Free decay.* The shell is released from the mode shape at a chosen amplitude, given as the peak displacement at the antinode in units of the local thickness, and rings down. The in-plane displacements and rotations of the initial state come from the static condensation (one banded solve per mode, cached), so the released state is the complete linearised mode rather than a pure-w field that would also launch fast membrane and rotation transients. Below about 0.1 thicknesses the decay is linear; around one thickness energy starts leaking into specific partners; at several thicknesses the cascade is broadband.
25+- *Harmonic forcing.* A force distributed as the mode shape and weighted by the nodal mass, so it is orthogonal to every other mode: in linear theory only the driven mode responds, and whatever else appears in the nonlinear run got there through the coupling. The force ramps up over a set time, holds at the drive frequency (detunable in cents, since the nonlinear resonance shifts with amplitude), and stops after the set drive time so the tail is a free decay. The amplitude is specified as the linear steady-state response at physical damping; scaling the damping down makes the true response larger, which the log states.
26+
27+The damping scale (0 to 1) applies to both loss terms of the run only; 0 gives a conservative shell, on which energy transfer is not obscured by decay. The *linear* checkbox runs the linearised model instead, as a control: any energy appearing in other modes there is discretization leakage (the simulator carries in-plane and rotational inertia that the condensed eigenproblem drops), typically well below the nonlinear transfer.
28+
29+**The views.** During the run the plate shows the live displacement. The analysis panel offers three views on one frequency axis. *Modal energy* is time against mode frequency, one horizontal trace per mode, colour the modal energy in dB below the loudest value seen: energy arriving in a mode lights its trace up, resolved per mode rather than smeared by an STFT window. The two *spectrogram* views are true STFTs, of the listener signal and of the driven mode's own coordinate (captured at the full audio rate on the GPU), with the linear mode frequencies overlaid as ticks. The distinction matters: the modal view says which shapes hold the energy, pinned at their linear frequencies, while the spectrograms say at which frequencies the motion actually oscillates. Combination tones between the modes, the amplitude-dependent shift of the driven peak off its dashed linear frequency, and the broadband floor of the turbulent regime appear only in the spectrograms; which mode carries them appears only in the modal view. Hovering a spectrogram names the nearest linear mode and the offset in hertz. The bar view shows every mode at the cursor (or the frontier of the run) and names the strongest ones; clicking a trace or a bar selects that mode, so one can walk the cascade asking of each excited mode what its frequency is and whether it is a sum or difference of others. The energy readout compares the modal sum with the shell's total energy; at strong amplitudes the modal sum falls well short of the total, because the in-plane motion and the quadratic part of the membrane energy are not in the linear basis. That gap is itself a rough meter of how nonlinear the state is.
30+
31+**4. The sound.** The run also evaluates the Rayleigh-integral listener of cymsim (front and diffracted-back terms), playable and exportable as WAV.
32+
33+## The projection
34+
35+With mass-normalised transverse shapes phi_m and the simulator's transverse lumped mass M, the modal coordinate is q_m = phi_m^T M w and the modal energy E_m = (qdot_m^2 + omega_m^2 q_m^2) / 2. The state is projected on the GPU (one workgroup per mode, at a selectable snapshot rate, 240 per second by default), so tracking 1500 modes adds a negligible cost to the run. Note that the basis is the *condensed* eigenproblem while the simulator carries all five DOFs per node, with an inflated rotational inertia as a time-step device; the frequencies therefore differ by a few percent at the top of the band, and a linear run leaks a little energy between close modes. The tests quantify this: on the coarse test cymbal a linear release keeps more than 85 % of the energy in its mode over the run, while a strong nonlinear release moves out more than five times as much.
36+
37+## Layout
38+
39+| path | contents |
40+|---|---|
41+| `src/config/`, `src/model/` | configuration and shell model, shared with cymsimweb (schema, mesher, DKT element, shell assembly, exciters, radiation, set-up) |
42+| `src/precompute/` | eigensolve pipeline shared with cymplay, plus the condensation-complement operator and the extended worker |
43+| `src/modes.ts`, `src/cache.ts`, `src/derived.ts`, `src/design.ts` | modes file format, IndexedDB cache, live material relations, design panel (shared with cymplay) |
44+| `src/cpu/`, `src/gpu/` | the time loops of cymsimweb, extended with the initial state, the distributed drive and the projection pass |
45+| `src/drive.ts`, `src/modal/` | modal initial conditions and forces, the energy tracker, the (m, n) classifier |
46+| `src/ui/`, `src/main.ts` | plate view (cymsimweb), mode table, modal spectrogram and bar views, wiring |
47+| `tests/` | node:test physics checks (complement, projection, linear purity and decay, nonlinear transfer, forcing) and the Dawn GPU check |
48+
49+## Tests
50+
51+`npm run test:cpu` checks, on a small cymbal: that the condensation complement makes a mode shape an equilibrium of the in-plane and rotation DOFs with the transverse force equal to -omega^2 M w; that the projection of the initial state is the driven mode alone; that a small linear release stays in its mode and decays at exp(-2 sigma0 t); that a strong nonlinear release moves energy into other modes while the linear control does not; and that resonant forcing excites only the driven mode, with the audio-rate trace agreeing with the snapshot projection where they coincide. `npm run test:gpu` runs the same WGSL kernels under Node with Google Dawn and compares the modal snapshots, the trace and the energies with the CPU loop.
index.htmladded+147−0View file
@@ -0,0 +1,147 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>cymmodes</title>
7+ <link rel="icon" href="data:," />
8+ <link rel="stylesheet" href="./src/style.css" />
9+ </head>
10+ <body>
11+ <div id="app">
12+ <aside id="sidebar">
13+ <header>
14+ <h1>cymmodes</h1>
15+ <span class="subtitle">linear modes of a cymsim cymbal, and how they couple</span>
16+ </header>
17+ <div id="sidebar-scroll">
18+ <details id="box-cymbal" open>
19+ <summary>1. The cymbal</summary>
20+ <div class="row">
21+ <label>start from <select id="preset"></select></label>
22+ <label class="file">load YAML <input type="file" id="yamlfile" accept=".yaml,.yml" hidden /><button id="import-yaml">…</button></label>
23+ <button id="download-yaml">YAML</button>
24+ </div>
25+ <div id="design"></div>
26+ <div id="design-status" class="status"></div>
27+ </details>
28+
29+ <details id="box-modes" open>
30+ <summary>2. The modes</summary>
31+ <div class="row">
32+ <label>mesh <select id="esize">
33+ <option value="0.015">15 mm (fast, rough)</option>
34+ <option value="0.012">12 mm</option>
35+ <option value="0.010" selected>10 mm (default)</option>
36+ <option value="0.008">8 mm (slow)</option>
37+ <option value="0.006">6 mm (very slow)</option>
38+ </select></label>
39+ <label>up to <input type="number" id="fmax" value="20000" step="1000" min="500" max="24000" /> Hz</label>
40+ </div>
41+ <div class="row buttons">
42+ <button id="compute" class="primary">Compute modes</button>
43+ <button id="download-modes" disabled>Save</button>
44+ <label class="file"><input type="file" id="modesfile" accept=".bin" hidden /><button id="import-modes">Load</button></label>
45+ <button id="clear-cache" title="forget every cached modes file">Clear cache</button>
46+ </div>
47+ <div class="progress"><div id="modes-progress"></div></div>
48+ <div id="modes-status" class="status">no modes yet</div>
49+ </details>
50+
51+ <details id="box-drive" open>
52+ <summary>3. Drive a mode</summary>
53+ <div id="drive-mode" class="status">no mode selected; click one in the table or the views</div>
54+ <div class="row">
55+ <label>mechanism <select id="mechanism">
56+ <option value="decay">free decay from the mode shape</option>
57+ <option value="forced">harmonic forcing of the mode</option>
58+ </select></label>
59+ </div>
60+ <div class="row">
61+ <label>amplitude <input type="number" id="amplitude" value="1" step="0.1" min="0.001" max="50" /> &times; thickness</label>
62+ <label>damping &times; <input type="number" id="damp-scale" value="1" step="0.05" min="0" max="1" /></label>
63+ </div>
64+ <div class="row forced-only">
65+ <label>detune <input type="number" id="detune" value="0" step="1" min="-400" max="400" /> cents</label>
66+ <label>ramp <input type="number" id="ramp" value="0.2" step="0.05" min="0" /> s</label>
67+ <label>drive for <input type="number" id="drive-time" value="1.5" step="0.1" min="0.05" /> s</label>
68+ </div>
69+ <div class="row">
70+ <label>duration <input type="number" id="duration" value="2" step="0.5" min="0.1" max="20" /> s</label>
71+ <label>snapshots <input type="number" id="snap-rate" value="240" step="20" min="20" max="2000" /> /s</label>
72+ <label title="run the linearised model instead: a control in which no coupling can occur"><input type="checkbox" id="linear" /> linear</label>
73+ </div>
74+ <div class="row buttons">
75+ <button id="run" class="primary" disabled>Run</button>
76+ <button id="pause" disabled>Pause</button>
77+ <button id="stop" disabled>Stop</button>
78+ </div>
79+ <div class="row"><progress id="run-progress" value="0" max="1"></progress><span id="run-progress-text"></span></div>
80+ <div id="drive-status" class="status"></div>
81+ <div id="backend" class="muted"></div>
82+ </details>
83+
84+ <details id="box-sound">
85+ <summary>4. The sound</summary>
86+ <div class="row">
87+ <button id="play" disabled>Play</button>
88+ <button id="stop-audio" disabled>Stop</button>
89+ <input id="position" type="range" min="0" max="1" step="0.001" value="0" />
90+ <span id="time">0.000 s</span>
91+ </div>
92+ <div class="row">
93+ <label>signal <select id="signal"><option value="unbaffled">unbaffled</option><option value="rayleigh">Rayleigh (baffled)</option></select></label>
94+ <label>gain <input id="gain" type="range" min="-40" max="0" step="1" value="-12" /><span id="gain-text">-12 dB</span></label>
95+ <label><input id="normalize" type="checkbox" checked /> normalize</label>
96+ <button id="download-wav" disabled>WAV</button>
97+ </div>
98+ </details>
99+
100+ <div id="log" class="log"></div>
101+ </div>
102+ </aside>
103+ <div class="splitter vertical" data-target="sidebar" data-var="--sidebar-w"></div>
104+ <main>
105+ <section id="top-section">
106+ <section id="plate-section">
107+ <div class="toolbar">
108+ <span id="plate-title">Mode shape</span>
109+ <label><input type="checkbox" id="animate" checked /> animate</label>
110+ <label>exaggeration <input id="exag" type="range" min="0" max="3.5" step="0.1" value="1.5" /><span id="exag-text"></span></label>
111+ <button id="reset-view">Reset view</button>
112+ <span id="plate-info" class="muted"></span>
113+ </div>
114+ <canvas id="plate"></canvas>
115+ </section>
116+ <div class="splitter vertical" data-target="modelist-section" data-var="--modelist-w" data-invert="1"></div>
117+ <section id="modelist-section">
118+ <div class="toolbar"><span>Modes</span><span id="modelist-info" class="muted"></span></div>
119+ <div id="modelist"></div>
120+ </section>
121+ </section>
122+ <div class="splitter horizontal" data-target="top-section" data-var="--top-h"></div>
123+ <section id="modal-section">
124+ <div class="toolbar">
125+ <span>Analysis</span>
126+ <label>view <select id="view">
127+ <option value="modal" selected>modal energy</option>
128+ <option value="sound">spectrogram: sound</option>
129+ <option value="trace">spectrogram: driven mode</option>
130+ </select></label>
131+ <label>scale <select id="freq-scale"><option value="linear">linear</option><option value="log" selected>log</option></select></label>
132+ <label>max f <select id="freq-max"><option value="20000" selected>20 kHz</option><option value="10000">10 kHz</option><option value="5000">5 kHz</option><option value="2000">2 kHz</option></select></label>
133+ <label>range <select id="db-range"><option value="60">60 dB</option><option value="80" selected>80 dB</option><option value="100">100 dB</option><option value="120">120 dB</option></select></label>
134+ <span id="modal-info" class="muted"></span>
135+ </div>
136+ <canvas id="modal-spec"></canvas>
137+ </section>
138+ <div class="splitter horizontal" data-target="bars-section" data-var="--bars-h" data-invert="1"></div>
139+ <section id="bars-section">
140+ <div class="toolbar"><span>At the cursor</span><button id="follow" disabled>Follow the run</button><span id="bars-info" class="muted"></span></div>
141+ <canvas id="bars"></canvas>
142+ </section>
143+ </main>
144+ </div>
145+ <script type="module" src="./src/main.ts"></script>
146+ </body>
147+</html>
package-lock.jsonadded+1282−0View file
@@ -0,0 +1,1282 @@
1+{
2+ "name": "cymmodes",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "cymmodes",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "js-yaml": "^4.1.0"
12+ },
13+ "devDependencies": {
14+ "@types/js-yaml": "^4.0.9",
15+ "@types/node": "^24.0.0",
16+ "@webgpu/types": "^0.1.60",
17+ "typescript": "^5.6.0",
18+ "vite": "^6.0.0",
19+ "webgpu": "^0.4.0"
20+ }
21+ },
22+ "node_modules/@esbuild/aix-ppc64": {
23+ "version": "0.25.12",
24+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
25+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
26+ "cpu": [
27+ "ppc64"
28+ ],
29+ "dev": true,
30+ "license": "MIT",
31+ "optional": true,
32+ "os": [
33+ "aix"
34+ ],
35+ "engines": {
36+ "node": ">=18"
37+ }
38+ },
39+ "node_modules/@esbuild/android-arm": {
40+ "version": "0.25.12",
41+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
42+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
43+ "cpu": [
44+ "arm"
45+ ],
46+ "dev": true,
47+ "license": "MIT",
48+ "optional": true,
49+ "os": [
50+ "android"
51+ ],
52+ "engines": {
53+ "node": ">=18"
54+ }
55+ },
56+ "node_modules/@esbuild/android-arm64": {
57+ "version": "0.25.12",
58+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
59+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
60+ "cpu": [
61+ "arm64"
62+ ],
63+ "dev": true,
64+ "license": "MIT",
65+ "optional": true,
66+ "os": [
67+ "android"
68+ ],
69+ "engines": {
70+ "node": ">=18"
71+ }
72+ },
73+ "node_modules/@esbuild/android-x64": {
74+ "version": "0.25.12",
75+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
76+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
77+ "cpu": [
78+ "x64"
79+ ],
80+ "dev": true,
81+ "license": "MIT",
82+ "optional": true,
83+ "os": [
84+ "android"
85+ ],
86+ "engines": {
87+ "node": ">=18"
88+ }
89+ },
90+ "node_modules/@esbuild/darwin-arm64": {
91+ "version": "0.25.12",
92+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
93+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
94+ "cpu": [
95+ "arm64"
96+ ],
97+ "dev": true,
98+ "license": "MIT",
99+ "optional": true,
100+ "os": [
101+ "darwin"
102+ ],
103+ "engines": {
104+ "node": ">=18"
105+ }
106+ },
107+ "node_modules/@esbuild/darwin-x64": {
108+ "version": "0.25.12",
109+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
110+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
111+ "cpu": [
112+ "x64"
113+ ],
114+ "dev": true,
115+ "license": "MIT",
116+ "optional": true,
117+ "os": [
118+ "darwin"
119+ ],
120+ "engines": {
121+ "node": ">=18"
122+ }
123+ },
124+ "node_modules/@esbuild/freebsd-arm64": {
125+ "version": "0.25.12",
126+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
127+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
128+ "cpu": [
129+ "arm64"
130+ ],
131+ "dev": true,
132+ "license": "MIT",
133+ "optional": true,
134+ "os": [
135+ "freebsd"
136+ ],
137+ "engines": {
138+ "node": ">=18"
139+ }
140+ },
141+ "node_modules/@esbuild/freebsd-x64": {
142+ "version": "0.25.12",
143+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
144+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
145+ "cpu": [
146+ "x64"
147+ ],
148+ "dev": true,
149+ "license": "MIT",
150+ "optional": true,
151+ "os": [
152+ "freebsd"
153+ ],
154+ "engines": {
155+ "node": ">=18"
156+ }
157+ },
158+ "node_modules/@esbuild/linux-arm": {
159+ "version": "0.25.12",
160+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
161+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
162+ "cpu": [
163+ "arm"
164+ ],
165+ "dev": true,
166+ "license": "MIT",
167+ "optional": true,
168+ "os": [
169+ "linux"
170+ ],
171+ "engines": {
172+ "node": ">=18"
173+ }
174+ },
175+ "node_modules/@esbuild/linux-arm64": {
176+ "version": "0.25.12",
177+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
178+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
179+ "cpu": [
180+ "arm64"
181+ ],
182+ "dev": true,
183+ "license": "MIT",
184+ "optional": true,
185+ "os": [
186+ "linux"
187+ ],
188+ "engines": {
189+ "node": ">=18"
190+ }
191+ },
192+ "node_modules/@esbuild/linux-ia32": {
193+ "version": "0.25.12",
194+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
195+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
196+ "cpu": [
197+ "ia32"
198+ ],
199+ "dev": true,
200+ "license": "MIT",
201+ "optional": true,
202+ "os": [
203+ "linux"
204+ ],
205+ "engines": {
206+ "node": ">=18"
207+ }
208+ },
209+ "node_modules/@esbuild/linux-loong64": {
210+ "version": "0.25.12",
211+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
212+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
213+ "cpu": [
214+ "loong64"
215+ ],
216+ "dev": true,
217+ "license": "MIT",
218+ "optional": true,
219+ "os": [
220+ "linux"
221+ ],
222+ "engines": {
223+ "node": ">=18"
224+ }
225+ },
226+ "node_modules/@esbuild/linux-mips64el": {
227+ "version": "0.25.12",
228+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
229+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
230+ "cpu": [
231+ "mips64el"
232+ ],
233+ "dev": true,
234+ "license": "MIT",
235+ "optional": true,
236+ "os": [
237+ "linux"
238+ ],
239+ "engines": {
240+ "node": ">=18"
241+ }
242+ },
243+ "node_modules/@esbuild/linux-ppc64": {
244+ "version": "0.25.12",
245+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
246+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
247+ "cpu": [
248+ "ppc64"
249+ ],
250+ "dev": true,
251+ "license": "MIT",
252+ "optional": true,
253+ "os": [
254+ "linux"
255+ ],
256+ "engines": {
257+ "node": ">=18"
258+ }
259+ },
260+ "node_modules/@esbuild/linux-riscv64": {
261+ "version": "0.25.12",
262+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
263+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
264+ "cpu": [
265+ "riscv64"
266+ ],
267+ "dev": true,
268+ "license": "MIT",
269+ "optional": true,
270+ "os": [
271+ "linux"
272+ ],
273+ "engines": {
274+ "node": ">=18"
275+ }
276+ },
277+ "node_modules/@esbuild/linux-s390x": {
278+ "version": "0.25.12",
279+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
280+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
281+ "cpu": [
282+ "s390x"
283+ ],
284+ "dev": true,
285+ "license": "MIT",
286+ "optional": true,
287+ "os": [
288+ "linux"
289+ ],
290+ "engines": {
291+ "node": ">=18"
292+ }
293+ },
294+ "node_modules/@esbuild/linux-x64": {
295+ "version": "0.25.12",
296+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
297+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
298+ "cpu": [
299+ "x64"
300+ ],
301+ "dev": true,
302+ "license": "MIT",
303+ "optional": true,
304+ "os": [
305+ "linux"
306+ ],
307+ "engines": {
308+ "node": ">=18"
309+ }
310+ },
311+ "node_modules/@esbuild/netbsd-arm64": {
312+ "version": "0.25.12",
313+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
314+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
315+ "cpu": [
316+ "arm64"
317+ ],
318+ "dev": true,
319+ "license": "MIT",
320+ "optional": true,
321+ "os": [
322+ "netbsd"
323+ ],
324+ "engines": {
325+ "node": ">=18"
326+ }
327+ },
328+ "node_modules/@esbuild/netbsd-x64": {
329+ "version": "0.25.12",
330+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
331+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
332+ "cpu": [
333+ "x64"
334+ ],
335+ "dev": true,
336+ "license": "MIT",
337+ "optional": true,
338+ "os": [
339+ "netbsd"
340+ ],
341+ "engines": {
342+ "node": ">=18"
343+ }
344+ },
345+ "node_modules/@esbuild/openbsd-arm64": {
346+ "version": "0.25.12",
347+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
348+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
349+ "cpu": [
350+ "arm64"
351+ ],
352+ "dev": true,
353+ "license": "MIT",
354+ "optional": true,
355+ "os": [
356+ "openbsd"
357+ ],
358+ "engines": {
359+ "node": ">=18"
360+ }
361+ },
362+ "node_modules/@esbuild/openbsd-x64": {
363+ "version": "0.25.12",
364+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
365+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
366+ "cpu": [
367+ "x64"
368+ ],
369+ "dev": true,
370+ "license": "MIT",
371+ "optional": true,
372+ "os": [
373+ "openbsd"
374+ ],
375+ "engines": {
376+ "node": ">=18"
377+ }
378+ },
379+ "node_modules/@esbuild/openharmony-arm64": {
380+ "version": "0.25.12",
381+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
382+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
383+ "cpu": [
384+ "arm64"
385+ ],
386+ "dev": true,
387+ "license": "MIT",
388+ "optional": true,
389+ "os": [
390+ "openharmony"
391+ ],
392+ "engines": {
393+ "node": ">=18"
394+ }
395+ },
396+ "node_modules/@esbuild/sunos-x64": {
397+ "version": "0.25.12",
398+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
399+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
400+ "cpu": [
401+ "x64"
402+ ],
403+ "dev": true,
404+ "license": "MIT",
405+ "optional": true,
406+ "os": [
407+ "sunos"
408+ ],
409+ "engines": {
410+ "node": ">=18"
411+ }
412+ },
413+ "node_modules/@esbuild/win32-arm64": {
414+ "version": "0.25.12",
415+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
416+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
417+ "cpu": [
418+ "arm64"
419+ ],
420+ "dev": true,
421+ "license": "MIT",
422+ "optional": true,
423+ "os": [
424+ "win32"
425+ ],
426+ "engines": {
427+ "node": ">=18"
428+ }
429+ },
430+ "node_modules/@esbuild/win32-ia32": {
431+ "version": "0.25.12",
432+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
433+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
434+ "cpu": [
435+ "ia32"
436+ ],
437+ "dev": true,
438+ "license": "MIT",
439+ "optional": true,
440+ "os": [
441+ "win32"
442+ ],
443+ "engines": {
444+ "node": ">=18"
445+ }
446+ },
447+ "node_modules/@esbuild/win32-x64": {
448+ "version": "0.25.12",
449+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
450+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
451+ "cpu": [
452+ "x64"
453+ ],
454+ "dev": true,
455+ "license": "MIT",
456+ "optional": true,
457+ "os": [
458+ "win32"
459+ ],
460+ "engines": {
461+ "node": ">=18"
462+ }
463+ },
464+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
465+ "version": "1.5.1",
466+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
467+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
468+ "cpu": [
469+ "x64"
470+ ],
471+ "dev": true,
472+ "libc": [
473+ "glibc"
474+ ],
475+ "license": "MIT",
476+ "optional": true,
477+ "os": [
478+ "linux"
479+ ],
480+ "engines": {
481+ "node": "^22.20 || ^24.12 || >=25"
482+ }
483+ },
484+ "node_modules/@rollup/rollup-android-arm-eabi": {
485+ "version": "4.63.4",
486+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
487+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
488+ "cpu": [
489+ "arm"
490+ ],
491+ "dev": true,
492+ "license": "MIT",
493+ "optional": true,
494+ "os": [
495+ "android"
496+ ]
497+ },
498+ "node_modules/@rollup/rollup-android-arm64": {
499+ "version": "4.63.4",
500+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
501+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
502+ "cpu": [
503+ "arm64"
504+ ],
505+ "dev": true,
506+ "license": "MIT",
507+ "optional": true,
508+ "os": [
509+ "android"
510+ ]
511+ },
512+ "node_modules/@rollup/rollup-darwin-arm64": {
513+ "version": "4.63.4",
514+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
515+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
516+ "cpu": [
517+ "arm64"
518+ ],
519+ "dev": true,
520+ "license": "MIT",
521+ "optional": true,
522+ "os": [
523+ "darwin"
524+ ]
525+ },
526+ "node_modules/@rollup/rollup-darwin-x64": {
527+ "version": "4.63.4",
528+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
529+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
530+ "cpu": [
531+ "x64"
532+ ],
533+ "dev": true,
534+ "license": "MIT",
535+ "optional": true,
536+ "os": [
537+ "darwin"
538+ ]
539+ },
540+ "node_modules/@rollup/rollup-freebsd-arm64": {
541+ "version": "4.63.4",
542+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
543+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
544+ "cpu": [
545+ "arm64"
546+ ],
547+ "dev": true,
548+ "license": "MIT",
549+ "optional": true,
550+ "os": [
551+ "freebsd"
552+ ]
553+ },
554+ "node_modules/@rollup/rollup-freebsd-x64": {
555+ "version": "4.63.4",
556+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
557+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
558+ "cpu": [
559+ "x64"
560+ ],
561+ "dev": true,
562+ "license": "MIT",
563+ "optional": true,
564+ "os": [
565+ "freebsd"
566+ ]
567+ },
568+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
569+ "version": "4.63.4",
570+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
571+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
572+ "cpu": [
573+ "arm"
574+ ],
575+ "dev": true,
576+ "libc": [
577+ "glibc"
578+ ],
579+ "license": "MIT",
580+ "optional": true,
581+ "os": [
582+ "linux"
583+ ]
584+ },
585+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
586+ "version": "4.63.4",
587+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
588+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
589+ "cpu": [
590+ "arm"
591+ ],
592+ "dev": true,
593+ "libc": [
594+ "musl"
595+ ],
596+ "license": "MIT",
597+ "optional": true,
598+ "os": [
599+ "linux"
600+ ]
601+ },
602+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
603+ "version": "4.63.4",
604+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
605+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
606+ "cpu": [
607+ "arm64"
608+ ],
609+ "dev": true,
610+ "libc": [
611+ "glibc"
612+ ],
613+ "license": "MIT",
614+ "optional": true,
615+ "os": [
616+ "linux"
617+ ]
618+ },
619+ "node_modules/@rollup/rollup-linux-arm64-musl": {
620+ "version": "4.63.4",
621+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
622+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
623+ "cpu": [
624+ "arm64"
625+ ],
626+ "dev": true,
627+ "libc": [
628+ "musl"
629+ ],
630+ "license": "MIT",
631+ "optional": true,
632+ "os": [
633+ "linux"
634+ ]
635+ },
636+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
637+ "version": "4.63.4",
638+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
639+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
640+ "cpu": [
641+ "loong64"
642+ ],
643+ "dev": true,
644+ "libc": [
645+ "glibc"
646+ ],
647+ "license": "MIT",
648+ "optional": true,
649+ "os": [
650+ "linux"
651+ ]
652+ },
653+ "node_modules/@rollup/rollup-linux-loong64-musl": {
654+ "version": "4.63.4",
655+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
656+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
657+ "cpu": [
658+ "loong64"
659+ ],
660+ "dev": true,
661+ "libc": [
662+ "musl"
663+ ],
664+ "license": "MIT",
665+ "optional": true,
666+ "os": [
667+ "linux"
668+ ]
669+ },
670+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
671+ "version": "4.63.4",
672+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
673+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
674+ "cpu": [
675+ "ppc64"
676+ ],
677+ "dev": true,
678+ "libc": [
679+ "glibc"
680+ ],
681+ "license": "MIT",
682+ "optional": true,
683+ "os": [
684+ "linux"
685+ ]
686+ },
687+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
688+ "version": "4.63.4",
689+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
690+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
691+ "cpu": [
692+ "ppc64"
693+ ],
694+ "dev": true,
695+ "libc": [
696+ "musl"
697+ ],
698+ "license": "MIT",
699+ "optional": true,
700+ "os": [
701+ "linux"
702+ ]
703+ },
704+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
705+ "version": "4.63.4",
706+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
707+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
708+ "cpu": [
709+ "riscv64"
710+ ],
711+ "dev": true,
712+ "libc": [
713+ "glibc"
714+ ],
715+ "license": "MIT",
716+ "optional": true,
717+ "os": [
718+ "linux"
719+ ]
720+ },
721+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
722+ "version": "4.63.4",
723+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
724+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
725+ "cpu": [
726+ "riscv64"
727+ ],
728+ "dev": true,
729+ "libc": [
730+ "musl"
731+ ],
732+ "license": "MIT",
733+ "optional": true,
734+ "os": [
735+ "linux"
736+ ]
737+ },
738+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
739+ "version": "4.63.4",
740+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
741+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
742+ "cpu": [
743+ "s390x"
744+ ],
745+ "dev": true,
746+ "libc": [
747+ "glibc"
748+ ],
749+ "license": "MIT",
750+ "optional": true,
751+ "os": [
752+ "linux"
753+ ]
754+ },
755+ "node_modules/@rollup/rollup-linux-x64-gnu": {
756+ "version": "4.63.4",
757+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
758+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
759+ "cpu": [
760+ "x64"
761+ ],
762+ "dev": true,
763+ "libc": [
764+ "glibc"
765+ ],
766+ "license": "MIT",
767+ "optional": true,
768+ "os": [
769+ "linux"
770+ ]
771+ },
772+ "node_modules/@rollup/rollup-linux-x64-musl": {
773+ "version": "4.63.4",
774+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
775+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
776+ "cpu": [
777+ "x64"
778+ ],
779+ "dev": true,
780+ "libc": [
781+ "musl"
782+ ],
783+ "license": "MIT",
784+ "optional": true,
785+ "os": [
786+ "linux"
787+ ]
788+ },
789+ "node_modules/@rollup/rollup-openbsd-x64": {
790+ "version": "4.63.4",
791+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
792+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
793+ "cpu": [
794+ "x64"
795+ ],
796+ "dev": true,
797+ "license": "MIT",
798+ "optional": true,
799+ "os": [
800+ "openbsd"
801+ ]
802+ },
803+ "node_modules/@rollup/rollup-openharmony-arm64": {
804+ "version": "4.63.4",
805+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
806+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
807+ "cpu": [
808+ "arm64"
809+ ],
810+ "dev": true,
811+ "license": "MIT",
812+ "optional": true,
813+ "os": [
814+ "openharmony"
815+ ]
816+ },
817+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
818+ "version": "4.63.4",
819+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
820+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
821+ "cpu": [
822+ "arm64"
823+ ],
824+ "dev": true,
825+ "license": "MIT",
826+ "optional": true,
827+ "os": [
828+ "win32"
829+ ]
830+ },
831+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
832+ "version": "4.63.4",
833+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
834+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
835+ "cpu": [
836+ "ia32"
837+ ],
838+ "dev": true,
839+ "license": "MIT",
840+ "optional": true,
841+ "os": [
842+ "win32"
843+ ]
844+ },
845+ "node_modules/@rollup/rollup-win32-x64-gnu": {
846+ "version": "4.63.4",
847+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
848+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
849+ "cpu": [
850+ "x64"
851+ ],
852+ "dev": true,
853+ "license": "MIT",
854+ "optional": true,
855+ "os": [
856+ "win32"
857+ ]
858+ },
859+ "node_modules/@rollup/rollup-win32-x64-msvc": {
860+ "version": "4.63.4",
861+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
862+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
863+ "cpu": [
864+ "x64"
865+ ],
866+ "dev": true,
867+ "license": "MIT",
868+ "optional": true,
869+ "os": [
870+ "win32"
871+ ]
872+ },
873+ "node_modules/@types/estree": {
874+ "version": "1.0.9",
875+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
876+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
877+ "dev": true,
878+ "license": "MIT"
879+ },
880+ "node_modules/@types/js-yaml": {
881+ "version": "4.0.9",
882+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
883+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
884+ "dev": true,
885+ "license": "MIT"
886+ },
887+ "node_modules/@types/node": {
888+ "version": "24.13.6",
889+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
890+ "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
891+ "dev": true,
892+ "license": "MIT",
893+ "dependencies": {
894+ "undici-types": "~7.18.0"
895+ }
896+ },
897+ "node_modules/@webgpu/types": {
898+ "version": "0.1.74",
899+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
900+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
901+ "dev": true,
902+ "license": "BSD-3-Clause"
903+ },
904+ "node_modules/argparse": {
905+ "version": "2.0.1",
906+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
907+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
908+ "license": "Python-2.0"
909+ },
910+ "node_modules/debug": {
911+ "version": "4.4.3",
912+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
913+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
914+ "dev": true,
915+ "license": "MIT",
916+ "dependencies": {
917+ "ms": "^2.1.3"
918+ },
919+ "engines": {
920+ "node": ">=6.0"
921+ },
922+ "peerDependenciesMeta": {
923+ "supports-color": {
924+ "optional": true
925+ }
926+ }
927+ },
928+ "node_modules/esbuild": {
929+ "version": "0.25.12",
930+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
931+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
932+ "dev": true,
933+ "hasInstallScript": true,
934+ "license": "MIT",
935+ "bin": {
936+ "esbuild": "bin/esbuild"
937+ },
938+ "engines": {
939+ "node": ">=18"
940+ },
941+ "optionalDependencies": {
942+ "@esbuild/aix-ppc64": "0.25.12",
943+ "@esbuild/android-arm": "0.25.12",
944+ "@esbuild/android-arm64": "0.25.12",
945+ "@esbuild/android-x64": "0.25.12",
946+ "@esbuild/darwin-arm64": "0.25.12",
947+ "@esbuild/darwin-x64": "0.25.12",
948+ "@esbuild/freebsd-arm64": "0.25.12",
949+ "@esbuild/freebsd-x64": "0.25.12",
950+ "@esbuild/linux-arm": "0.25.12",
951+ "@esbuild/linux-arm64": "0.25.12",
952+ "@esbuild/linux-ia32": "0.25.12",
953+ "@esbuild/linux-loong64": "0.25.12",
954+ "@esbuild/linux-mips64el": "0.25.12",
955+ "@esbuild/linux-ppc64": "0.25.12",
956+ "@esbuild/linux-riscv64": "0.25.12",
957+ "@esbuild/linux-s390x": "0.25.12",
958+ "@esbuild/linux-x64": "0.25.12",
959+ "@esbuild/netbsd-arm64": "0.25.12",
960+ "@esbuild/netbsd-x64": "0.25.12",
961+ "@esbuild/openbsd-arm64": "0.25.12",
962+ "@esbuild/openbsd-x64": "0.25.12",
963+ "@esbuild/openharmony-arm64": "0.25.12",
964+ "@esbuild/sunos-x64": "0.25.12",
965+ "@esbuild/win32-arm64": "0.25.12",
966+ "@esbuild/win32-ia32": "0.25.12",
967+ "@esbuild/win32-x64": "0.25.12"
968+ }
969+ },
970+ "node_modules/fdir": {
971+ "version": "6.5.0",
972+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
973+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
974+ "dev": true,
975+ "license": "MIT",
976+ "engines": {
977+ "node": ">=12.0.0"
978+ },
979+ "peerDependencies": {
980+ "picomatch": "^3 || ^4"
981+ },
982+ "peerDependenciesMeta": {
983+ "picomatch": {
984+ "optional": true
985+ }
986+ }
987+ },
988+ "node_modules/fsevents": {
989+ "version": "2.3.3",
990+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
991+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
992+ "dev": true,
993+ "hasInstallScript": true,
994+ "license": "MIT",
995+ "optional": true,
996+ "os": [
997+ "darwin"
998+ ],
999+ "engines": {
1000+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1001+ }
1002+ },
1003+ "node_modules/js-yaml": {
1004+ "version": "4.3.2",
1005+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
1006+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
1007+ "funding": [
1008+ {
1009+ "type": "github",
1010+ "url": "https://github.com/sponsors/puzrin"
1011+ },
1012+ {
1013+ "type": "github",
1014+ "url": "https://github.com/sponsors/nodeca"
1015+ }
1016+ ],
1017+ "license": "MIT",
1018+ "dependencies": {
1019+ "argparse": "^2.0.1"
1020+ },
1021+ "bin": {
1022+ "js-yaml": "bin/js-yaml.js"
1023+ }
1024+ },
1025+ "node_modules/ms": {
1026+ "version": "2.1.3",
1027+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1028+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1029+ "dev": true,
1030+ "license": "MIT"
1031+ },
1032+ "node_modules/nanoid": {
1033+ "version": "3.3.19",
1034+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1035+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1036+ "dev": true,
1037+ "funding": [
1038+ {
1039+ "type": "github",
1040+ "url": "https://github.com/sponsors/ai"
1041+ }
1042+ ],
1043+ "license": "MIT",
1044+ "bin": {
1045+ "nanoid": "bin/nanoid.cjs"
1046+ },
1047+ "engines": {
1048+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1049+ }
1050+ },
1051+ "node_modules/picocolors": {
1052+ "version": "1.1.1",
1053+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1054+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1055+ "dev": true,
1056+ "license": "ISC"
1057+ },
1058+ "node_modules/picomatch": {
1059+ "version": "4.0.7",
1060+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1061+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1062+ "dev": true,
1063+ "license": "MIT",
1064+ "engines": {
1065+ "node": ">=12"
1066+ },
1067+ "funding": {
1068+ "url": "https://github.com/sponsors/jonschlinkert"
1069+ }
1070+ },
1071+ "node_modules/postcss": {
1072+ "version": "8.5.28",
1073+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1074+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1075+ "dev": true,
1076+ "funding": [
1077+ {
1078+ "type": "opencollective",
1079+ "url": "https://opencollective.com/postcss/"
1080+ },
1081+ {
1082+ "type": "tidelift",
1083+ "url": "https://tidelift.com/funding/github/npm/postcss"
1084+ },
1085+ {
1086+ "type": "github",
1087+ "url": "https://github.com/sponsors/ai"
1088+ }
1089+ ],
1090+ "license": "MIT",
1091+ "dependencies": {
1092+ "nanoid": "^3.3.18",
1093+ "picocolors": "^1.1.1",
1094+ "source-map-js": "^1.2.1"
1095+ },
1096+ "engines": {
1097+ "node": "^10 || ^12 || >=14"
1098+ }
1099+ },
1100+ "node_modules/rollup": {
1101+ "version": "4.63.4",
1102+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
1103+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
1104+ "dev": true,
1105+ "license": "MIT",
1106+ "dependencies": {
1107+ "@types/estree": "1.0.9"
1108+ },
1109+ "bin": {
1110+ "rollup": "dist/bin/rollup"
1111+ },
1112+ "engines": {
1113+ "node": ">=18.0.0",
1114+ "npm": ">=8.0.0"
1115+ },
1116+ "optionalDependencies": {
1117+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1118+ "@rollup/rollup-android-arm-eabi": "4.63.4",
1119+ "@rollup/rollup-android-arm64": "4.63.4",
1120+ "@rollup/rollup-darwin-arm64": "4.63.4",
1121+ "@rollup/rollup-darwin-x64": "4.63.4",
1122+ "@rollup/rollup-freebsd-arm64": "4.63.4",
1123+ "@rollup/rollup-freebsd-x64": "4.63.4",
1124+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
1125+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
1126+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
1127+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
1128+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
1129+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
1130+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
1131+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
1132+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
1133+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
1134+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
1135+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
1136+ "@rollup/rollup-linux-x64-musl": "4.63.4",
1137+ "@rollup/rollup-openbsd-x64": "4.63.4",
1138+ "@rollup/rollup-openharmony-arm64": "4.63.4",
1139+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
1140+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
1141+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
1142+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
1143+ "fsevents": "~2.3.2"
1144+ }
1145+ },
1146+ "node_modules/source-map-js": {
1147+ "version": "1.2.1",
1148+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1149+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1150+ "dev": true,
1151+ "license": "BSD-3-Clause",
1152+ "engines": {
1153+ "node": ">=0.10.0"
1154+ }
1155+ },
1156+ "node_modules/tinyglobby": {
1157+ "version": "0.2.17",
1158+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1159+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1160+ "dev": true,
1161+ "license": "MIT",
1162+ "dependencies": {
1163+ "fdir": "^6.5.0",
1164+ "picomatch": "^4.0.4"
1165+ },
1166+ "engines": {
1167+ "node": ">=12.0.0"
1168+ },
1169+ "funding": {
1170+ "url": "https://github.com/sponsors/SuperchupuDev"
1171+ }
1172+ },
1173+ "node_modules/typescript": {
1174+ "version": "5.9.3",
1175+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1176+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1177+ "dev": true,
1178+ "license": "Apache-2.0",
1179+ "bin": {
1180+ "tsc": "bin/tsc",
1181+ "tsserver": "bin/tsserver"
1182+ },
1183+ "engines": {
1184+ "node": ">=14.17"
1185+ }
1186+ },
1187+ "node_modules/undici-types": {
1188+ "version": "7.18.2",
1189+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1190+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1191+ "dev": true,
1192+ "license": "MIT"
1193+ },
1194+ "node_modules/vite": {
1195+ "version": "6.4.3",
1196+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
1197+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
1198+ "dev": true,
1199+ "license": "MIT",
1200+ "dependencies": {
1201+ "esbuild": "^0.25.0",
1202+ "fdir": "^6.4.4",
1203+ "picomatch": "^4.0.2",
1204+ "postcss": "^8.5.3",
1205+ "rollup": "^4.34.9",
1206+ "tinyglobby": "^0.2.13"
1207+ },
1208+ "bin": {
1209+ "vite": "bin/vite.js"
1210+ },
1211+ "engines": {
1212+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1213+ },
1214+ "funding": {
1215+ "url": "https://github.com/vitejs/vite?sponsor=1"
1216+ },
1217+ "optionalDependencies": {
1218+ "fsevents": "~2.3.3"
1219+ },
1220+ "peerDependencies": {
1221+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1222+ "jiti": ">=1.21.0",
1223+ "less": "*",
1224+ "lightningcss": "^1.21.0",
1225+ "sass": "*",
1226+ "sass-embedded": "*",
1227+ "stylus": "*",
1228+ "sugarss": "*",
1229+ "terser": "^5.16.0",
1230+ "tsx": "^4.8.1",
1231+ "yaml": "^2.4.2"
1232+ },
1233+ "peerDependenciesMeta": {
1234+ "@types/node": {
1235+ "optional": true
1236+ },
1237+ "jiti": {
1238+ "optional": true
1239+ },
1240+ "less": {
1241+ "optional": true
1242+ },
1243+ "lightningcss": {
1244+ "optional": true
1245+ },
1246+ "sass": {
1247+ "optional": true
1248+ },
1249+ "sass-embedded": {
1250+ "optional": true
1251+ },
1252+ "stylus": {
1253+ "optional": true
1254+ },
1255+ "sugarss": {
1256+ "optional": true
1257+ },
1258+ "terser": {
1259+ "optional": true
1260+ },
1261+ "tsx": {
1262+ "optional": true
1263+ },
1264+ "yaml": {
1265+ "optional": true
1266+ }
1267+ }
1268+ },
1269+ "node_modules/webgpu": {
1270+ "version": "0.4.0",
1271+ "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.4.0.tgz",
1272+ "integrity": "sha512-F5pimn3Aoi0zWjuRdiVs5TnrUwSzD2lESBohsIUsqyitWkGRQlXU2fhV6ycXlQTa1bvAf3sjqiUpBEpmSQ5ptA==",
1273+ "dev": true,
1274+ "hasInstallScript": true,
1275+ "license": "MIT",
1276+ "dependencies": {
1277+ "@webgpu/types": "^0.1.69",
1278+ "debug": "^4.4.0"
1279+ }
1280+ }
1281+ }
1282+}
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "cymmodes",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Browser explorer of the linear modes of a cymsim cymbal and of the nonlinear coupling between them: compute the modes, view them in 3D, drive one in the full nonlinear model and watch where the energy goes, mode by mode.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview",
11+ "test": "npm run test:cpu && npm run test:gpu",
12+ "test:cpu": "node --test tests/*.test.ts",
13+ "test:gpu": "node tests/gpu.check.ts"
14+ },
15+ "devDependencies": {
16+ "@types/js-yaml": "^4.0.9",
17+ "@types/node": "^24.0.0",
18+ "@webgpu/types": "^0.1.60",
19+ "typescript": "^5.6.0",
20+ "vite": "^6.0.0",
21+ "webgpu": "^0.4.0"
22+ },
23+ "dependencies": {
24+ "js-yaml": "^4.1.0"
25+ }
26+}
src/analysis/spectrogram.tsadded+97−0View file
@@ -0,0 +1,97 @@
1+// Incremental short-time Fourier transform of the listener signal: a Hann window of nfft samples
2+// advanced by hop, one power column per hop, plus the spectral centroid of each column.
3+
4+/** In-place iterative radix-2 FFT of (re, im); n must be a power of two. */
5+export function fft(re: Float32Array, im: Float32Array) {
6+ const n = re.length;
7+ for (let i = 1, j = 0; i < n; i++) {
8+ let bit = n >> 1;
9+ for (; j & bit; bit >>= 1) j ^= bit;
10+ j ^= bit;
11+ if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
12+ }
13+ for (let len = 2; len <= n; len <<= 1) {
14+ const ang = (-2 * Math.PI) / len, wr = Math.cos(ang), wi = Math.sin(ang);
15+ for (let i = 0; i < n; i += len) {
16+ let cr = 1, ci = 0;
17+ for (let k = 0; k < len / 2; k++) {
18+ const a = i + k, b = a + len / 2;
19+ const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
20+ re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
21+ const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr;
22+ }
23+ }
24+ }
25+}
26+
27+export class Stft {
28+ readonly nfft: number; readonly hop: number; readonly fs: number; readonly nBins: number;
29+ readonly columns: Float32Array[] = []; // power per bin
30+ readonly centroid: number[] = []; // Hz per column
31+ private readonly window: Float32Array;
32+ private buffer = new Float32Array(0);
33+ private consumed = 0; // samples already removed from the front of buffer
34+ private readonly re: Float32Array; private readonly im: Float32Array;
35+
36+ constructor(fs: number, nfft = 1024, hop = 256) {
37+ this.fs = fs; this.nfft = nfft; this.hop = hop; this.nBins = nfft / 2 + 1;
38+ this.window = new Float32Array(nfft);
39+ for (let i = 0; i < nfft; i++) this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / nfft);
40+ this.re = new Float32Array(nfft); this.im = new Float32Array(nfft);
41+ }
42+
43+ /** Time of column c (its window centre) in seconds. */
44+ columnTime(c: number): number { return (c * this.hop + this.nfft / 2) / this.fs; }
45+ binFrequency(b: number): number { return (b * this.fs) / this.nfft; }
46+
47+ /** Append samples; returns the number of new columns. */
48+ push(samples: Float32Array): number {
49+ const joined = new Float32Array(this.buffer.length + samples.length);
50+ joined.set(this.buffer); joined.set(samples, this.buffer.length);
51+ this.buffer = joined;
52+ let n = 0, start = 0;
53+ while (start + this.nfft <= this.buffer.length) {
54+ for (let i = 0; i < this.nfft; i++) { this.re[i] = this.buffer[start + i] * this.window[i]; this.im[i] = 0; }
55+ fft(this.re, this.im);
56+ const col = new Float32Array(this.nBins);
57+ let num = 0, den = 0;
58+ for (let b = 0; b < this.nBins; b++) {
59+ col[b] = this.re[b] * this.re[b] + this.im[b] * this.im[b];
60+ num += col[b] * this.binFrequency(b); den += col[b];
61+ }
62+ this.columns.push(col); this.centroid.push(den > 0 ? num / den : 0);
63+ start += this.hop; n++;
64+ }
65+ this.buffer = this.buffer.slice(start); this.consumed += start;
66+ return n;
67+ }
68+}
69+
70+export const DEFAULT_BANDS = [60, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
71+
72+export interface BandSummary { lo: number; hi: number; onsetMs: number; peakMs: number; levelDb: number }
73+
74+/** Per-octave band envelopes: onset (first time at -20 dB of the band's own peak), peak time, level vs the loudest band. */
75+export function bandSummary(st: Stft, bands = DEFAULT_BANDS): { bands: BandSummary[]; centroidHz: { ms: number; hz: number }[] } {
76+ const nCol = st.columns.length;
77+ if (!nCol) return { bands: [], centroidHz: [] };
78+ const total = new Float64Array(nCol);
79+ for (let c = 0; c < nCol; c++) for (const p of st.columns[c]) total[c] += p;
80+ const totMax = Math.max(...total) || 1;
81+ const out: BandSummary[] = [];
82+ for (let b = 0; b + 1 < bands.length; b++) {
83+ const lo = bands[b], hi = bands[b + 1];
84+ const b0 = Math.ceil(lo / st.binFrequency(1)), b1 = Math.min(st.nBins, Math.ceil(hi / st.binFrequency(1)));
85+ if (b0 >= b1) continue;
86+ const env = new Float64Array(nCol);
87+ let mx = 0, peak = 0;
88+ for (let c = 0; c < nCol; c++) { let s = 0; for (let k = b0; k < b1; k++) s += st.columns[c][k]; env[c] = s; if (s > mx) { mx = s; peak = c; } }
89+ if (mx <= 0) continue;
90+ let onset = 0;
91+ for (let c = 0; c < nCol; c++) if (env[c] >= mx * 0.01) { onset = c; break; }
92+ out.push({ lo, hi, onsetMs: 1e3 * st.columnTime(onset), peakMs: 1e3 * st.columnTime(peak), levelDb: 10 * Math.log10(mx / totMax) });
93+ }
94+ const centroidHz = [5, 10, 20, 50, 100, 200, 500, 1000, 2000].filter((ms) => ms / 1e3 < st.columnTime(nCol - 1))
95+ .map((ms) => { let c = 0; while (c < nCol - 1 && st.columnTime(c) < ms / 1e3) c++; return { ms, hz: Math.round(st.centroid[c]) }; });
96+ return { bands: out, centroidHz };
97+}
src/audio/player.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+// Playback of the part of the signal that exists so far, through Web Audio, and WAV export.
2+
3+export class Player {
4+ private ctx: AudioContext | null = null;
5+ private gain: GainNode | null = null;
6+ private source: AudioBufferSourceNode | null = null;
7+ private startedAt = 0; private offset = 0; private length = 0;
8+ private fs = 44100;
9+ playing = false;
10+ onEnded: (() => void) | null = null;
11+
12+ private ensure() {
13+ if (!this.ctx) { this.ctx = new AudioContext(); this.gain = this.ctx.createGain(); this.gain.connect(this.ctx.destination); }
14+ return this.ctx;
15+ }
16+
17+ setGainDb(db: number) { if (this.gain) this.gain.gain.value = Math.pow(10, db / 20); this.gainDb = db; }
18+ private gainDb = -12;
19+
20+ /** Play ``samples`` (already scaled to [-1, 1]) from ``fromSec``; the sound stops where the samples end. */
21+ play(samples: Float32Array, fs: number, fromSec: number) {
22+ this.stop();
23+ const ctx = this.ensure();
24+ this.setGainDb(this.gainDb);
25+ if (ctx.state === "suspended") void ctx.resume();
26+ const from = Math.min(Math.max(0, Math.floor(fromSec * fs)), samples.length);
27+ const part = samples.subarray(from);
28+ if (part.length === 0) return;
29+ const buf = ctx.createBuffer(1, part.length, fs);
30+ buf.copyToChannel(Float32Array.from(part), 0);
31+ const src = ctx.createBufferSource();
32+ src.buffer = buf; src.connect(this.gain!);
33+ src.onended = () => { if (this.source === src) { this.playing = false; this.source = null; this.onEnded?.(); } };
34+ src.start();
35+ this.source = src; this.startedAt = ctx.currentTime; this.offset = from / fs; this.length = samples.length / fs; this.fs = fs;
36+ this.playing = true;
37+ }
38+
39+ stop() {
40+ if (this.source) { const s = this.source; this.source = null; try { s.stop(); } catch { /* already stopped */ } }
41+ this.playing = false;
42+ }
43+
44+ /** Current playback position in seconds (or the last position when stopped). */
45+ position(): number {
46+ if (!this.playing || !this.ctx) return this.offset;
47+ return Math.min(this.offset + this.ctx.currentTime - this.startedAt, this.length);
48+ }
49+
50+ get sampleRate() { return this.fs; }
51+}
52+
53+/** Scale a pressure signal for playback: peak at 0.9 full scale (normalize) or 1 Pa = 0.05 FS. */
54+export function scaleForPlayback(p: Float32Array, normalize: boolean): Float32Array {
55+ let peak = 0;
56+ for (const x of p) peak = Math.max(peak, Math.abs(x));
57+ const s = normalize ? (peak > 0 ? 0.9 / peak : 0) : 0.05;
58+ const out = new Float32Array(p.length);
59+ for (let i = 0; i < p.length; i++) out[i] = Math.max(-1, Math.min(1, p[i] * s));
60+ return out;
61+}
62+
63+/** 16-bit PCM mono WAV. */
64+export function encodeWav(samples: Float32Array, fs: number): Blob {
65+ const n = samples.length, buf = new ArrayBuffer(44 + 2 * n), v = new DataView(buf);
66+ const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
67+ str(0, "RIFF"); v.setUint32(4, 36 + 2 * n, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true);
68+ v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, fs, true); v.setUint32(28, fs * 2, true);
69+ v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, 2 * n, true);
70+ for (let i = 0; i < n; i++) v.setInt16(44 + 2 * i, Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), true);
71+ return new Blob([buf], { type: "audio/wav" });
72+}
src/cache.tsadded+59−0View file
@@ -0,0 +1,59 @@
1+// IndexedDB cache of computed modes files, keyed by a hash of the configuration and options.
2+
3+const DB = "cymplay", STORE = "modes";
4+
5+function open(): Promise<IDBDatabase> {
6+ return new Promise((resolve, reject) => {
7+ const req = indexedDB.open(DB, 1);
8+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
9+ req.onsuccess = () => resolve(req.result);
10+ req.onerror = () => reject(req.error);
11+ });
12+}
13+
14+export async function cacheKey(obj: unknown): Promise<string> {
15+ const bytes = new TextEncoder().encode(JSON.stringify(obj));
16+ const digest = await crypto.subtle.digest("SHA-256", bytes);
17+ return Array.from(new Uint8Array(digest).slice(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
18+}
19+
20+export async function cacheGet(key: string): Promise<ArrayBuffer | null> {
21+ try {
22+ const db = await open();
23+ return await new Promise((resolve, reject) => {
24+ const req = db.transaction(STORE, "readonly").objectStore(STORE).get(key);
25+ req.onsuccess = () => resolve((req.result as ArrayBuffer) ?? null);
26+ req.onerror = () => reject(req.error);
27+ });
28+ } catch {
29+ return null;
30+ }
31+}
32+
33+export async function cachePut(key: string, buffer: ArrayBuffer): Promise<void> {
34+ try {
35+ const db = await open();
36+ await new Promise<void>((resolve, reject) => {
37+ const tx = db.transaction(STORE, "readwrite");
38+ tx.objectStore(STORE).put(buffer, key);
39+ tx.oncomplete = () => resolve();
40+ tx.onerror = () => reject(tx.error);
41+ });
42+ } catch {
43+ /* caching is best effort */
44+ }
45+}
46+
47+export async function cacheClear(): Promise<void> {
48+ try {
49+ const db = await open();
50+ await new Promise<void>((resolve, reject) => {
51+ const tx = db.transaction(STORE, "readwrite");
52+ tx.objectStore(STORE).clear();
53+ tx.oncomplete = () => resolve();
54+ tx.onerror = () => reject(tx.error);
55+ });
56+ } catch {
57+ /* ignore */
58+ }
59+}
src/config/presets.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+// Built-in configurations: the example YAML files of the cymsim Python package, verbatim.
2+
3+export interface Preset { id: string; name: string; yaml: string }
4+
5+export const PRESETS: Preset[] = [
6+ { id: "crash_mallet", name: "Crash, mallet stroke", yaml: "# Soft mallet, gong-like stroke on an 18 inch medium-thin crash (the prototype's default run).\n# The mallet injects only low frequencies; everything above a few hundred Hz comes from the\n# nonlinear cascade. Units are SI throughout (m, kg, s, Pa). See docs/model.md.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.13716\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 2.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: crash_mallet\n wav: true\n npz: true\n cascade_plot: true\n" },
7+ { id: "crash_stick", name: "Crash, stick shoulder hit", yaml: "# Hard stick shoulder crash near the rim: Hertz contact (alpha = 1.5) along a 3 cm line.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.19430999999999998\n phi: 0.3\n velocity: 7.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 2.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: crash_stick\n wav: true\n npz: true\n cascade_plot: true\n" },
8+ { id: "hit_train", name: "Train of four hits", yaml: "# A train of four hits: a mallet stroke, two stick crashes on the ringing cymbal, and a second\n# mallet stroke. Each hit has its own exciter, released at its time just above the moving surface.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 400\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.006\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.137\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\n- time: 0.8\n r: 0.194\n phi: 2.0\n velocity: 7.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\n- time: 1.4\n r: 0.194\n phi: 2.3\n velocity: 5.0\n exciter:\n kind: stick\n mass: 0.03\n k: 500000000.0\n alpha: 1.5\n patch_length: 0.03\n patch_points: 7\n patch_angle: 0.6\n gap: 0.0001\n- time: 2.0\n r: 0.137\n phi: -1.0\n velocity: 6.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 3.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: train\n wav: true\n npz: true\n cascade_plot: true\n" },
9+ { id: "symmetric", name: "Symmetric (no hammering)", yaml: "# The default 18 inch crash with every imperfection removed: no hammer marks, no rim warp, no\n# ovality, centred bell. The mode pairs stay (nearly) degenerate, which makes the internal\n# resonances easiest to read.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 0\n dent_depth: 0.0\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.0\n bell_offset: 0.0\nmesh:\n element_size: 0.006\n smoothing: 1\nhits: []\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 2.0\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 20000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: symmetric\n wav: true\n npz: true\n cascade_plot: true\n" },
10+ { id: "quick_cpu", name: "Quick preview (coarse mesh)", yaml: "# Coarse, short run that finishes in about a minute on a CPU. Element size 20 mm resolves only\n# the low modes; use it to exercise the pipeline, not for listening.\nmaterial:\n E: 110000000000.0\n nu: 0.34\n rho: 8700.0\n sigma0: 0.9\n sigma1: 0.0005\nprofile:\n radius: 0.2286\n bell_height: 0.013\n bell_top_radius: 0.025\n bell_shoulder_radius: 0.08\n bow_height: 0.03\n bow_power: 1.4\n h_rim: 0.001\n h_shoulder: 0.0017\n h_bell: 0.0026\n max_slope: 0.4\nimperfections:\n seed: 1\n n_dents: 100\n dent_depth: 0.00025\n dent_sigma: 0.007\n dent_margin: 0.01\n rim_warp: 0.0006\n rim_warp_modes:\n - 2\n - 3\n - 4\n - 5\n shoulder_ovality: 0.03\n bell_offset: 0.0015\nmesh:\n element_size: 0.02\n smoothing: 1\nhits:\n- time: 0.0\n r: 0.13716\n phi: 0.3\n velocity: 8.0\n exciter:\n kind: mallet\n mass: 0.3\n alpha: 2.5\n compression: 0.012\n radius: 0.012\n gap: 0.0001\nlistener:\n position:\n - 0.4\n - 0.3\n - 0.9\n c_air: 343.0\n rho_air: 1.2\n back_diffraction_gain: 1.0\nnumerics:\n fs: 44100\n duration: 0.3\n cfl_safety: 0.6\n rot_inertia_factor: 0.015625\n inplane_mass_scale: 1.0\n steps_per_block: 2000\n dtype: float32\n nonlinear: true\n n_progress: 20\noutput:\n prefix: quick\n wav: true\n npz: true\n cascade_plot: true\n" },
11+];
src/config/schema.tsadded+56−0View file
@@ -0,0 +1,56 @@
1+// Configuration types mirroring the Python package's dataclasses (cymsim/config.py), with the
2+// same field names, units (SI) and defaults, so that a YAML file is valid for both.
3+
4+export interface Material { E: number; nu: number; rho: number; sigma0: number; sigma1: number }
5+export interface Profile {
6+ radius: number; bell_height: number; bell_top_radius: number; bell_shoulder_radius: number;
7+ bow_height: number; bow_power: number; h_rim: number; h_shoulder: number; h_bell: number; max_slope: number;
8+}
9+export interface Imperfections {
10+ seed: number; n_dents: number; dent_depth: number; dent_sigma: number; dent_margin: number;
11+ rim_warp: number; rim_warp_modes: number[]; shoulder_ovality: number; bell_offset: number;
12+}
13+export interface MeshConfig { element_size: number; smoothing: number }
14+export interface StickExciter {
15+ kind: "stick"; mass: number; k: number; alpha: number; patch_length: number; patch_points: number;
16+ patch_angle: number; gap: number;
17+}
18+export interface MalletExciter { kind: "mallet"; mass: number; alpha: number; compression: number; radius: number; gap: number }
19+export type Exciter = StickExciter | MalletExciter;
20+export interface Hit { time: number; r: number; phi: number; velocity: number; exciter: Exciter }
21+export interface Listener { position: [number, number, number]; c_air: number; rho_air: number; back_diffraction_gain: number }
22+export interface Numerics {
23+ fs: number; duration: number; cfl_safety: number; rot_inertia_factor: number; inplane_mass_scale: number;
24+ steps_per_block: number; dtype: string; nonlinear: boolean; n_progress: number;
25+}
26+export interface Output { prefix: string; wav: boolean; npz: boolean; cascade_plot: boolean }
27+export interface SimulationConfig {
28+ material: Material; profile: Profile; imperfections: Imperfections; mesh: MeshConfig; hits: Hit[];
29+ listener: Listener; numerics: Numerics; output: Output;
30+}
31+
32+export const defaultMaterial = (): Material => ({ E: 1.1e11, nu: 0.34, rho: 8700, sigma0: 0.9, sigma1: 5e-4 });
33+export const defaultProfile = (): Profile => ({
34+ radius: 0.2286, bell_height: 0.013, bell_top_radius: 0.025, bell_shoulder_radius: 0.08, bow_height: 0.03,
35+ bow_power: 1.4, h_rim: 1.0e-3, h_shoulder: 1.7e-3, h_bell: 2.6e-3, max_slope: 0.4,
36+});
37+export const defaultImperfections = (): Imperfections => ({
38+ seed: 1, n_dents: 400, dent_depth: 0.25e-3, dent_sigma: 7e-3, dent_margin: 0.01, rim_warp: 0.6e-3,
39+ rim_warp_modes: [2, 3, 4, 5], shoulder_ovality: 0.03, bell_offset: 1.5e-3,
40+});
41+export const defaultMesh = (): MeshConfig => ({ element_size: 6e-3, smoothing: 1 });
42+export const defaultStick = (): StickExciter => ({
43+ kind: "stick", mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4,
44+});
45+export const defaultMallet = (): MalletExciter => ({ kind: "mallet", mass: 0.3, alpha: 2.5, compression: 12e-3, radius: 0.012, gap: 1e-4 });
46+export const defaultHit = (): Hit => ({ time: 0, r: 0.137, phi: 0.3, velocity: 8, exciter: defaultMallet() });
47+export const defaultListener = (): Listener => ({ position: [0.4, 0.3, 0.9], c_air: 343, rho_air: 1.2, back_diffraction_gain: 1 });
48+export const defaultNumerics = (): Numerics => ({
49+ fs: 44100, duration: 2, cfl_safety: 0.6, rot_inertia_factor: 1 / 64, inplane_mass_scale: 1, steps_per_block: 20000,
50+ dtype: "float32", nonlinear: true, n_progress: 20,
51+});
52+export const defaultOutput = (): Output => ({ prefix: "crash", wav: true, npz: true, cascade_plot: true });
53+export const defaultConfig = (): SimulationConfig => ({
54+ material: defaultMaterial(), profile: defaultProfile(), imperfections: defaultImperfections(), mesh: defaultMesh(),
55+ hits: [defaultHit()], listener: defaultListener(), numerics: defaultNumerics(), output: defaultOutput(),
56+});
src/config/yaml.tsadded+125−0View file
@@ -0,0 +1,125 @@
1+// YAML <-> SimulationConfig: parsing with defaults for omitted fields, reporting of unknown keys
2+// and wrong types, the validation rules of the Python package, and serialization in the same
3+// field order the Python package writes.
4+import yaml from "js-yaml";
5+import { defaultConfig, defaultHit, defaultMallet, defaultStick, type Exciter, type Hit, type SimulationConfig } from "./schema.ts";
6+
7+export interface Issue { path: string; message: string }
8+
9+const SECTIONS = ["material", "profile", "imperfections", "mesh", "hits", "listener", "numerics", "output"] as const;
10+
11+function isObject(x: unknown): x is Record<string, unknown> { return typeof x === "object" && x !== null && !Array.isArray(x); }
12+
13+/** Fill one flat section from raw data, reporting unknown keys and wrong types. */
14+function fillSection<T extends object>(target: T, raw: unknown, path: string, issues: Issue[]): T {
15+ if (raw === undefined || raw === null) return target;
16+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return target; }
17+ const t = target as unknown as Record<string, unknown>;
18+ for (const key of Object.keys(raw)) {
19+ if (!(key in t)) { issues.push({ path: `${path}.${key}`, message: "unknown field" }); continue; }
20+ const def = t[key], val = raw[key];
21+ if (typeof def === "number") {
22+ if (typeof val === "number" && Number.isFinite(val)) t[key] = val;
23+ else issues.push({ path: `${path}.${key}`, message: "expected a number" });
24+ } else if (typeof def === "boolean") {
25+ if (typeof val === "boolean") t[key] = val;
26+ else issues.push({ path: `${path}.${key}`, message: "expected true or false" });
27+ } else if (typeof def === "string") {
28+ if (typeof val === "string") t[key] = val;
29+ else issues.push({ path: `${path}.${key}`, message: "expected a string" });
30+ } else if (Array.isArray(def)) {
31+ if (Array.isArray(val) && val.every((x) => typeof x === "number" && Number.isFinite(x))) {
32+ if (key === "position" && val.length !== 3) issues.push({ path: `${path}.${key}`, message: "expected three numbers" });
33+ else t[key] = val.slice();
34+ } else issues.push({ path: `${path}.${key}`, message: "expected a list of numbers" });
35+ }
36+ }
37+ return target;
38+}
39+
40+function fillExciter(raw: unknown, path: string, issues: Issue[]): Exciter {
41+ if (raw === undefined) return defaultMallet();
42+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping with a 'kind' key" }); return defaultMallet(); }
43+ const kind = raw.kind;
44+ if (kind === "stick") return fillSection(defaultStick(), raw, path, issues);
45+ if (kind === "mallet") return fillSection(defaultMallet(), raw, path, issues);
46+ issues.push({ path: `${path}.kind`, message: `unknown exciter kind ${JSON.stringify(kind)}; expected 'stick' or 'mallet'` });
47+ return defaultMallet();
48+}
49+
50+function fillHit(raw: unknown, path: string, issues: Issue[]): Hit {
51+ const hit = defaultHit();
52+ if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return hit; }
53+ const { exciter, ...rest } = raw;
54+ const flat: Record<string, unknown> = { time: hit.time, r: hit.r, phi: hit.phi, velocity: hit.velocity };
55+ fillSection(flat, rest, path, issues);
56+ return { time: flat.time as number, r: flat.r as number, phi: flat.phi as number, velocity: flat.velocity as number,
57+ exciter: fillExciter(exciter, `${path}.exciter`, issues) };
58+}
59+
60+/** Build a complete configuration from raw (parsed YAML) data; missing fields take the defaults. */
61+export function normalizeConfig(raw: unknown): { config: SimulationConfig; issues: Issue[] } {
62+ const issues: Issue[] = [];
63+ const cfg = defaultConfig();
64+ if (raw === null || raw === undefined) return { config: cfg, issues };
65+ if (!isObject(raw)) { issues.push({ path: "", message: "the document must be a mapping" }); return { config: cfg, issues }; }
66+ for (const key of Object.keys(raw)) if (!(SECTIONS as readonly string[]).includes(key)) issues.push({ path: key, message: "unknown section" });
67+ fillSection(cfg.material, raw.material, "material", issues);
68+ fillSection(cfg.profile, raw.profile, "profile", issues);
69+ fillSection(cfg.imperfections, raw.imperfections, "imperfections", issues);
70+ fillSection(cfg.mesh, raw.mesh, "mesh", issues);
71+ fillSection(cfg.listener, raw.listener, "listener", issues);
72+ fillSection(cfg.numerics, raw.numerics, "numerics", issues);
73+ fillSection(cfg.output, raw.output, "output", issues);
74+ if (raw.hits !== undefined) {
75+ if (!Array.isArray(raw.hits)) issues.push({ path: "hits", message: "expected a list of hits" });
76+ else cfg.hits = raw.hits.map((h, i) => fillHit(h, `hits[${i}]`, issues));
77+ }
78+ return { config: cfg, issues };
79+}
80+
81+/** The validation rules of cymsim/config.py, plus a few that the web app needs. */
82+export function validateConfig(cfg: SimulationConfig): Issue[] {
83+ const issues: Issue[] = [], p = cfg.profile, n = cfg.numerics;
84+ if (cfg.hits.length === 0) issues.push({ path: "hits", message: "at least one hit is required" });
85+ if (n.dtype !== "float32" && n.dtype !== "float64") issues.push({ path: "numerics.dtype", message: "must be 'float32' or 'float64' (the web app always runs in float32)" });
86+ if (!(n.cfl_safety > 0 && n.cfl_safety <= 1)) issues.push({ path: "numerics.cfl_safety", message: "must lie in (0, 1]" });
87+ if (!(cfg.mesh.element_size > 0 && cfg.mesh.element_size <= p.radius / 2)) issues.push({ path: "mesh.element_size", message: "must be positive and well below the radius" });
88+ if (!(n.duration > 0)) issues.push({ path: "numerics.duration", message: "must be positive" });
89+ if (!(n.fs >= 8000 && n.fs <= 192000)) issues.push({ path: "numerics.fs", message: "must lie between 8000 and 192000 Hz" });
90+ if (!(p.radius > 0 && p.h_rim > 0 && p.h_shoulder > 0 && p.h_bell > 0)) issues.push({ path: "profile", message: "radius and thicknesses must be positive" });
91+ if (!(p.bell_top_radius < p.bell_shoulder_radius && p.bell_shoulder_radius < p.radius)) issues.push({ path: "profile", message: "need bell_top_radius < bell_shoulder_radius < radius" });
92+ if (!(cfg.material.E > 0 && cfg.material.rho > 0 && cfg.material.nu > -1 && cfg.material.nu < 0.5)) issues.push({ path: "material", message: "E and rho must be positive and nu in (-1, 0.5)" });
93+ cfg.hits.forEach((h, i) => {
94+ if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" });
95+ if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" });
96+ const ex = h.exciter;
97+ const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
98+ if (h.r + extent >= p.radius - cfg.mesh.element_size) issues.push({ path: `hits[${i}].r`, message: `contact patch reaches the rim (r = ${h.r}, extent = ${extent})` });
99+ if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" });
100+ if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" });
101+ if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" });
102+ if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" });
103+ });
104+ return issues;
105+}
106+
107+/** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */
108+export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } {
109+ let raw: unknown;
110+ try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; }
111+ const { config, issues } = normalizeConfig(raw);
112+ return { config, issues: issues.concat(validateConfig(config)) };
113+}
114+
115+export function configToYaml(cfg: SimulationConfig): string {
116+ return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false });
117+}
118+
119+export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }
120+
121+/** The validation relevant here, where the hits of a configuration are ignored: every rule of
122+ * validateConfig except the ones about hits. */
123+export function designIssues(cfg: SimulationConfig): Issue[] {
124+ return validateConfig(cfg).filter((i) => i.path !== "hits" && !i.path.startsWith("hits["));
125+}
src/cpu/simulator.tsadded+208−0View file
@@ -0,0 +1,208 @@
1+// Float64 reference time loop with exactly the structure of the GPU kernels: an element pass
2+// (forces, sigma1 term, radiation accumulation), a hit pass (contact forces, release), a node
3+// pass (gather, contact, lagged rigid-mode projection, leapfrog update, partial sums for the
4+// next projection), and the exciter update. Relative to cymsimweb, this copy adds a prescribed
5+// initial state, a distributed harmonic drive on the transverse DOFs, and snapshots of the state
6+// projected onto a set of transverse modes.
7+import { elementForces, strainEnergies } from "../model/forces.ts";
8+import { HOLD_HEIGHT } from "../model/exciter.ts";
9+import { DECIMATION_SPAN } from "../model/radiation.ts";
10+import type { Setup } from "../model/setup.ts";
11+import type { BackendOptions, ChunkResult, Diagnostics, SimulatorBackend } from "../model/simulator.ts";
12+
13+export class CpuSimulator implements SimulatorBackend {
14+ readonly kind = "cpu" as const;
15+ private n = 0; // sub-step counter
16+ private readonly q: Float64Array; private readonly v: Float64Array; private readonly nodeAcc: Float64Array;
17+ private readonly vwPrev: Float64Array; // projected transverse velocity of the previous step (for the radiated acceleration)
18+ private readonly ef: Float64Array; private readonly fc: Float64Array;
19+ private readonly zs: Float64Array; private readonly vs: Float64Array; private readonly Fk: Float64Array; private readonly F: Float64Array;
20+ private readonly Fmax: Float64Array; private readonly ncont: Uint32Array;
21+ private partialsPrev = new Float64Array(12); private partialsNew = new Float64Array(12); // [0,6): Rm.(M v), [6,12): Rm.(M q)
22+ private readonly acc: Float64Array; // (E, DECIMATION_SPAN) decimation accumulators
23+ private readonly history: Float64Array;
24+ private pending: { j: number; front: number; back: number }[] = [];
25+ samplesDone = 0;
26+ readonly maxChunk = 8192;
27+ readonly setup: Setup; readonly opts: BackendOptions;
28+
29+ constructor(setup: Setup, opts: BackendOptions = {}) {
30+ this.setup = setup; this.opts = opts;
31+ const N = setup.mesh.n, E = setup.mesh.e, K = setup.schedule.K, P = setup.schedule.P;
32+ this.q = new Float64Array(5 * N); this.v = new Float64Array(5 * N); this.nodeAcc = new Float64Array(N); this.vwPrev = new Float64Array(N);
33+ this.ef = new Float64Array(15 * E); this.fc = new Float64Array(N);
34+ this.zs = new Float64Array(K).fill(HOLD_HEIGHT); this.vs = new Float64Array(K);
35+ this.Fk = new Float64Array(K * P); this.F = new Float64Array(K); this.Fmax = new Float64Array(K); this.ncont = new Uint32Array(K);
36+ this.acc = new Float64Array(E * DECIMATION_SPAN); this.history = new Float64Array(E * setup.radiation.ringLength);
37+ }
38+
39+ setState(q: Float64Array, v: Float64Array) {
40+ this.q.set(q); this.v.set(v);
41+ const N = this.setup.mesh.n;
42+ for (let nd = 0; nd < N; nd++) this.vwPrev[nd] = v[5 * nd + 2];
43+ }
44+
45+ private step() {
46+ const { shell: sh, mesh, schedule: sc, radiation: rad, dt, substeps: m } = this.setup;
47+ const { tri } = mesh, N = mesh.n, E = mesh.e, n = this.n;
48+ const sigma0 = this.setup.config.material.sigma0;
49+ // ---- element pass
50+ elementForces(sh, this.q, this.v, this.ef);
51+ if (n >= 1) {
52+ const s = n - 1, j0 = Math.floor(s / m), kk = s - j0 * m, W = DECIMATION_SPAN, taps = rad.taps;
53+ if (kk === 0 && j0 > 0) this.finalizeSample(j0 - W / 2); // slot 0 = sample j0 - (W/2 - 1) of the previous j0
54+ for (let e = 0; e < E; e++) {
55+ const a = (this.nodeAcc[tri[3 * e]] + this.nodeAcc[tri[3 * e + 1]] + this.nodeAcc[tri[3 * e + 2]]) / 3;
56+ for (let w = 0; w < W; w++) this.acc[e * W + w] += a * taps[w * m + kk];
57+ }
58+ }
59+ // ---- hit pass
60+ this.fc.fill(0);
61+ for (let k = 0; k < sc.K; k++) {
62+ let wmax = -Infinity;
63+ const wp = new Float64Array(sc.P);
64+ for (let p = 0; p < sc.P; p++) {
65+ const b = (k * sc.P + p) * 3;
66+ let w = sc.offsets[k * sc.P + p];
67+ for (let i = 0; i < 3; i++) w += sc.weights[b + i] * this.q[5 * sc.nodes[b + i] + 2];
68+ wp[p] = w; wmax = Math.max(wmax, w);
69+ }
70+ if (n === sc.releaseStep[k]) { this.zs[k] = wmax + sc.gap[k]; this.vs[k] = -sc.velocity[k]; }
71+ const active = n >= sc.releaseStep[k];
72+ let Ftot = 0;
73+ for (let p = 0; p < sc.P; p++) {
74+ const delta = Math.max(wp[p] - this.zs[k], 0);
75+ const f = active ? sc.kPoint[k] * Math.pow(delta, sc.alpha[k]) : 0;
76+ this.Fk[k * sc.P + p] = f; Ftot += f;
77+ if (f > 0) { const b = (k * sc.P + p) * 3; for (let i = 0; i < 3; i++) this.fc[sc.nodes[b + i]] -= f * sc.weights[b + i]; }
78+ }
79+ this.F[k] = Ftot;
80+ }
81+ // ---- drive: distributed transverse force fNode * env * sin(omega t) added with the contacts
82+ const drv = this.opts.drive;
83+ if (drv && n < drv.stopStep) {
84+ const env = Math.min(n / drv.rampSteps, 1) * Math.sin(drv.omega * dt * n);
85+ for (let nd = 0; nd < N; nd++) this.fc[nd] += drv.fNode[nd] * env;
86+ }
87+ // ---- node pass
88+ const { M, Minv, Rm, adjOffsets, adjEntries } = sh, L = 5 * N, c = this.partialsPrev;
89+ this.partialsNew.fill(0);
90+ const f = new Float64Array(5);
91+ for (let nd = 0; nd < N; nd++) {
92+ f.fill(0);
93+ for (let a = adjOffsets[nd]; a < adjOffsets[nd + 1]; a++) {
94+ const ent = adjEntries[a], e = (ent / 3) | 0, i = ent % 3;
95+ for (let d = 0; d < 5; d++) f[d] += this.ef[15 * e + 5 * i + d];
96+ }
97+ f[2] += this.fc[nd];
98+ for (let d = 0; d < 5; d++) {
99+ const idx = 5 * nd + d;
100+ // lagged projection: remove the rigid content that the previous step's update introduced
101+ let vd = this.v[idx], qd = this.q[idx];
102+ for (let k = 0; k < 6; k++) { const r = Rm[k * L + idx]; vd -= c[k] * r; qd -= c[6 + k] * r; }
103+ const v1 = (vd + dt * f[d] * Minv[idx]) / (1 + 2 * sigma0 * dt);
104+ // radiated acceleration from consecutive projected velocities, so that the rigid increment
105+ // that the lagged projection removes one step later never radiates
106+ if (d === 2) { this.nodeAcc[nd] = (vd - this.vwPrev[nd]) / dt; this.vwPrev[nd] = vd; }
107+ const q1 = qd + dt * v1;
108+ this.q[idx] = q1; this.v[idx] = v1;
109+ for (let k = 0; k < 6; k++) { const r = Rm[k * L + idx] * M[idx]; this.partialsNew[k] += r * v1; this.partialsNew[6 + k] += r * q1; }
110+ }
111+ }
112+ [this.partialsPrev, this.partialsNew] = [this.partialsNew, this.partialsPrev];
113+ // ---- exciters
114+ for (let k = 0; k < sc.K; k++) {
115+ this.vs[k] += (dt * this.F[k]) / sc.mass[k]; this.zs[k] += dt * this.vs[k];
116+ this.Fmax[k] = Math.max(this.Fmax[k], this.F[k]); if (this.F[k] > 0) this.ncont[k]++;
117+ }
118+ this.n++;
119+ }
120+
121+ /** Slot 0 (audio sample j) of the accumulators is complete: store it, shift the slots, evaluate the listener pressure. */
122+ private finalizeSample(j: number) {
123+ const { radiation: rad, mesh } = this.setup, E = mesh.e, D = rad.ringLength, W = DECIMATION_SPAN;
124+ for (let e = 0; e < E; e++) {
125+ if (j >= 0) this.history[e * D + (j % D)] = this.acc[e * W];
126+ for (let w = 0; w < W - 1; w++) this.acc[e * W + w] = this.acc[e * W + w + 1];
127+ this.acc[e * W + W - 1] = 0;
128+ }
129+ if (j < 0) return;
130+ let front = 0, back = 0;
131+ for (let e = 0; e < E; e++) {
132+ const jf = j - rad.i0F[e], jb = j - rad.i0B[e];
133+ if (jf >= 1) front += rad.coefF[e] * (this.history[e * D + (jf % D)] * (1 - rad.frF[e]) + this.history[e * D + ((jf - 1) % D)] * rad.frF[e]);
134+ if (jb >= 1) back += rad.coefB[e] * (this.history[e * D + (jb % D)] * (1 - rad.frB[e]) + this.history[e * D + ((jb - 1) % D)] * rad.frB[e]);
135+ }
136+ this.pending.push({ j, front, back });
137+ }
138+
139+ /** Project the current state onto the modes: 2 Nm values, q_m then qdot_m. */
140+ private projectNow(): Float32Array {
141+ const pr = this.opts.projection!;
142+ const N = this.setup.mesh.n, Nm = pr.nModes, out = new Float32Array(2 * Nm);
143+ for (let m = 0; m < Nm; m++) {
144+ let sq = 0, sv = 0;
145+ const row = m * N;
146+ for (let nd = 0; nd < N; nd++) { const p = pr.phiM[row + nd]; sq += p * this.q[5 * nd + 2]; sv += p * this.v[5 * nd + 2]; }
147+ out[m] = sq; out[Nm + m] = sv;
148+ }
149+ return out;
150+ }
151+
152+ async runChunk(nSamples: number): Promise<ChunkResult> {
153+ const m = this.setup.substeps, N = this.setup.mesh.n, frames: Float32Array[] = [], frameSamples: number[] = [];
154+ const proj: Float32Array[] = [], projSamples: number[] = [];
155+ const movieEvery = this.opts.movieEvery ?? 0, pr = this.opts.projection;
156+ const tm = this.opts.traceMode, tracing = pr && tm !== undefined;
157+ const traceBase = this.samplesDone;
158+ const trace = new Float32Array(tracing ? nSamples : 0);
159+ for (let s = 0; s < nSamples * m; s++) {
160+ if (movieEvery > 0 && this.n % (m * movieEvery) === 0) {
161+ const w = new Float32Array(N);
162+ for (let nd = 0; nd < N; nd++) w[nd] = this.q[5 * nd + 2];
163+ frames.push(w); frameSamples.push(this.n / m);
164+ }
165+ if (pr && this.n % (m * pr.every) === 0) { proj.push(this.projectNow()); projSamples.push(this.n / m); }
166+ if (tracing && this.n % m === 0) {
167+ let sq = 0;
168+ const row = tm! * N;
169+ for (let nd = 0; nd < N; nd++) sq += pr!.phiM[row + nd] * this.q[5 * nd + 2];
170+ trace[this.n / m - traceBase] = sq;
171+ }
172+ this.step();
173+ }
174+ this.samplesDone += nSamples;
175+ const out = this.pending; this.pending = [];
176+ const firstSample = out.length ? out[0].j : this.samplesDone;
177+ return { firstSample, pressureFront: Float32Array.from(out, (o) => o.front), pressureBack: Float32Array.from(out, (o) => o.back),
178+ frames, frameSamples, proj, projSamples, trace, traceBase };
179+ }
180+
181+ async diagnostics(): Promise<Diagnostics> {
182+ const { shell: sh, schedule: sc, dt } = this.setup, N = sh.mesh.n;
183+ let ke = 0, woh = 0;
184+ for (let i = 0; i < 5 * N; i++) ke += 0.5 * sh.M[i] * this.v[i] * this.v[i];
185+ for (let nd = 0; nd < N; nd++) woh = Math.max(woh, Math.abs(this.q[5 * nd + 2] / sh.hNode[nd]));
186+ const { membrane, bending } = strainEnergies(sh, this.q);
187+ let ex = 0;
188+ for (let k = 0; k < sc.K; k++) ex += 0.5 * sc.mass[k] * this.vs[k] * this.vs[k];
189+ return { time: this.n * dt, kinetic: ke, membrane, bending, exciters: ex, maxWoverH: woh,
190+ hits: Array.from({ length: sc.K }, (_, k) => ({ peakForce: this.Fmax[k], contactTime: this.ncont[k] * dt, velocity: this.vs[k], height: this.zs[k] })) };
191+ }
192+
193+ /** M-weighted rigid-mode content of the current velocity (the lagged projection leaves one step's increment). */
194+ rigidContent(): number {
195+ const { shell: sh } = this.setup, L = 5 * sh.mesh.n;
196+ let worst = 0;
197+ for (let k = 0; k < 6; k++) { let c = 0; for (let i = 0; i < L; i++) c += sh.Rm[k * L + i] * sh.M[i] * this.v[i]; worst = Math.max(worst, Math.abs(c)); }
198+ return worst;
199+ }
200+
201+ async displacement(): Promise<Float32Array> {
202+ const N = this.setup.mesh.n, w = new Float32Array(N);
203+ for (let nd = 0; nd < N; nd++) w[nd] = this.q[5 * nd + 2];
204+ return w;
205+ }
206+
207+ dispose() {}
208+}
src/derived.tsadded+52−0View file
@@ -0,0 +1,52 @@
1+// The parameters of the modal bank for the configuration currently being designed.
2+//
3+// A modes file is computed for one shape at a reference material and listener. The material and
4+// the damping enter analytically (see precompute/pipeline.ts), so they are applied here, on every
5+// slider move, without touching the eigensolve. The listener enters through the gains, which are
6+// a separate and slower computation done in the worker.
7+import type { SimulationConfig } from "./config/schema.ts";
8+import type { ModesData } from "./modes.ts";
9+
10+export interface Derived {
11+ freq: Float64Array; // [Hz]
12+ sigma: Float64Array; // [1/s]
13+ phiFactor: number; // mode-shape rescaling for a density change
14+ overdamped: number; // modes whose loss had to be clamped below critical
15+ t60Low: number; // 60 dB decay of the lowest mode [s]
16+ t60High: number; // 60 dB decay of the highest mode [s]
17+}
18+
19+export function derive(data: ModesData, cfg: SimulationConfig): Derived {
20+ const { E0, rho0 } = data.meta;
21+ const sE = cfg.material.E / E0, sRho = cfg.material.rho / rho0;
22+ const fScale = Math.sqrt(sE / sRho);
23+ const Nm = data.nModes;
24+ const freq = new Float64Array(Nm), sigma = new Float64Array(Nm);
25+ let overdamped = 0;
26+ for (let m = 0; m < Nm; m++) {
27+ const f = data.freq[m] * fScale;
28+ freq[m] = f;
29+ const s = cfg.material.sigma0 + cfg.material.sigma1 * data.dampQ[m];
30+ const crit = 0.999 * 2 * Math.PI * f;
31+ if (s > crit) overdamped++;
32+ sigma[m] = Math.min(s, crit);
33+ }
34+ return {
35+ freq, sigma, phiFactor: 1 / Math.sqrt(sRho), overdamped,
36+ t60Low: 6.907755 / sigma[0],
37+ t60High: 6.907755 / sigma[Nm - 1],
38+ };
39+}
40+
41+/** The live quantities the gains depend on, as a comparable string. The gains are a sum over
42+ * elements at the mode frequencies, so they go stale when the listener moves and when the
43+ * material changes the frequencies. */
44+export function gainState(cfg: SimulationConfig): string {
45+ return JSON.stringify([cfg.material.E, cfg.material.rho, cfg.listener]);
46+}
47+
48+/** The same string for the state a freshly loaded modes file was computed at. */
49+export function storedGainState(data: ModesData): string {
50+ const stored = data.meta.config as unknown as SimulationConfig;
51+ return JSON.stringify([data.meta.E0, data.meta.rho0, stored.listener]);
52+}
src/design.tsadded+179−0View file
@@ -0,0 +1,179 @@
1+// The design panel: every parameter of the cymbal that the player exposes, in two tiers.
2+//
3+// A "live" parameter enters the modal bank analytically and takes effect while the slider moves:
4+// the two loss coefficients, Young's modulus, the density and the listener. A "structural"
5+// parameter changes the eigenproblem itself, so editing it marks the modes stale and the user
6+// presses Recompute. The tiers are explained in precompute/pipeline.ts.
7+//
8+// Values are stored in SI, as the cymsim schema has them, and displayed in whatever unit reads
9+// best (millimetres for the geometry, gigapascals for the modulus), through a per-field factor.
10+import type { SimulationConfig } from "./config/schema.ts";
11+
12+export type Tier = "live" | "structural";
13+
14+export interface Field {
15+ section: "material" | "profile" | "imperfections" | "listener";
16+ key: string;
17+ index?: number; // component of a vector field (the listener position)
18+ label: string;
19+ unit: string;
20+ scale: number; // displayed = stored * scale
21+ min: number; // in displayed units
22+ max: number;
23+ step: number;
24+ tier: Tier;
25+ title?: string;
26+}
27+
28+const f = (section: Field["section"], key: string, label: string, unit: string, scale: number,
29+ min: number, max: number, step: number, tier: Tier, title?: string): Field =>
30+ ({ section, key, label, unit, scale, min, max, step, tier, title });
31+
32+export const GROUPS: { name: string; note: string; fields: Field[] }[] = [
33+ {
34+ name: "Sustain and material",
35+ note: "live: these change the bank without a new eigensolve",
36+ fields: [
37+ f("material", "sigma0", "uniform loss", "1/s", 1, 0.05, 20, 0.05, "live",
38+ "frequency-independent decay; the 60 dB time of the low modes is about 6.9 / sigma0"),
39+ f("material", "sigma1", "loss rise with frequency", "1e-4 m^2/s", 1e4, 0, 60, 0.25, "live",
40+ "adds 2 sigma1 k^2 to the decay rate, so high modes die first; this sets how dark the tail is"),
41+ f("material", "E", "Young's modulus", "GPa", 1e-9, 20, 260, 1, "live",
42+ "scales every frequency as sqrt(E); B20 bronze is 110 GPa"),
43+ f("material", "rho", "density", "kg/m^3", 1, 2000, 12000, 50, "live",
44+ "scales every frequency as 1/sqrt(rho); B20 bronze is 8700"),
45+ f("material", "nu", "Poisson ratio", "", 1, 0.0, 0.48, 0.01, "structural"),
46+ ],
47+ },
48+ {
49+ name: "Size and profile",
50+ note: "structural: a change needs the modes recomputed",
51+ fields: [
52+ f("profile", "radius", "radius", "mm", 1e3, 100, 400, 1, "structural", "18 inch is 228.6 mm"),
53+ f("profile", "h_rim", "thickness at the rim", "mm", 1e3, 0.3, 4, 0.01, "structural",
54+ "the single strongest control on pitch and weight"),
55+ f("profile", "h_shoulder", "thickness at the shoulder", "mm", 1e3, 0.3, 5, 0.01, "structural"),
56+ f("profile", "h_bell", "thickness in the bell", "mm", 1e3, 0.3, 6, 0.01, "structural"),
57+ f("profile", "bow_height", "bow rise", "mm", 1e3, 0, 80, 0.5, "structural",
58+ "height of the shoulder above the rim; more curvature stiffens the low modes"),
59+ f("profile", "bow_power", "bow shape", "", 1, 0.5, 4, 0.05, "structural",
60+ "1 is a cone; above 1 the bow curves near the rim and flattens at the shoulder"),
61+ f("profile", "bell_height", "bell height", "mm", 1e3, 0, 40, 0.5, "structural"),
62+ f("profile", "bell_top_radius", "bell plateau radius", "mm", 1e3, 5, 60, 1, "structural"),
63+ f("profile", "bell_shoulder_radius", "shoulder radius", "mm", 1e3, 20, 150, 1, "structural"),
64+ ],
65+ },
66+ {
67+ name: "Hammering and asymmetry",
68+ note: "structural: these break the symmetry and give the cymbal its character",
69+ fields: [
70+ f("imperfections", "seed", "seed", "", 1, 0, 999, 1, "structural", "a different draw of the same statistics"),
71+ f("imperfections", "n_dents", "hammer marks", "", 1, 0, 1500, 10, "structural"),
72+ f("imperfections", "dent_depth", "mark depth", "mm", 1e3, 0, 1.5, 0.01, "structural"),
73+ f("imperfections", "dent_sigma", "mark width", "mm", 1e3, 2, 30, 0.5, "structural",
74+ "keep this at or above the playback element size or the mesh cannot see the marks"),
75+ f("imperfections", "rim_warp", "rim warp", "mm", 1e3, 0, 5, 0.05, "structural",
76+ "low azimuthal harmonics that split the degenerate mode pairs"),
77+ f("imperfections", "shoulder_ovality", "shoulder ovality", "", 1, 0, 0.25, 0.005, "structural"),
78+ f("imperfections", "bell_offset", "bell off-centre", "mm", 1e3, 0, 10, 0.1, "structural"),
79+ ],
80+ },
81+ {
82+ name: "Listener",
83+ note: "live: the gains are redone in the background, in a fraction of a second",
84+ fields: [
85+ { ...f("listener", "position", "listener x", "m", 1, -3, 3, 0.05, "live"), index: 0 },
86+ { ...f("listener", "position", "listener y", "m", 1, -3, 3, 0.05, "live"), index: 1 },
87+ { ...f("listener", "position", "listener z", "m", 1, -3, 3, 0.05, "live"), index: 2 },
88+ f("listener", "back_diffraction_gain", "back-side cancellation", "", 1, 0, 1.5, 0.05, "live",
89+ "0 is a baffled plate, 1 the full rim-diffraction estimate; raising it thins the low end"),
90+ f("listener", "c_air", "speed of sound", "m/s", 1, 300, 380, 1, "live"),
91+ f("listener", "rho_air", "air density", "kg/m^3", 1, 0.7, 1.6, 0.01, "live"),
92+ ],
93+ },
94+];
95+
96+export const ALL_FIELDS = GROUPS.flatMap((g) => g.fields);
97+
98+export function getValue(cfg: SimulationConfig, field: Field): number {
99+ const section = cfg[field.section] as unknown as Record<string, number | number[]>;
100+ const v = section[field.key];
101+ return field.index === undefined ? (v as number) : (v as number[])[field.index];
102+}
103+
104+export function setValue(cfg: SimulationConfig, field: Field, value: number): void {
105+ const section = cfg[field.section] as unknown as Record<string, number | number[]>;
106+ if (field.index === undefined) section[field.key] = value;
107+ else (section[field.key] as number[])[field.index] = value;
108+}
109+
110+function fmt(v: number, step: number): string {
111+ const digits = Math.max(0, Math.min(6, Math.ceil(-Math.log10(step) + 0.001)));
112+ return v.toFixed(digits);
113+}
114+
115+/** Build the panel. ``onChange`` fires on every input event with the tier that changed. */
116+export class DesignPanel {
117+ private rows = new Map<Field, { range: HTMLInputElement; num: HTMLInputElement }>();
118+ private cfg: () => SimulationConfig;
119+ private onChange: (field: Field, tier: Tier) => void;
120+
121+ constructor(container: HTMLElement, cfg: () => SimulationConfig, onChange: (field: Field, tier: Tier) => void) {
122+ this.cfg = cfg;
123+ this.onChange = onChange;
124+ for (const group of GROUPS) {
125+ const box = document.createElement("details");
126+ box.className = "group";
127+ const sum = document.createElement("summary");
128+ sum.textContent = group.name;
129+ box.appendChild(sum);
130+ const note = document.createElement("div");
131+ note.className = "note";
132+ note.textContent = group.note;
133+ box.appendChild(note);
134+ for (const field of group.fields) {
135+ box.appendChild(this.row(field));
136+ }
137+ container.appendChild(box);
138+ }
139+ }
140+
141+ private row(field: Field): HTMLElement {
142+ const row = document.createElement("div");
143+ row.className = `field ${field.tier}`;
144+ const label = document.createElement("label");
145+ label.textContent = field.label + (field.unit ? ` [${field.unit}]` : "");
146+ if (field.title) label.title = field.title;
147+ const range = document.createElement("input");
148+ range.type = "range";
149+ range.min = String(field.min);
150+ range.max = String(field.max);
151+ range.step = String(field.step);
152+ const num = document.createElement("input");
153+ num.type = "number";
154+ num.min = String(field.min);
155+ num.max = String(field.max);
156+ num.step = String(field.step);
157+ const apply = (raw: number, from: HTMLInputElement) => {
158+ const v = Math.min(field.max, Math.max(field.min, raw));
159+ setValue(this.cfg(), field, v / field.scale);
160+ if (from !== range) range.value = String(v);
161+ if (from !== num) num.value = fmt(v, field.step);
162+ this.onChange(field, field.tier);
163+ };
164+ range.addEventListener("input", () => apply(Number(range.value), range));
165+ num.addEventListener("change", () => apply(Number(num.value), num));
166+ row.append(label, range, num);
167+ this.rows.set(field, { range, num });
168+ return row;
169+ }
170+
171+ /** Push the configuration into the inputs (after loading a preset or a YAML file). */
172+ refresh(): void {
173+ for (const [field, els] of this.rows) {
174+ const v = getValue(this.cfg(), field) * field.scale;
175+ els.range.value = String(v);
176+ els.num.value = fmt(v, field.step);
177+ }
178+ }
179+}
src/drive.tsadded+93−0View file
@@ -0,0 +1,93 @@
1+// Turning a selected linear mode into something the nonlinear simulator can run.
2+//
3+// Free decay: the shell is released from the mode shape at a chosen amplitude (given as the peak
4+// transverse displacement at the antinode, in units of the local thickness) and rings down. The
5+// in-plane displacements and rotations of the initial state come from the static condensation
6+// (the worker's "complement" request), so the released state is the complete linearised mode and
7+// not a pure-w field that would also launch fast membrane and rotation transients.
8+//
9+// Forcing: a distributed force F_n = Gamma * Mw_n * phi_n * env(t) * sin(omega_d t). Weighting by
10+// the transverse lumped mass makes the force M-orthogonal to every other mode, so in linear
11+// theory only the driven mode responds; whatever else appears in the nonlinear run got there
12+// through the coupling. Gamma is chosen so that the *linear* steady-state amplitude at resonance
13+// equals the requested one: Gamma = q_target * 2 sigma omega. With the damping scaled down this
14+// target is exceeded (at zero damping the linear response grows without bound), which is stated
15+// in the UI rather than hidden.
16+import type { ModesData } from "./modes.ts";
17+import type { DriveParams, ProjectionSpec } from "./model/simulator.ts";
18+
19+export type Mechanism = "decay" | "forced";
20+
21+export interface DriveSpec {
22+ mode: number; // index into the modes file
23+ mechanism: Mechanism;
24+ amplitude: number; // peak w at the antinode, in units of the local thickness
25+ detuneCents: number; // forced: drive frequency offset from the mode frequency
26+ rampTime: number; // forced: seconds of linear onset
27+ driveTime: number; // forced: drive stops after this many seconds (free decay afterwards)
28+}
29+
30+/** Mode shape m on the nodes, dequantised, times ``factor`` (the density rescaling phiFactor). */
31+export function modeShape(modes: ModesData, m: number, factor = 1): Float64Array {
32+ const N = modes.nNodes, Nm = modes.nModes, out = new Float64Array(N);
33+ const s = modes.phiScale[m] * factor;
34+ for (let n = 0; n < N; n++) out[n] = modes.phiQ[n * Nm + m] * s;
35+ return out;
36+}
37+
38+/** Node of largest |phi|. */
39+export function antinode(shape: Float64Array): { node: number; absPhi: number } {
40+ let node = 0, absPhi = 0;
41+ for (let n = 0; n < shape.length; n++) if (Math.abs(shape[n]) > absPhi) { absPhi = Math.abs(shape[n]); node = n; }
42+ return { node, absPhi };
43+}
44+
45+/** Modal-coordinate amplitude that puts the antinode at ``woverh`` thicknesses. */
46+export function modalAmplitude(modes: ModesData, shape: Float64Array, woverh: number): number {
47+ const { node, absPhi } = antinode(shape);
48+ return (woverh * modes.h[node]) / absPhi;
49+}
50+
51+/** Initial state (q, v) of the free decay: w = a phi, in-plane and rotations from the complement
52+ * o (node-major u, v, thx, thy per node, computed for the *unit* shape), velocities zero. */
53+export function modalInitialState(shape: Float64Array, o: Float64Array, a: number): { q: Float64Array; v: Float64Array } {
54+ const N = shape.length, q = new Float64Array(5 * N), v = new Float64Array(5 * N);
55+ for (let n = 0; n < N; n++) {
56+ q[5 * n] = a * o[4 * n];
57+ q[5 * n + 1] = a * o[4 * n + 1];
58+ q[5 * n + 2] = a * shape[n];
59+ q[5 * n + 3] = a * o[4 * n + 2];
60+ q[5 * n + 4] = a * o[4 * n + 3];
61+ }
62+ return { q, v };
63+}
64+
65+/** The distributed drive of the forced mechanism. ``Mw`` is the transverse lumped mass of the
66+ * simulator's shell (current material), ``sigma`` the decay rate used for the amplitude target,
67+ * ``omegaMode`` the (current-material) angular frequency of the mode. */
68+export function buildDrive(spec: DriveSpec, modes: ModesData, shape: Float64Array, Mw: Float64Array,
69+ sigma: number, omegaMode: number, dt: number): DriveParams {
70+ const qTarget = modalAmplitude(modes, shape, spec.amplitude);
71+ const gamma = qTarget * 2 * Math.max(sigma, 1e-6) * omegaMode;
72+ const N = shape.length, fNode = new Float64Array(N);
73+ for (let n = 0; n < N; n++) fNode[n] = gamma * Mw[n] * shape[n];
74+ const omega = omegaMode * Math.pow(2, spec.detuneCents / 1200);
75+ return {
76+ fNode, omega,
77+ rampSteps: Math.max(1, Math.round(spec.rampTime / dt)),
78+ stopStep: Math.max(1, Math.round(spec.driveTime / dt)),
79+ };
80+}
81+
82+/** The projection matrix (mode-major, mass-weighted): with mass-normalised shapes and the same
83+ * shell mass, q_m = sum_n phiM[m N + n] w_n is the modal coordinate. ``Mw`` must be the
84+ * transverse lumped mass of the shell actually being simulated. */
85+export function buildProjection(modes: ModesData, Mw: Float64Array, factor: number, every: number): ProjectionSpec {
86+ const N = modes.nNodes, Nm = modes.nModes;
87+ const phiM = new Float32Array(Nm * N);
88+ for (let m = 0; m < Nm; m++) {
89+ const s = modes.phiScale[m] * factor, row = m * N;
90+ for (let n = 0; n < N; n++) phiM[row + n] = modes.phiQ[n * Nm + m] * s * Mw[n];
91+ }
92+ return { phiM, nModes: Nm, every };
93+}
src/gpu/shaders.tsadded+327−0View file
@@ -0,0 +1,327 @@
1+// WGSL kernels of the time loop, as in cymsimweb. Entry points: the element pass (forces, sigma1
2+// term, contact forces of the hits, decimation accumulators of the radiated acceleration), the
3+// node pass (gather, contact, distributed drive, lagged rigid-mode projection, leapfrog update,
4+// partial sums, exciter update), the gather pass (retarded-time sum at the listener once per
5+// audio sample), the capture pass (displacement movie) and, new here, the projection pass, which
6+// contracts the transverse displacement and velocity with a mass-weighted mode matrix and writes
7+// one modal snapshot. Sizes and constants are baked into the source; K = 0 (no hits) is allowed.
8+
9+export interface ShaderConstants {
10+ N: number; E: number; K: number; P: number; M: number; W: number; D: number; G: number;
11+ dt: number; sigma0: number; nl: number;
12+ Nm: number; // modes in the projection (1 when unused)
13+ driveOmega: number; driveRampSteps: number; driveStopStep: number; // stopStep 0 disables
14+ traceMode: number; // mode traced at the audio rate; -1 disables
15+}
16+
17+export const ELEM_STRIDE = 96; // Gx[3] Gy[3] A z0x z0y C11 C12 C33 cd Ke[81] pad[2]
18+export const NODE_STRIDE = 40; // Minv[5] M[5] Rm[6][5]
19+export const HIT_HEADER = 12; // kPoint alpha mass gap velocity zs vs F Fmax pad[3]
20+export const HITU_HEADER = 4; // releaseStep ncont pad[2]
21+export const SYNC_HEADER = 4; // stepA stepB pad[2], then partials[2][G][12]
22+export const WORKGROUP_NODES = 256;
23+export const CHUNK_UNIFORM_BYTES = 32;
24+
25+function f(x: number): string {
26+ const s = x.toPrecision(9);
27+ return /[.e]/.test(s) ? s : s + ".0";
28+}
29+
30+export function shaderSource(c: ShaderConstants): string {
31+ const HF = HIT_HEADER + 5 * c.P, HU = HITU_HEADER + 3 * c.P;
32+ const PA = Math.max(1, c.P); // array<f32, 0> is invalid WGSL; the hit branch never runs when K = 0
33+ return /* wgsl */ `
34+const N: u32 = ${c.N}u; const E: u32 = ${c.E}u; const K: u32 = ${c.K}u; const P: u32 = ${c.P}u;
35+const M: u32 = ${c.M}u; const W: u32 = ${c.W}u; const D: u32 = ${c.D}u; const G: u32 = ${c.G}u;
36+const DT: f32 = ${f(c.dt)}; const DAMP0: f32 = ${f(1 / (1 + 2 * c.sigma0 * c.dt))}; const NL: f32 = ${f(c.nl)};
37+const EF: u32 = ${ELEM_STRIDE}u; const NF: u32 = ${NODE_STRIDE}u; const HF: u32 = ${HF}u; const HU: u32 = ${HU}u;
38+const NM: u32 = ${Math.max(1, c.Nm)}u; const TRACE_MODE: u32 = ${Math.max(0, c.traceMode)}u;
39+const DRV_STOP: u32 = ${Math.max(0, Math.floor(c.driveStopStep))}u;
40+const DRV_OMDT: f32 = ${f(c.driveOmega * c.dt)}; const DRV_RAMP: f32 = ${f(Math.max(1, c.driveRampSteps))};
41+const OFF_V: u32 = 5u * N; const OFF_ACC: u32 = 10u * N; const OFF_VWP: u32 = 11u * N;
42+const OFF_HIST: u32 = E * W; const OFF_TAPS: u32 = EF * E; const OFF_PART: u32 = ${SYNC_HEADER}u;
43+
44+struct Chunk { base: i32, size: u32, frameBase: u32, every: u32, projBase: u32, projEvery: u32, phase0: f32, step0: u32 }
45+
46+// ------------------------------------------------------------------ element pass
47+@group(0) @binding(0) var<storage, read> elemF: array<f32>;
48+@group(0) @binding(1) var<storage, read> elemU: array<u32>;
49+@group(0) @binding(2) var<storage, read> stateR: array<f32>;
50+@group(0) @binding(3) var<storage, read_write> elemForce: array<f32>;
51+@group(0) @binding(4) var<storage, read_write> hitsF: array<f32>;
52+@group(0) @binding(5) var<storage, read> hitsUR: array<u32>;
53+@group(0) @binding(6) var<storage, read_write> syncE: array<f32>;
54+@group(0) @binding(7) var<storage, read_write> rad: array<f32>;
55+
56+@compute @workgroup_size(64)
57+fn elementPass(@builtin(global_invocation_id) gid: vec3<u32>) {
58+ let id = gid.x;
59+ let step = bitcast<u32>(syncE[0]);
60+ if (id == 0u) { syncE[1] = syncE[0]; }
61+ if (id < E) {
62+ let e = id;
63+ let b = e * EF;
64+ let n0 = elemU[3u * e]; let n1 = elemU[3u * e + 1u]; let n2 = elemU[3u * e + 2u];
65+ var qe: array<f32, 15>;
66+ for (var d = 0u; d < 5u; d++) { qe[d] = stateR[5u * n0 + d]; qe[5u + d] = stateR[5u * n1 + d]; qe[10u + d] = stateR[5u * n2 + d]; }
67+ let gx = vec3<f32>(elemF[b], elemF[b + 1u], elemF[b + 2u]);
68+ let gy = vec3<f32>(elemF[b + 3u], elemF[b + 4u], elemF[b + 5u]);
69+ let A = elemF[b + 6u]; let z0x = elemF[b + 7u]; let z0y = elemF[b + 8u];
70+ let C11 = elemF[b + 9u]; let C12 = elemF[b + 10u]; let C33 = elemF[b + 11u]; let cd = elemF[b + 12u];
71+ let uu = vec3<f32>(qe[0], qe[5], qe[10]); let vv = vec3<f32>(qe[1], qe[6], qe[11]); let ww = vec3<f32>(qe[2], qe[7], qe[12]);
72+ let ux = dot(gx, uu); let uy = dot(gy, uu); let vx = dot(gx, vv); let vy = dot(gy, vv); let wx = dot(gx, ww); let wy = dot(gy, ww);
73+ let ax = NL * wx + z0x; let ay = NL * wy + z0y;
74+ let exx = ux + NL * 0.5 * wx * wx + z0x * wx;
75+ let eyy = vy + NL * 0.5 * wy * wy + z0y * wy;
76+ let gxy = uy + vx + NL * wx * wy + z0x * wy + z0y * wx;
77+ let Nxx = C11 * exx + C12 * eyy; let Nyy = C12 * exx + C11 * eyy; let Nxy = C33 * gxy;
78+ let vw = vec3<f32>(stateR[OFF_V + 5u * n0 + 2u], stateR[OFF_V + 5u * n1 + 2u], stateR[OFF_V + 5u * n2 + 2u]);
79+ let vwx = dot(gx, vw); let vwy = dot(gy, vw);
80+ var out: array<f32, 15>;
81+ for (var i = 0u; i < 3u; i++) {
82+ let g_x = gx[i]; let g_y = gy[i];
83+ out[5u * i] = -A * (Nxx * g_x + Nxy * g_y);
84+ out[5u * i + 1u] = -A * (Nyy * g_y + Nxy * g_x);
85+ out[5u * i + 2u] = -A * (Nxx * ax * g_x + Nyy * ay * g_y + Nxy * (ay * g_x + ax * g_y)) - cd * (g_x * vwx + g_y * vwy);
86+ out[5u * i + 3u] = 0.0; out[5u * i + 4u] = 0.0;
87+ }
88+ var U: array<f32, 9>;
89+ for (var i = 0u; i < 3u; i++) { U[3u * i] = qe[5u * i + 2u]; U[3u * i + 1u] = qe[5u * i + 3u]; U[3u * i + 2u] = qe[5u * i + 4u]; }
90+ for (var i = 0u; i < 9u; i++) {
91+ var s = 0.0;
92+ for (var j = 0u; j < 9u; j++) { s += elemF[b + 13u + 9u * i + j] * U[j]; }
93+ let slot = 5u * (i / 3u) + 2u + (i % 3u);
94+ out[slot] -= s;
95+ }
96+ for (var i = 0u; i < 15u; i++) { elemForce[15u * e + i] = out[i]; }
97+ // decimation accumulators of the radiated acceleration (of the previous step)
98+ if (step >= 1u) {
99+ let s = step - 1u; let j0 = s / M; let kk = s - j0 * M;
100+ if (kk == 0u && j0 > 0u) {
101+ let j = i32(j0) - i32(W / 2u);
102+ if (j >= 0) { rad[OFF_HIST + e * D + (u32(j) % D)] = rad[e * W]; }
103+ for (var w = 0u; w < W - 1u; w++) { rad[e * W + w] = rad[e * W + w + 1u]; }
104+ rad[e * W + W - 1u] = 0.0;
105+ }
106+ let a = (stateR[OFF_ACC + n0] + stateR[OFF_ACC + n1] + stateR[OFF_ACC + n2]) / 3.0;
107+ for (var w = 0u; w < W; w++) { rad[e * W + w] += a * elemF[OFF_TAPS + w * M + kk]; }
108+ }
109+ } else if (id < E + K) {
110+ // contact forces of hit k at this step
111+ let k = id - E; let hf = k * HF; let hu = k * HU;
112+ var wp: array<f32, ${PA}>;
113+ var wmax = -1.0e30;
114+ for (var p = 0u; p < P; p++) {
115+ var w = hitsF[hf + ${HIT_HEADER}u + 3u * P + p];
116+ for (var i = 0u; i < 3u; i++) { w += hitsF[hf + ${HIT_HEADER}u + 3u * p + i] * stateR[5u * hitsUR[hu + ${HITU_HEADER}u + 3u * p + i] + 2u]; }
117+ wp[p] = w; wmax = max(wmax, w);
118+ }
119+ var zs = hitsF[hf + 5u];
120+ if (step == hitsUR[hu]) { zs = wmax + hitsF[hf + 3u]; hitsF[hf + 5u] = zs; hitsF[hf + 6u] = -hitsF[hf + 4u]; }
121+ let isActive = step >= hitsUR[hu];
122+ let kPoint = hitsF[hf]; let alpha = hitsF[hf + 1u];
123+ var F = 0.0;
124+ for (var p = 0u; p < P; p++) {
125+ let delta = max(wp[p] - zs, 0.0);
126+ var fp = 0.0;
127+ if (isActive && delta > 0.0) { fp = kPoint * pow(delta, alpha); }
128+ hitsF[hf + ${HIT_HEADER}u + 4u * P + p] = fp; F += fp;
129+ }
130+ hitsF[hf + 7u] = F;
131+ }
132+}
133+
134+// ------------------------------------------------------------------ node pass
135+@group(1) @binding(0) var<storage, read_write> state: array<f32>;
136+@group(1) @binding(1) var<storage, read> elemForceR: array<f32>;
137+@group(1) @binding(2) var<storage, read> nodeF: array<f32>;
138+@group(1) @binding(3) var<storage, read> adj: array<u32>;
139+@group(1) @binding(4) var<storage, read_write> hitsFN: array<f32>;
140+@group(1) @binding(5) var<storage, read_write> hitsU: array<u32>;
141+@group(1) @binding(6) var<storage, read_write> sync: array<f32>;
142+@group(1) @binding(7) var<storage, read> driveF: array<f32>;
143+@group(1) @binding(8) var<uniform> chunkD: Chunk;
144+
145+var<workgroup> red: array<f32, ${12 * WORKGROUP_NODES}>;
146+
147+@compute @workgroup_size(${WORKGROUP_NODES})
148+fn nodePass(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wid: vec3<u32>) {
149+ let nd = gid.x; let t = lid.x;
150+ let step = bitcast<u32>(sync[1]);
151+ let parity = step & 1u; let prevp = (step + 1u) & 1u;
152+ var c: array<f32, 12>;
153+ for (var k = 0u; k < 12u; k++) {
154+ var s = 0.0;
155+ for (var g = 0u; g < G; g++) { s += sync[OFF_PART + (prevp * G + g) * 12u + k]; }
156+ c[k] = s;
157+ }
158+ var part: array<f32, 12>;
159+ for (var k = 0u; k < 12u; k++) { part[k] = 0.0; }
160+ if (nd < N) {
161+ var fo: array<f32, 5>;
162+ for (var d = 0u; d < 5u; d++) { fo[d] = 0.0; }
163+ let a0 = adj[nd]; let a1 = adj[nd + 1u];
164+ for (var a = a0; a < a1; a++) {
165+ let ent = adj[N + 1u + a]; let e = ent / 3u; let i = ent - 3u * e;
166+ for (var d = 0u; d < 5u; d++) { fo[d] += elemForceR[15u * e + 5u * i + d]; }
167+ }
168+ for (var k = 0u; k < K; k++) {
169+ for (var p = 0u; p < P; p++) {
170+ for (var i = 0u; i < 3u; i++) {
171+ if (hitsU[k * HU + ${HITU_HEADER}u + 3u * p + i] == nd) {
172+ fo[2] -= hitsFN[k * HF + ${HIT_HEADER}u + 4u * P + p] * hitsFN[k * HF + ${HIT_HEADER}u + 3u * p + i];
173+ }
174+ }
175+ }
176+ }
177+ // distributed drive on w: fNode * ramp * sin(omega t), phase carried across chunks in phase0
178+ if (DRV_STOP > 0u && step < DRV_STOP) {
179+ let arg = chunkD.phase0 + f32(step - chunkD.step0) * DRV_OMDT;
180+ let env = min(f32(step) / DRV_RAMP, 1.0);
181+ fo[2] += driveF[nd] * env * sin(arg);
182+ }
183+ let nb = nd * NF;
184+ for (var d = 0u; d < 5u; d++) {
185+ let idx = 5u * nd + d;
186+ var vd = state[OFF_V + idx]; var qd = state[idx];
187+ for (var k = 0u; k < 6u; k++) { let r = nodeF[nb + 10u + 5u * k + d]; vd -= c[k] * r; qd -= c[6u + k] * r; }
188+ let v1 = (vd + DT * fo[d] * nodeF[nb + d]) * DAMP0;
189+ if (d == 2u) { state[OFF_ACC + nd] = (vd - state[OFF_VWP + nd]) / DT; state[OFF_VWP + nd] = vd; }
190+ let q1 = qd + DT * v1;
191+ state[idx] = q1; state[OFF_V + idx] = v1;
192+ let mm = nodeF[nb + 5u + d];
193+ for (var k = 0u; k < 6u; k++) { let r = nodeF[nb + 10u + 5u * k + d] * mm; part[k] += r * v1; part[6u + k] += r * q1; }
194+ }
195+ }
196+ for (var k = 0u; k < 12u; k++) { red[t * 12u + k] = part[k]; }
197+ workgroupBarrier();
198+ for (var s = ${WORKGROUP_NODES / 2}u; s > 0u; s = s >> 1u) {
199+ if (t < s) { for (var k = 0u; k < 12u; k++) { red[t * 12u + k] += red[(t + s) * 12u + k]; } }
200+ workgroupBarrier();
201+ }
202+ if (t == 0u) { for (var k = 0u; k < 12u; k++) { sync[OFF_PART + (parity * G + wid.x) * 12u + k] = red[k]; } }
203+ if (nd == 0u) {
204+ for (var k = 0u; k < K; k++) {
205+ let hf = k * HF; let hu = k * HU;
206+ let F = hitsFN[hf + 7u];
207+ let vs = hitsFN[hf + 6u] + DT * F / hitsFN[hf + 2u];
208+ hitsFN[hf + 6u] = vs; hitsFN[hf + 5u] = hitsFN[hf + 5u] + DT * vs;
209+ hitsFN[hf + 8u] = max(hitsFN[hf + 8u], F);
210+ if (F > 0.0) { hitsU[hu + 1u] = hitsU[hu + 1u] + 1u; }
211+ }
212+ sync[0] = bitcast<f32>(step + 1u);
213+ }
214+}
215+
216+// ------------------------------------------------------------------ gather pass (listener)
217+@group(2) @binding(0) var<storage, read> syncG: array<f32>;
218+@group(2) @binding(1) var<storage, read> radR: array<f32>;
219+@group(2) @binding(2) var<storage, read> radCoef: array<f32>;
220+@group(2) @binding(3) var<storage, read_write> pressure: array<f32>;
221+@group(2) @binding(4) var<uniform> chunk: Chunk;
222+
223+var<workgroup> redF: array<f32, 256>;
224+var<workgroup> redB: array<f32, 256>;
225+
226+@compute @workgroup_size(256)
227+fn gatherPass(@builtin(local_invocation_id) lid: vec3<u32>) {
228+ let t = lid.x;
229+ let step = bitcast<u32>(syncG[0]);
230+ let j = i32((step - 1u) / M) - i32(W / 2u);
231+ var fs = 0.0; var bs = 0.0;
232+ if (j >= 0) {
233+ for (var e = t; e < E; e += 256u) {
234+ let rc = 6u * e;
235+ let jf = j - i32(radCoef[rc + 2u]); let jb = j - i32(radCoef[rc + 4u]);
236+ if (jf >= 1) {
237+ let fr = radCoef[rc + 3u];
238+ fs += radCoef[rc] * (radR[OFF_HIST + e * D + (u32(jf) % D)] * (1.0 - fr) + radR[OFF_HIST + e * D + (u32(jf - 1) % D)] * fr);
239+ }
240+ if (jb >= 1) {
241+ let fr = radCoef[rc + 5u];
242+ bs += radCoef[rc + 1u] * (radR[OFF_HIST + e * D + (u32(jb) % D)] * (1.0 - fr) + radR[OFF_HIST + e * D + (u32(jb - 1) % D)] * fr);
243+ }
244+ }
245+ }
246+ redF[t] = fs; redB[t] = bs;
247+ workgroupBarrier();
248+ for (var s = 128u; s > 0u; s = s >> 1u) {
249+ if (t < s) { redF[t] += redF[t + s]; redB[t] += redB[t + s]; }
250+ workgroupBarrier();
251+ }
252+ if (t == 0u && j >= 0) {
253+ let idx = j - chunk.base;
254+ if (idx >= 0 && u32(idx) < chunk.size) { pressure[u32(idx)] = redF[0]; pressure[chunk.size + u32(idx)] = redB[0]; }
255+ }
256+}
257+
258+// ------------------------------------------------------------------ capture pass (displacement movie)
259+// and projection pass (modal snapshots); they share group 3, with disjoint extra bindings, and
260+// each pipeline's layout covers only the bindings its entry point uses.
261+@group(3) @binding(0) var<storage, read> stateC: array<f32>;
262+@group(3) @binding(1) var<storage, read_write> movie: array<f32>;
263+@group(3) @binding(2) var<uniform> chunkC: Chunk;
264+@group(3) @binding(3) var<storage, read> syncC: array<f32>;
265+@group(3) @binding(4) var<storage, read> phiM: array<f32>;
266+@group(3) @binding(5) var<storage, read_write> projOut: array<f32>;
267+@group(3) @binding(6) var<storage, read_write> traceOut: array<f32>;
268+
269+@compute @workgroup_size(256)
270+fn capturePass(@builtin(global_invocation_id) gid: vec3<u32>) {
271+ let nd = gid.x;
272+ if (nd >= N) { return; }
273+ let step = bitcast<u32>(syncC[0]);
274+ let frame = (step / M) / chunkC.every - chunkC.frameBase;
275+ movie[frame * N + nd] = stateC[5u * nd + 2u];
276+}
277+
278+var<workgroup> redQ: array<f32, 256>;
279+var<workgroup> redV: array<f32, 256>;
280+
281+@compute @workgroup_size(256)
282+fn projPass(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_id) lid: vec3<u32>) {
283+ let m = wid.x; let t = lid.x;
284+ var sq = 0.0; var sv = 0.0;
285+ for (var nd = t; nd < N; nd += 256u) {
286+ let p = phiM[m * N + nd];
287+ sq += p * stateC[5u * nd + 2u];
288+ sv += p * stateC[OFF_V + 5u * nd + 2u];
289+ }
290+ redQ[t] = sq; redV[t] = sv;
291+ workgroupBarrier();
292+ for (var s = 128u; s > 0u; s = s >> 1u) {
293+ if (t < s) { redQ[t] += redQ[t + s]; redV[t] += redV[t + s]; }
294+ workgroupBarrier();
295+ }
296+ if (t == 0u) {
297+ let step = bitcast<u32>(syncC[0]);
298+ let snap = (step / M) / chunkC.projEvery - chunkC.projBase;
299+ projOut[snap * 2u * NM + m] = redQ[0];
300+ projOut[snap * 2u * NM + NM + m] = redV[0];
301+ }
302+}
303+
304+// ------------------------------------------------------------------ trace pass (one mode's
305+// coordinate at the audio rate, one workgroup per audio sample)
306+var<workgroup> redT: array<f32, 256>;
307+
308+@compute @workgroup_size(256)
309+fn tracePass(@builtin(local_invocation_id) lid: vec3<u32>) {
310+ let t = lid.x;
311+ var sq = 0.0;
312+ for (var nd = t; nd < N; nd += 256u) {
313+ sq += phiM[TRACE_MODE * N + nd] * stateC[5u * nd + 2u];
314+ }
315+ redT[t] = sq;
316+ workgroupBarrier();
317+ for (var s = 128u; s > 0u; s = s >> 1u) {
318+ if (t < s) { redT[t] += redT[t + s]; }
319+ workgroupBarrier();
320+ }
321+ if (t == 0u) {
322+ let step = bitcast<u32>(syncC[0]);
323+ traceOut[step / M - chunkC.step0 / M] = redT[0];
324+ }
325+}
326+`;
327+}
src/gpu/simulator.tsadded+299−0View file
@@ -0,0 +1,299 @@
1+// WebGPU time loop: packs the set-up into storage buffers, compiles the kernels and runs the
2+// sub-steps of a chunk of audio samples as one command buffer. Works in a browser and under Node
3+// with the `webgpu` (Dawn) package, which the tests use. Relative to cymsimweb this copy adds a
4+// prescribed initial state, the distributed harmonic drive, and the modal projection pass.
5+import { HOLD_HEIGHT } from "../model/exciter.ts";
6+import { strainEnergies } from "../model/forces.ts";
7+import { DECIMATION_SPAN } from "../model/radiation.ts";
8+import type { Setup } from "../model/setup.ts";
9+import type { BackendOptions, ChunkResult, Diagnostics, SimulatorBackend } from "../model/simulator.ts";
10+import { CHUNK_UNIFORM_BYTES, ELEM_STRIDE, HIT_HEADER, HITU_HEADER, NODE_STRIDE, SYNC_HEADER, WORKGROUP_NODES, shaderSource } from "./shaders.ts";
11+
12+// evaluated lazily: under Node the WebGPU globals are installed after this module is imported
13+const storageUsage = () => GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
14+
15+export class GpuSimulator implements SimulatorBackend {
16+ readonly kind = "gpu" as const;
17+ readonly setup: Setup; readonly device: GPUDevice; readonly opts: BackendOptions;
18+ samplesDone = 0;
19+ private step = 0;
20+ private readonly G: number; private readonly HF: number; private readonly HU: number;
21+ private readonly buffers: Record<string, GPUBuffer> = {};
22+ private readonly pipelines: Record<string, GPUComputePipeline> = {};
23+ private readonly bindGroups: Record<string, GPUBindGroup> = {};
24+ private readonly chunkCapacity: number; readonly movieEvery: number; private readonly framesCapacity: number;
25+ private readonly projEvery: number; private readonly nModes: number; private readonly projCapacity: number;
26+ private readonly tracing: boolean;
27+ private readonly stagingPressure: GPUBuffer; private readonly stagingState: GPUBuffer; private readonly stagingHits: GPUBuffer; private readonly stagingHitsU: GPUBuffer;
28+ private readonly stagingMovie: GPUBuffer | null; private readonly stagingProj: GPUBuffer | null; private readonly stagingTrace: GPUBuffer | null;
29+
30+ constructor(device: GPUDevice, setup: Setup, chunkCapacity = 8192, opts: BackendOptions = {}) {
31+ this.device = device; this.setup = setup; this.opts = opts;
32+ const movieEvery = opts.movieEvery ?? 0;
33+ this.movieEvery = movieEvery;
34+ const Nn = setup.mesh.n;
35+ if (movieEvery > 0) chunkCapacity = Math.max(128, Math.min(chunkCapacity, Math.floor((64e6 / (4 * Nn)) * movieEvery)));
36+ this.chunkCapacity = chunkCapacity;
37+ this.framesCapacity = movieEvery > 0 ? Math.ceil(chunkCapacity / movieEvery) + 2 : 0;
38+ this.projEvery = opts.projection ? opts.projection.every : 0;
39+ this.nModes = opts.projection ? opts.projection.nModes : 0;
40+ this.projCapacity = this.projEvery > 0 ? Math.ceil(chunkCapacity / this.projEvery) + 2 : 0;
41+ this.tracing = !!opts.projection && opts.traceMode !== undefined;
42+ const { mesh, shell: sh, schedule: sc, radiation: rad, substeps: M, dt } = setup;
43+ const N = mesh.n, E = mesh.e, K = sc.K, P = sc.P, W = DECIMATION_SPAN, D = rad.ringLength;
44+ this.G = Math.ceil(N / WORKGROUP_NODES);
45+ this.HF = HIT_HEADER + 5 * P; this.HU = HITU_HEADER + 3 * P;
46+ const G = this.G, HF = this.HF, HU = this.HU;
47+
48+ // ---- pack the set-up
49+ const elemF = new Float32Array(ELEM_STRIDE * E + W * M);
50+ for (let e = 0; e < E; e++) {
51+ const b = ELEM_STRIDE * e;
52+ for (let i = 0; i < 3; i++) { elemF[b + i] = mesh.Gx[3 * e + i]; elemF[b + 3 + i] = mesh.Gy[3 * e + i]; }
53+ elemF[b + 6] = mesh.area[e]; elemF[b + 7] = sh.z0x[e]; elemF[b + 8] = sh.z0y[e];
54+ elemF[b + 9] = sh.Cm[3 * e]; elemF[b + 10] = sh.Cm[3 * e + 1]; elemF[b + 11] = sh.Cm[3 * e + 2];
55+ elemF[b + 12] = 2 * setup.config.material.sigma1 * sh.rhoHA[e];
56+ for (let i = 0; i < 81; i++) elemF[b + 13 + i] = sh.Ke[81 * e + i];
57+ }
58+ elemF.set(rad.taps, ELEM_STRIDE * E);
59+ const elemU = new Uint32Array(3 * E); elemU.set(mesh.tri);
60+ const nodeF = new Float32Array(NODE_STRIDE * N);
61+ for (let n = 0; n < N; n++) {
62+ const b = NODE_STRIDE * n;
63+ for (let d = 0; d < 5; d++) { nodeF[b + d] = sh.Minv[5 * n + d]; nodeF[b + 5 + d] = sh.M[5 * n + d]; }
64+ for (let k = 0; k < 6; k++) for (let d = 0; d < 5; d++) nodeF[b + 10 + 5 * k + d] = sh.Rm[(k * N + n) * 5 + d];
65+ }
66+ const adj = new Uint32Array(N + 1 + sh.adjEntries.length); adj.set(sh.adjOffsets); adj.set(sh.adjEntries, N + 1);
67+ const hitsF = new Float32Array(HF * K), hitsU = new Uint32Array(HU * K);
68+ for (let k = 0; k < K; k++) {
69+ const hf = HF * k, hu = HU * k;
70+ hitsF[hf] = sc.kPoint[k]; hitsF[hf + 1] = sc.alpha[k]; hitsF[hf + 2] = sc.mass[k]; hitsF[hf + 3] = sc.gap[k];
71+ hitsF[hf + 4] = sc.velocity[k]; hitsF[hf + 5] = HOLD_HEIGHT; hitsF[hf + 6] = 0; hitsF[hf + 7] = 0; hitsF[hf + 8] = 0;
72+ for (let p = 0; p < P; p++) {
73+ for (let i = 0; i < 3; i++) { hitsF[hf + HIT_HEADER + 3 * p + i] = sc.weights[(k * P + p) * 3 + i]; hitsU[hu + HITU_HEADER + 3 * p + i] = sc.nodes[(k * P + p) * 3 + i]; }
74+ hitsF[hf + HIT_HEADER + 3 * P + p] = sc.offsets[k * P + p];
75+ }
76+ hitsU[hu] = sc.releaseStep[k]; hitsU[hu + 1] = 0;
77+ }
78+ const radCoef = new Float32Array(6 * E);
79+ for (let e = 0; e < E; e++) {
80+ radCoef[6 * e] = rad.coefF[e]; radCoef[6 * e + 1] = rad.coefB[e]; radCoef[6 * e + 2] = rad.i0F[e];
81+ radCoef[6 * e + 3] = rad.frF[e]; radCoef[6 * e + 4] = rad.i0B[e]; radCoef[6 * e + 5] = rad.frB[e];
82+ }
83+ const mk = (name: string, data: Float32Array | Uint32Array, usage = storageUsage()) => {
84+ const buf = device.createBuffer({ label: name, size: Math.max(16, data.byteLength), usage });
85+ device.queue.writeBuffer(buf, 0, data.buffer, data.byteOffset, data.byteLength);
86+ this.buffers[name] = buf;
87+ return buf;
88+ };
89+ mk("elemF", elemF); mk("elemU", elemU); mk("nodeF", nodeF); mk("adj", adj); mk("radCoef", radCoef);
90+ mk("hitsF", hitsF); mk("hitsU", hitsU);
91+ mk("state", new Float32Array(12 * N)); mk("elemForce", new Float32Array(15 * E));
92+ mk("sync", new Float32Array(SYNC_HEADER + 2 * G * 12)); mk("rad", new Float32Array(E * W + E * D));
93+ mk("pressure", new Float32Array(2 * chunkCapacity));
94+ const driveF = new Float32Array(N);
95+ if (opts.drive) for (let n = 0; n < N; n++) driveF[n] = opts.drive.fNode[n];
96+ mk("driveF", driveF);
97+ mk("phiM", opts.projection ? opts.projection.phiM : new Float32Array(0));
98+ mk("projOut", new Float32Array(Math.max(1, this.projCapacity * 2 * this.nModes)));
99+ mk("traceOut", new Float32Array(this.tracing ? chunkCapacity : 1));
100+ const chunkBuf = device.createBuffer({ label: "chunk", size: CHUNK_UNIFORM_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
101+ this.buffers.chunk = chunkBuf;
102+ const staging = (size: number) => device.createBuffer({ size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
103+ this.stagingPressure = staging(8 * chunkCapacity); this.stagingState = staging(12 * N * 4);
104+ this.stagingHits = staging(Math.max(16, HF * K * 4)); this.stagingHitsU = staging(Math.max(16, HU * K * 4));
105+ if (this.framesCapacity > 0) {
106+ mk("movie", new Float32Array(this.framesCapacity * N));
107+ this.stagingMovie = staging(this.framesCapacity * N * 4);
108+ } else this.stagingMovie = null;
109+ this.stagingProj = this.projCapacity > 0 ? staging(this.projCapacity * 2 * this.nModes * 4) : null;
110+ this.stagingTrace = this.tracing ? staging(chunkCapacity * 4) : null;
111+
112+ // ---- pipelines
113+ const drive = opts.drive;
114+ const module = device.createShaderModule({ code: shaderSource({
115+ N, E, K, P, M, W, D, G, dt, sigma0: setup.config.material.sigma0, nl: sh.nl,
116+ Nm: Math.max(1, this.nModes),
117+ driveOmega: drive ? drive.omega : 0, driveRampSteps: drive ? drive.rampSteps : 1, driveStopStep: drive ? drive.stopStep : 0,
118+ traceMode: this.tracing ? opts.traceMode! : -1,
119+ }) });
120+ const bgl = (entries: [number, "read-only-storage" | "storage" | "uniform"][]) => device.createBindGroupLayout({
121+ entries: entries.map(([binding, type]) => ({ binding, visibility: GPUShaderStage.COMPUTE, buffer: { type } })),
122+ });
123+ const L0 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "storage"], [5, "read-only-storage"], [6, "storage"], [7, "storage"]]);
124+ const L1 = bgl([[0, "storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "read-only-storage"], [4, "storage"], [5, "storage"], [6, "storage"], [7, "read-only-storage"], [8, "uniform"]]);
125+ const L2 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "uniform"]]);
126+ const L3 = bgl([[0, "read-only-storage"], [1, "storage"], [2, "uniform"], [3, "read-only-storage"]]);
127+ const L4 = bgl([[0, "read-only-storage"], [2, "uniform"], [3, "read-only-storage"], [4, "read-only-storage"], [5, "storage"]]);
128+ const L5 = bgl([[0, "read-only-storage"], [2, "uniform"], [3, "read-only-storage"], [4, "read-only-storage"], [6, "storage"]]);
129+ const empty = device.createBindGroupLayout({ entries: [] });
130+ const mkPipe = (entryPoint: string, layouts: GPUBindGroupLayout[]) => device.createComputePipeline({
131+ label: entryPoint, layout: device.createPipelineLayout({ bindGroupLayouts: layouts }), compute: { module, entryPoint },
132+ });
133+ this.pipelines.element = mkPipe("elementPass", [L0]);
134+ this.pipelines.node = mkPipe("nodePass", [empty, L1]);
135+ this.pipelines.gather = mkPipe("gatherPass", [empty, empty, L2]);
136+ if (this.framesCapacity > 0) this.pipelines.capture = mkPipe("capturePass", [empty, empty, empty, L3]);
137+ if (this.projCapacity > 0) this.pipelines.proj = mkPipe("projPass", [empty, empty, empty, L4]);
138+ if (this.tracing) this.pipelines.trace = mkPipe("tracePass", [empty, empty, empty, L5]);
139+ const B = this.buffers;
140+ const bg = (layout: GPUBindGroupLayout, entries: [number, GPUBuffer][]) => device.createBindGroup({ layout, entries: entries.map(([binding, buffer]) => ({ binding, resource: { buffer } })) });
141+ this.bindGroups.element = bg(L0, [[0, B.elemF], [1, B.elemU], [2, B.state], [3, B.elemForce], [4, B.hitsF], [5, B.hitsU], [6, B.sync], [7, B.rad]]);
142+ this.bindGroups.node = bg(L1, [[0, B.state], [1, B.elemForce], [2, B.nodeF], [3, B.adj], [4, B.hitsF], [5, B.hitsU], [6, B.sync], [7, B.driveF], [8, B.chunk]]);
143+ this.bindGroups.gather = bg(L2, [[0, B.sync], [1, B.rad], [2, B.radCoef], [3, B.pressure], [4, B.chunk]]);
144+ if (this.framesCapacity > 0) this.bindGroups.capture = bg(L3, [[0, B.state], [1, B.movie], [2, B.chunk], [3, B.sync]]);
145+ if (this.projCapacity > 0) this.bindGroups.proj = bg(L4, [[0, B.state], [2, B.chunk], [3, B.sync], [4, B.phiM], [5, B.projOut]]);
146+ if (this.tracing) this.bindGroups.trace = bg(L5, [[0, B.state], [2, B.chunk], [3, B.sync], [4, B.phiM], [6, B.traceOut]]);
147+ this.bindGroups.empty = device.createBindGroup({ layout: empty, entries: [] });
148+ }
149+
150+ setState(q: Float64Array, v: Float64Array) {
151+ const N = this.setup.mesh.n, st = new Float32Array(12 * N);
152+ for (let i = 0; i < 5 * N; i++) { st[i] = q[i]; st[5 * N + i] = v[i]; }
153+ for (let n = 0; n < N; n++) st[11 * N + n] = v[5 * n + 2]; // vwPrev, so the first radiated acceleration is not a spike
154+ this.device.queue.writeBuffer(this.buffers.state, 0, st);
155+ }
156+
157+ /** Audio sample completed by the gather pass at sub-step n, or -1 when none is due. */
158+ private sampleDueAt(n: number): number {
159+ const M = this.setup.substeps, W = DECIMATION_SPAN;
160+ if (n < 1 || (n - 1) % M !== 0) return -1;
161+ const j0 = (n - 1) / M;
162+ if (j0 === 0) return -1;
163+ const j = j0 - W / 2;
164+ return j >= 0 ? j : -1;
165+ }
166+
167+ get maxChunk() { return this.chunkCapacity; }
168+
169+ async runChunk(nSamples: number): Promise<ChunkResult> {
170+ if (nSamples > this.chunkCapacity) throw new Error("chunk larger than the pressure buffer");
171+ const { device, setup } = this, M = setup.substeps, E = setup.mesh.e, K = setup.schedule.K, N = setup.mesh.n;
172+ const n0 = this.step, n1 = n0 + nSamples * M;
173+ let base = -1, count = 0;
174+ for (let n = n0; n < n1; n++) { const j = this.sampleDueAt(n); if (j >= 0) { if (base < 0) base = j; count++; } }
175+ // frames captured in this chunk: the state at the start of sub-step n where n / M is a multiple of movieEvery
176+ const frameSamples: number[] = [];
177+ const every = this.movieEvery;
178+ if (every > 0) for (let n = n0; n < n1; n++) if (n % (M * every) === 0) frameSamples.push(n / M);
179+ const frameBase = frameSamples.length ? frameSamples[0] / every : 0;
180+ const projSamples: number[] = [];
181+ const pEvery = this.projEvery;
182+ if (pEvery > 0) for (let n = n0; n < n1; n++) if (n % (M * pEvery) === 0) projSamples.push(n / M);
183+ const projBase = projSamples.length ? projSamples[0] / pEvery : 0;
184+ // drive phase at the first sub-step of the chunk, computed in float64 and folded to [0, 2 pi)
185+ const drv = this.opts.drive;
186+ const phase0 = drv ? (drv.omega * setup.dt * n0) % (2 * Math.PI) : 0;
187+ const u = new ArrayBuffer(CHUNK_UNIFORM_BYTES), dv = new DataView(u);
188+ dv.setInt32(0, base < 0 ? 0 : base, true); dv.setUint32(4, Math.max(count, 1), true);
189+ dv.setUint32(8, frameBase, true); dv.setUint32(12, Math.max(every, 1), true);
190+ dv.setUint32(16, projBase, true); dv.setUint32(20, Math.max(pEvery, 1), true);
191+ dv.setFloat32(24, phase0, true); dv.setUint32(28, n0, true);
192+ device.queue.writeBuffer(this.buffers.chunk, 0, u);
193+ const enc = device.createCommandEncoder();
194+ enc.clearBuffer(this.buffers.pressure);
195+ const pass = enc.beginComputePass();
196+ const wgE = Math.ceil((E + K) / 64), wgN = this.G;
197+ for (let n = n0; n < n1; n++) {
198+ if (every > 0 && n % (M * every) === 0) {
199+ pass.setPipeline(this.pipelines.capture); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
200+ pass.setBindGroup(2, this.bindGroups.empty); pass.setBindGroup(3, this.bindGroups.capture); pass.dispatchWorkgroups(wgN);
201+ }
202+ if (pEvery > 0 && n % (M * pEvery) === 0) {
203+ pass.setPipeline(this.pipelines.proj); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
204+ pass.setBindGroup(2, this.bindGroups.empty); pass.setBindGroup(3, this.bindGroups.proj); pass.dispatchWorkgroups(this.nModes);
205+ }
206+ if (this.tracing && n % M === 0) {
207+ pass.setPipeline(this.pipelines.trace); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
208+ pass.setBindGroup(2, this.bindGroups.empty); pass.setBindGroup(3, this.bindGroups.trace); pass.dispatchWorkgroups(1);
209+ }
210+ pass.setPipeline(this.pipelines.element); pass.setBindGroup(0, this.bindGroups.element); pass.dispatchWorkgroups(wgE);
211+ if (this.sampleDueAt(n) >= 0) {
212+ pass.setPipeline(this.pipelines.gather); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
213+ pass.setBindGroup(2, this.bindGroups.gather); pass.dispatchWorkgroups(1);
214+ }
215+ pass.setPipeline(this.pipelines.node); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.node); pass.dispatchWorkgroups(wgN);
216+ }
217+ pass.end();
218+ enc.copyBufferToBuffer(this.buffers.pressure, 0, this.stagingPressure, 0, 8 * this.chunkCapacity);
219+ const nFrames = frameSamples.length;
220+ if (nFrames > 0) enc.copyBufferToBuffer(this.buffers.movie, 0, this.stagingMovie!, 0, nFrames * N * 4);
221+ const nProj = projSamples.length;
222+ if (nProj > 0) enc.copyBufferToBuffer(this.buffers.projOut, 0, this.stagingProj!, 0, nProj * 2 * this.nModes * 4);
223+ if (this.tracing) enc.copyBufferToBuffer(this.buffers.traceOut, 0, this.stagingTrace!, 0, nSamples * 4);
224+ device.queue.submit([enc.finish()]);
225+ this.step = n1; this.samplesDone += nSamples;
226+ await this.stagingPressure.mapAsync(GPUMapMode.READ);
227+ const all = new Float32Array(this.stagingPressure.getMappedRange().slice(0));
228+ this.stagingPressure.unmap();
229+ const frames: Float32Array[] = [];
230+ if (nFrames > 0) {
231+ await this.stagingMovie!.mapAsync(GPUMapMode.READ, 0, nFrames * N * 4);
232+ const data = new Float32Array(this.stagingMovie!.getMappedRange(0, nFrames * N * 4).slice(0));
233+ this.stagingMovie!.unmap();
234+ for (let f = 0; f < nFrames; f++) frames.push(data.slice(f * N, (f + 1) * N));
235+ }
236+ const proj: Float32Array[] = [];
237+ if (nProj > 0) {
238+ const stride = 2 * this.nModes;
239+ await this.stagingProj!.mapAsync(GPUMapMode.READ, 0, nProj * stride * 4);
240+ const data = new Float32Array(this.stagingProj!.getMappedRange(0, nProj * stride * 4).slice(0));
241+ this.stagingProj!.unmap();
242+ for (let s = 0; s < nProj; s++) proj.push(data.slice(s * stride, (s + 1) * stride));
243+ }
244+ let trace = new Float32Array(0);
245+ if (this.tracing) {
246+ await this.stagingTrace!.mapAsync(GPUMapMode.READ, 0, nSamples * 4);
247+ trace = new Float32Array(this.stagingTrace!.getMappedRange(0, nSamples * 4).slice(0));
248+ this.stagingTrace!.unmap();
249+ }
250+ const size = Math.max(count, 1);
251+ return { firstSample: base < 0 ? this.samplesDone : base, pressureFront: all.slice(0, count), pressureBack: all.slice(size, size + count),
252+ frames, frameSamples, proj, projSamples, trace, traceBase: n0 / M };
253+ }
254+
255+ private async read(buffer: GPUBuffer, staging: GPUBuffer, bytes: number): Promise<ArrayBuffer> {
256+ const enc = this.device.createCommandEncoder();
257+ enc.copyBufferToBuffer(buffer, 0, staging, 0, bytes);
258+ this.device.queue.submit([enc.finish()]);
259+ await staging.mapAsync(GPUMapMode.READ);
260+ const out = staging.getMappedRange().slice(0);
261+ staging.unmap();
262+ return out;
263+ }
264+
265+ async diagnostics(): Promise<Diagnostics> {
266+ const { setup } = this, sh = setup.shell, N = setup.mesh.n, sc = setup.schedule, HF = this.HF, HU = this.HU;
267+ const st = new Float32Array(await this.read(this.buffers.state, this.stagingState, 12 * N * 4));
268+ const hf = new Float32Array(await this.read(this.buffers.hitsF, this.stagingHits, Math.max(16, HF * sc.K * 4)));
269+ const hu = new Uint32Array(await this.read(this.buffers.hitsU, this.stagingHitsU, Math.max(16, HU * sc.K * 4)));
270+ let ke = 0, woh = 0;
271+ for (let i = 0; i < 5 * N; i++) ke += 0.5 * sh.M[i] * st[5 * N + i] * st[5 * N + i];
272+ for (let n = 0; n < N; n++) woh = Math.max(woh, Math.abs(st[5 * n + 2] / sh.hNode[n]));
273+ const { membrane, bending } = strainEnergies(sh, st.subarray(0, 5 * N));
274+ let ex = 0;
275+ const hits = [];
276+ for (let k = 0; k < sc.K; k++) {
277+ ex += 0.5 * hf[HF * k + 2] * hf[HF * k + 6] ** 2;
278+ hits.push({ peakForce: hf[HF * k + 8], contactTime: hu[HU * k + 1] * setup.dt, velocity: hf[HF * k + 6], height: hf[HF * k + 5] });
279+ }
280+ return { time: this.step * setup.dt, kinetic: ke, membrane, bending, exciters: ex, maxWoverH: woh, hits };
281+ }
282+
283+ async displacement(): Promise<Float32Array> {
284+ const N = this.setup.mesh.n;
285+ const st = new Float32Array(await this.read(this.buffers.state, this.stagingState, 12 * N * 4));
286+ const w = new Float32Array(N);
287+ for (let n = 0; n < N; n++) w[n] = st[5 * n + 2];
288+ return w;
289+ }
290+
291+ /** The state buffer (q at offset 0, 5 floats per node), for a renderer that reads w directly. */
292+ get stateBuffer(): GPUBuffer { return this.buffers.state; }
293+
294+ dispose() {
295+ for (const b of Object.values(this.buffers)) b.destroy();
296+ this.stagingPressure.destroy(); this.stagingState.destroy(); this.stagingHits.destroy(); this.stagingHitsU.destroy();
297+ this.stagingMovie?.destroy(); this.stagingProj?.destroy(); this.stagingTrace?.destroy();
298+ }
299+}
src/main.tsadded+607−0View file
@@ -0,0 +1,607 @@
1+import { Player, encodeWav, scaleForPlayback } from "./audio/player.ts";
2+import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts";
3+import { PRESETS } from "./config/presets.ts";
4+import type { SimulationConfig } from "./config/schema.ts";
5+import { cloneConfig, configToYaml, designIssues, normalizeConfig, parseConfigYaml } from "./config/yaml.ts";
6+import { CpuSimulator } from "./cpu/simulator.ts";
7+import { derive, type Derived } from "./derived.ts";
8+import { DesignPanel, type Field, type Tier } from "./design.ts";
9+import { antinode, buildDrive, buildProjection, modalAmplitude, modalInitialState, modeShape, type Mechanism } from "./drive.ts";
10+import { GpuSimulator } from "./gpu/simulator.ts";
11+import { classifyMode } from "./modal/classify.ts";
12+import { ModalTracker } from "./modal/tracker.ts";
13+import { parseModes, type ModesData } from "./modes.ts";
14+import { buildSetup, type Setup } from "./model/setup.ts";
15+import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
16+import { structuralKey } from "./precompute/pipeline.ts";
17+import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts";
18+import { Runner } from "./run.ts";
19+import { Stft } from "./analysis/spectrogram.ts";
20+import { EnergyBars, ModalSpectrogram, StftView, type FrequencyScale } from "./ui/modalView.ts";
21+import { ModeList } from "./ui/modeList.ts";
22+import { PlateView } from "./ui/plateView.ts";
23+
24+const CACHE_VERSION = 1;
25+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
26+const params = new URLSearchParams(location.search);
27+
28+// ---------------------------------------------------------------- WebGPU device
29+let device: GPUDevice | null = null;
30+if (params.get("backend") !== "cpu" && "gpu" in navigator) {
31+ try {
32+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
33+ device = (await adapter?.requestDevice()) ?? null;
34+ } catch (err) { console.warn("WebGPU unavailable", err); }
35+}
36+$("backend").textContent = device ? "backend: WebGPU compute" : "backend: CPU fallback (no WebGPU); use a coarse mesh and a short duration";
37+
38+// ---------------------------------------------------------------- state
39+let presetYaml = PRESETS[0].yaml;
40+let config: SimulationConfig = parseConfigYaml(presetYaml).config!;
41+let modes: ModesData | null = null;
42+let modesBuffer: ArrayBuffer | null = null;
43+let modesStructural = "";
44+let derived: Derived | null = null;
45+let selectedMode = -1;
46+let worker: Worker | null = null;
47+let token = 0, computeToken = -1, computing = false, computeStart = 0;
48+const complementWaiters = new Map<number, { resolve: (o: Float64Array) => void; reject: (e: Error) => void }>();
49+const complementCache = new Map<number, Float64Array>(); // per mode, for the unit (factor 1) shape
50+
51+let setup: Setup | null = null;
52+let backend: SimulatorBackend | null = null;
53+let runner: Runner | null = null;
54+let runState: "idle" | "setup" | "running" | "paused" | "done" | "stopped" = "idle";
55+let tracker: ModalTracker | null = null;
56+let front = new Float32Array(0), back = new Float32Array(0), frontier = 0;
57+let runDuration = 0, runFs = 44100;
58+let cursorTime: number | null = null; // null: the bar view follows the run
59+
60+// ---------------------------------------------------------------- DOM
61+const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile"), modesFile = $<HTMLInputElement>("modesfile");
62+const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
63+const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
64+const modesProgress = $("modes-progress"), modesStatus = $("modes-status"), designStatus = $("design-status");
65+const mechanismSel = $<HTMLSelectElement>("mechanism"), amplitudeInput = $<HTMLInputElement>("amplitude");
66+const dampScaleInput = $<HTMLInputElement>("damp-scale"), detuneInput = $<HTMLInputElement>("detune");
67+const rampInput = $<HTMLInputElement>("ramp"), driveTimeInput = $<HTMLInputElement>("drive-time");
68+const durationInput = $<HTMLInputElement>("duration"), snapRateInput = $<HTMLInputElement>("snap-rate"), linearBox = $<HTMLInputElement>("linear");
69+const runBtn = $<HTMLButtonElement>("run"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
70+const runProgress = $<HTMLProgressElement>("run-progress"), runProgressText = $("run-progress-text");
71+const driveModeEl = $("drive-mode"), driveStatus = $("drive-status");
72+const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
73+const position = $<HTMLInputElement>("position"), timeEl = $("time"), gain = $<HTMLInputElement>("gain"), normalize = $<HTMLInputElement>("normalize");
74+const signalSel = $<HTMLSelectElement>("signal");
75+const followBtn = $<HTMLButtonElement>("follow");
76+const logEl = $("log");
77+
78+function log(text: string) {
79+ logEl.textContent = `${logEl.textContent}${text}\n`.split("\n").slice(-30).join("\n");
80+}
81+function download(blob: Blob, name: string) {
82+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click();
83+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
84+}
85+
86+// ---------------------------------------------------------------- views
87+const plate = new PlateView($<HTMLCanvasElement>("plate"), device);
88+const modeList = new ModeList($("modelist"));
89+const specCanvas = $<HTMLCanvasElement>("modal-spec");
90+const spec = new ModalSpectrogram(specCanvas);
91+const soundView = new StftView(specCanvas, spec.axis);
92+const traceView = new StftView(specCanvas, spec.axis);
93+const bars = new EnergyBars($<HTMLCanvasElement>("bars"), spec.axis);
94+const player = new Player();
95+spec.axis.scale = $<HTMLSelectElement>("freq-scale").value as FrequencyScale;
96+spec.axis.fmax = Number($<HTMLSelectElement>("freq-max").value);
97+const viewSel = $<HTMLSelectElement>("view");
98+type AnalysisView = ModalSpectrogram | StftView;
99+const activeView = (): AnalysisView => (viewSel.value === "sound" ? soundView : viewSel.value === "trace" ? traceView : spec);
100+// the STFT columns of the run so far: the listener signal (rebuilt when the signal choice
101+// changes) and the driven mode's coordinate at the audio rate
102+let stftSound: Stft | null = null, soundPushed = 0;
103+let stftTrace: Stft | null = null, tracePushed = 0;
104+let traceArr = new Float32Array(0), traceFrontier = 0;
105+
106+const exag = $<HTMLInputElement>("exag");
107+const applyExag = () => { plate.exaggeration = Math.pow(10, Number(exag.value)); $("exag-text").textContent = `x${plate.exaggeration.toFixed(0)}`; plate.render(); };
108+exag.oninput = applyExag; applyExag();
109+$("reset-view").onclick = () => plate.resetView();
110+
111+function invalidateViews() { spec.invalidate(); soundView.invalidate(); traceView.invalidate(); }
112+for (const [sel, apply] of [
113+ ["freq-scale", (v: string) => { spec.axis.scale = v as FrequencyScale; }],
114+ ["freq-max", (v: string) => { spec.axis.fmax = Number(v); }],
115+ ["db-range", (v: string) => { spec.dbRange = Number(v); bars.dbRange = Number(v); soundView.dbRange = Number(v); traceView.dbRange = Number(v); }],
116+] as const) {
117+ $<HTMLSelectElement>(sel).onchange = () => { apply($<HTMLSelectElement>(sel).value); invalidateViews(); redrawModal(); };
118+}
119+viewSel.onchange = () => redrawModal();
120+spec.onHover = (t) => { $("modal-info").textContent = t; };
121+soundView.onHover = (t) => { $("modal-info").textContent = t; };
122+traceView.onHover = (t) => { $("modal-info").textContent = t; };
123+bars.onHover = (t) => { $("bars-info").textContent = t || barsDefaultInfo(); };
124+spec.onSelect = (mode, time) => { cursorTime = time; followBtn.disabled = false; selectMode(mode); updateBars(); };
125+soundView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); };
126+traceView.onSelect = (time) => { cursorTime = time; followBtn.disabled = false; updateBars(); };
127+bars.onSelect = (mode) => selectMode(mode);
128+modeList.onSelect = (mode) => selectMode(mode);
129+followBtn.onclick = () => { cursorTime = null; followBtn.disabled = true; updateBars(); };
130+
131+function redrawModal() {
132+ spec.cursorTime = cursorTime; soundView.cursorTime = cursorTime; traceView.cursorTime = cursorTime;
133+ activeView().draw(); bars.draw();
134+}
135+new ResizeObserver(() => { plate.render(); }).observe($("plate"));
136+new ResizeObserver(() => { activeView().draw(); }).observe($("modal-spec"));
137+new ResizeObserver(() => { bars.draw(); }).observe($("bars"));
138+
139+// splitters: drag to set the CSS variable that sizes the neighbouring pane
140+for (const sp of document.querySelectorAll<HTMLElement>(".splitter")) {
141+ const target = $(sp.dataset.target!), cssVar = sp.dataset.var!, vertical = sp.classList.contains("vertical"), invert = sp.dataset.invert === "1";
142+ sp.addEventListener("pointerdown", (e) => {
143+ e.preventDefault();
144+ sp.setPointerCapture(e.pointerId); sp.classList.add("active");
145+ const start = vertical ? e.clientX : e.clientY, size0 = vertical ? target.getBoundingClientRect().width : target.getBoundingClientRect().height;
146+ const move = (ev: PointerEvent) => {
147+ const delta = (vertical ? ev.clientX : ev.clientY) - start;
148+ const size = Math.max(60, size0 + (invert ? -delta : delta));
149+ document.documentElement.style.setProperty(cssVar, `${size}px`);
150+ };
151+ const up = () => { sp.classList.remove("active"); sp.removeEventListener("pointermove", move); sp.removeEventListener("pointerup", up); plate.render(); redrawModal(); };
152+ sp.addEventListener("pointermove", move); sp.addEventListener("pointerup", up);
153+ });
154+}
155+
156+// ---------------------------------------------------------------- mode-shape animation
157+let animShape: Float64Array | null = null; // physical peak displacement per node [m]
158+let animBuf = new Float32Array(0);
159+const animateBox = $<HTMLInputElement>("animate");
160+function prepareAnimation() {
161+ animShape = null;
162+ if (!modes || !derived || selectedMode < 0) return;
163+ const shape = modeShape(modes, selectedMode, derived.phiFactor);
164+ const a = modalAmplitude(modes, shape, Number(amplitudeInput.value) || 1);
165+ animShape = shape.map((x) => a * x);
166+ const { node } = antinode(shape);
167+ plate.colorScale = Math.abs(animShape[node]);
168+ if (animBuf.length !== shape.length) animBuf = new Float32Array(shape.length);
169+}
170+function animating(): boolean {
171+ return animateBox.checked && animShape !== null && runState !== "running" && runState !== "paused" && runState !== "setup";
172+}
173+function animationTick(tMs: number) {
174+ if (animating()) {
175+ const c = Math.cos(2 * Math.PI * 0.7 * (tMs / 1000));
176+ for (let n = 0; n < animShape!.length; n++) animBuf[n] = c * animShape![n];
177+ plate.setDisplacement(animBuf);
178+ }
179+ if (player.playing) {
180+ const t = player.position();
181+ position.value = String(runDuration > 0 ? t / runDuration : 0); timeEl.textContent = `${t.toFixed(3)} s`;
182+ }
183+ requestAnimationFrame(animationTick);
184+}
185+requestAnimationFrame(animationTick);
186+animateBox.onchange = () => { if (!animating()) plate.render(); };
187+
188+// ---------------------------------------------------------------- modes lifecycle
189+function options() { return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) }; }
190+function structuralString(cfg: SimulationConfig) { return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) }); }
191+function stale(): boolean { return !!modes && modesStructural !== structuralString(config); }
192+
193+function ensureWorker(): Worker {
194+ if (!worker) {
195+ worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" });
196+ worker.onmessage = (ev: MessageEvent<PrecomputeReply>) => void onWorkerMessage(ev.data);
197+ worker.onerror = (e) => { modesStatus.textContent = `worker error: ${e.message}`; };
198+ }
199+ return worker;
200+}
201+
202+async function onWorkerMessage(msg: PrecomputeReply) {
203+ if (msg.type === "progress") {
204+ const secs = (performance.now() - computeStart) / 1000;
205+ if (msg.token === computeToken) {
206+ modesProgress.style.width = `${Math.round(100 * msg.frac)}%`;
207+ modesStatus.textContent = `${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${secs.toFixed(0)} s]`;
208+ } else if (complementWaiters.has(msg.token)) {
209+ driveStatus.textContent = `condensation: ${msg.phase} ${(100 * msg.frac).toFixed(0)} %`;
210+ }
211+ } else if (msg.type === "modes") {
212+ if (msg.token !== computeToken) return;
213+ computing = false; computeBtn.disabled = false;
214+ modesProgress.style.width = "100%";
215+ const structural = structuralString(config);
216+ await cachePut(await cacheKey(structural), msg.buffer);
217+ setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
218+ } else if (msg.type === "complement") {
219+ const w = complementWaiters.get(msg.token);
220+ if (w) { complementWaiters.delete(msg.token); w.resolve(msg.o); }
221+ } else if (msg.type === "gains") {
222+ // not used here: the run computes its own radiation
223+ } else {
224+ const w = complementWaiters.get(msg.token);
225+ if (w) { complementWaiters.delete(msg.token); w.reject(new Error(msg.message)); return; }
226+ computing = false; computeBtn.disabled = false;
227+ modesStatus.textContent = `error: ${msg.message}`;
228+ updateRunUi();
229+ }
230+}
231+
232+function compute() {
233+ const issues = designIssues(config);
234+ if (issues.length) { modesStatus.textContent = "the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; }
235+ computing = true; computeBtn.disabled = true; computeStart = performance.now();
236+ computeToken = ++token;
237+ modesProgress.style.width = "0%";
238+ modesStatus.textContent = "computing the modes...";
239+ updateRunUi();
240+ const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
241+ ensureWorker().postMessage(req);
242+}
243+
244+function setModes(buffer: ArrayBuffer, label: string, structural: string) {
245+ let parsed: ModesData;
246+ try { parsed = parseModes(buffer); } catch (err) { modesStatus.textContent = `could not read the modes: ${(err as Error).message}`; return; }
247+ modes = parsed; modesBuffer = buffer; modesStructural = structural;
248+ complementCache.clear();
249+ derived = derive(parsed, config);
250+ downloadModesBtn.disabled = false;
251+ modesStatus.textContent = `${label}: ${parsed.nNodes} nodes, ${parsed.nModes} modes, ` +
252+ `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[parsed.nModes - 1].toFixed(0)} Hz`;
253+ modeList.setModes(parsed, derived.freq, derived.sigma);
254+ $("modelist-info").textContent = `${parsed.nModes} modes`;
255+ plate.setMesh({ n: parsed.nNodes, e: parsed.nElements, xy: parsed.xy, tri: parsed.tri }, parsed.z0,
256+ Math.min(...parsed.h), null);
257+ $("plate-info").textContent = `${parsed.nNodes} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
258+ selectMode(Math.min(selectedMode >= 0 ? selectedMode : 0, parsed.nModes - 1));
259+ updateRunUi();
260+}
261+
262+async function tryCache() {
263+ if (!modes) {
264+ const buf = await cacheGet(await cacheKey(structuralString(config)));
265+ if (buf) setModes(buf, "cached modes", structuralString(config));
266+ else modesStatus.textContent = "no modes for this cymbal yet";
267+ }
268+ updateRunUi();
269+}
270+
271+function requestComplement(mode: number): Promise<Float64Array> {
272+ const cached = complementCache.get(mode);
273+ if (cached) return Promise.resolve(cached);
274+ const raw = normalizeConfig(modes!.meta.config).config;
275+ const w = modeShape(modes!, mode, 1);
276+ const tok = ++token;
277+ return new Promise((resolve, reject) => {
278+ complementWaiters.set(tok, { resolve: (o) => { complementCache.set(mode, o); resolve(o); }, reject });
279+ const req: PrecomputeRequest = { type: "complement", token: tok, config: raw,
280+ options: { elementSize: modes!.meta.playback_element_size, fmax: modes!.meta.fmax }, w };
281+ ensureWorker().postMessage(req, [w.buffer]);
282+ });
283+}
284+
285+// ---------------------------------------------------------------- selection
286+function selectMode(mode: number) {
287+ if (!modes || !derived || mode < 0 || mode >= modes.nModes) return;
288+ selectedMode = mode;
289+ modeList.select(mode);
290+ spec.selectedMode = mode; bars.selectedMode = mode;
291+ soundView.selectedMode = mode; traceView.selectedMode = mode;
292+ const f = derived.freq[mode], sigma = derived.sigma[mode];
293+ const { diameters, circles } = classifyMode(modes, mode);
294+ const shape = modeShape(modes, mode, 1);
295+ const { node } = antinode(shape);
296+ const r = Math.hypot(modes.xy[2 * node], modes.xy[2 * node + 1]);
297+ driveModeEl.textContent = `mode ${mode}: ${f.toFixed(1)} Hz, T60 ${(6.907755 / sigma).toFixed(2)} s, ` +
298+ `≈ (${diameters} diameters, ${circles} circles), antinode at r = ${(r * 1e3).toFixed(0)} mm`;
299+ prepareAnimation();
300+ redrawModal();
301+ updateRunUi();
302+}
303+
304+// ---------------------------------------------------------------- design panel and presets
305+const panel = new DesignPanel($("design"), () => config, onDesignChange);
306+
307+function describeDesign() {
308+ const issues = designIssues(config);
309+ let text = "";
310+ if (modes && derived) {
311+ text = stale() ? "the shape changed: recompute the modes before running" : "";
312+ }
313+ if (issues.length) text += (text ? "\n" : "") + issues.map((i) => `${i.path}: ${i.message}`).join("\n");
314+ designStatus.textContent = text;
315+ designStatus.className = "status" + (issues.length ? " bad" : "");
316+}
317+
318+function onDesignChange(_field: Field, tier: Tier) {
319+ if (modes && tier === "live") {
320+ derived = derive(modes, config);
321+ modeList.setDerived(derived.freq, derived.sigma);
322+ if (selectedMode >= 0) selectMode(selectedMode);
323+ }
324+ describeDesign();
325+ updateRunUi();
326+}
327+
328+function setConfig(cfg: SimulationConfig, label: string) {
329+ config = cfg;
330+ panel.refresh();
331+ modes = null; modesBuffer = null; derived = null; modesStructural = "";
332+ complementCache.clear();
333+ downloadModesBtn.disabled = true;
334+ modeList.setModes(null, null, null);
335+ modesStatus.textContent = `${label}; no modes yet`;
336+ describeDesign();
337+ void tryCache();
338+}
339+
340+for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); }
341+const customOpt = document.createElement("option");
342+customOpt.value = "custom"; customOpt.textContent = "loaded YAML"; customOpt.disabled = true;
343+presetSel.appendChild(customOpt);
344+const initialPreset = PRESETS.find((p) => p.id === params.get("preset")) ?? PRESETS[0];
345+presetSel.value = initialPreset.id; presetYaml = initialPreset.yaml;
346+presetSel.onchange = () => {
347+ const p = PRESETS.find((x) => x.id === presetSel.value);
348+ if (p) { presetYaml = p.yaml; setConfig(parseConfigYaml(p.yaml).config!, p.name); }
349+};
350+$("import-yaml").onclick = () => yamlFile.click();
351+yamlFile.onchange = async () => {
352+ const file = yamlFile.files?.[0];
353+ if (!file) return;
354+ const text = await file.text();
355+ const { config: cfg, issues } = parseConfigYaml(text);
356+ if (!cfg) { modesStatus.textContent = "YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); return; }
357+ presetYaml = text; customOpt.disabled = false; presetSel.value = "custom";
358+ setConfig(cfg, file.name);
359+ yamlFile.value = "";
360+};
361+$("download-yaml").onclick = () => download(new Blob([configToYaml(config)], { type: "text/yaml" }), "cymmodes_design.yaml");
362+computeBtn.onclick = compute;
363+downloadModesBtn.onclick = () => { if (modesBuffer) download(new Blob([modesBuffer]), `cymmodes_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`); };
364+$("import-modes").onclick = () => modesFile.click();
365+modesFile.onchange = async () => {
366+ const file = modesFile.files?.[0];
367+ if (!file) return;
368+ const buf = await file.arrayBuffer();
369+ let loaded: ModesData;
370+ try { loaded = parseModes(buf); } catch (err) { modesStatus.textContent = `could not load the modes: ${(err as Error).message}`; return; }
371+ const cfg = normalizeConfig(loaded.meta.config).config;
372+ customOpt.disabled = false; presetSel.value = "custom";
373+ config = cfg;
374+ esizeSel.value = String(loaded.meta.playback_element_size);
375+ fmaxInput.value = String(loaded.meta.fmax);
376+ panel.refresh();
377+ setModes(buf, file.name, structuralString(config));
378+ modesFile.value = "";
379+};
380+$("clear-cache").onclick = async () => { await cacheClear(); modesStatus.textContent = "the cache is empty"; };
381+for (const el of [esizeSel, fmaxInput]) el.addEventListener("change", () => {
382+ modes = null; modesBuffer = null; derived = null; modesStructural = "";
383+ complementCache.clear();
384+ downloadModesBtn.disabled = true;
385+ modeList.setModes(null, null, null);
386+ updateRunUi();
387+ void tryCache();
388+});
389+
390+function applyMechanism() {
391+ for (const el of document.querySelectorAll<HTMLElement>(".forced-only")) el.classList.toggle("hidden", mechanismSel.value !== "forced");
392+ updateRunUi();
393+}
394+mechanismSel.onchange = applyMechanism;
395+applyMechanism();
396+amplitudeInput.onchange = prepareAnimation;
397+
398+// ---------------------------------------------------------------- the run
399+function updateRunUi() {
400+ const issues = designIssues(config);
401+ const busy = runState === "running" || runState === "paused" || runState === "setup";
402+ runBtn.disabled = busy || computing || !modes || stale() || selectedMode < 0 || issues.length > 0;
403+ computeBtn.disabled = computing || busy;
404+ pauseBtn.disabled = !(runState === "running" || runState === "paused");
405+ stopBtn.disabled = !(runState === "running" || runState === "paused");
406+ playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
407+ if (!modes) runBtn.title = "compute the modes first";
408+ else if (stale()) runBtn.title = "the shape changed: recompute the modes";
409+ else if (selectedMode < 0) runBtn.title = "select a mode";
410+ else runBtn.title = "";
411+}
412+
413+function barsDefaultInfo(): string {
414+ if (!tracker || !tracker.length) return "";
415+ const s = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime);
416+ if (s < 0) return "";
417+ const top = tracker.topModes(s, 4).filter((t) => t.energy > 0);
418+ const items = top.map((t) => `${t.mode} (${(tracker!.omega[t.mode] / (2 * Math.PI)).toFixed(0)} Hz)`).join(", ");
419+ return `t = ${tracker.times[s].toFixed(2)} s${cursorTime === null ? " (following)" : ""}; strongest: ${items}`;
420+}
421+
422+function updateBars() {
423+ if (!tracker || !tracker.length) { bars.snapshot = -1; redrawModal(); return; }
424+ bars.snapshot = cursorTime === null ? tracker.length - 1 : tracker.indexAtTime(cursorTime);
425+ $("bars-info").textContent = barsDefaultInfo();
426+ redrawModal();
427+ if (bars.snapshot >= 0) modeList.setEnergies(tracker.energies[bars.snapshot], tracker.maxEnergy);
428+}
429+
430+/** Push the newly arrived part of the listener signal into its STFT (rebuilt on a signal change). */
431+function pushSound(rebuild = false) {
432+ if (rebuild || !stftSound) { stftSound = new Stft(runFs, 2048, 256); soundPushed = 0; soundView.stft = stftSound; }
433+ if (frontier > soundPushed) {
434+ const part = new Float32Array(frontier - soundPushed);
435+ if (signalSel.value === "rayleigh") part.set(front.subarray(soundPushed, frontier));
436+ else for (let i = soundPushed; i < frontier; i++) part[i - soundPushed] = front[i] - back[i];
437+ if (stftSound.push(part) > 0) soundView.invalidate();
438+ soundPushed = frontier;
439+ }
440+}
441+
442+async function startRun() {
443+ if (!modes || !derived || selectedMode < 0 || stale()) return;
444+ runState = "setup"; updateRunUi();
445+ driveStatus.textContent = "building the set-up ...";
446+ const mech = mechanismSel.value as Mechanism;
447+ const dampScale = Math.min(1, Math.max(0, Number(dampScaleInput.value)));
448+ const cfg = cloneConfig(config);
449+ cfg.hits = [];
450+ cfg.mesh.element_size = modes.meta.playback_element_size;
451+ cfg.numerics.duration = Math.max(0.05, Number(durationInput.value) || 1);
452+ cfg.numerics.nonlinear = !linearBox.checked;
453+ cfg.material.sigma0 *= dampScale; cfg.material.sigma1 *= dampScale;
454+ try {
455+ setup = await buildSetup(cfg, async (phase) => { driveStatus.textContent = `set-up: ${phase}`; await new Promise((r) => setTimeout(r, 0)); });
456+ } catch (err) {
457+ driveStatus.textContent = `set-up failed: ${(err as Error).message}`; runState = "idle"; updateRunUi(); return;
458+ }
459+ if (setup.mesh.n !== modes.nNodes) {
460+ driveStatus.textContent = `mesh mismatch: the set-up has ${setup.mesh.n} nodes, the modes ${modes.nNodes}; recompute the modes`;
461+ runState = "idle"; updateRunUi(); return;
462+ }
463+ runFs = setup.fs; runDuration = cfg.numerics.duration;
464+ const m = selectedMode;
465+ const shape = modeShape(modes, m, derived.phiFactor);
466+ const ampWH = Number(amplitudeInput.value) || 1;
467+ const Mw = new Float64Array(setup.mesh.n);
468+ for (let n = 0; n < setup.mesh.n; n++) Mw[n] = setup.shell.M[5 * n + 2];
469+ const every = Math.max(1, Math.round(setup.fs / (Number(snapRateInput.value) || 240)));
470+ const projection = buildProjection(modes, Mw, derived.phiFactor, every);
471+ let drive;
472+ let initial: { q: Float64Array; v: Float64Array } | null = null;
473+ const spec_: { mode: number; mechanism: Mechanism; amplitude: number; detuneCents: number; rampTime: number; driveTime: number } = {
474+ mode: m, mechanism: mech, amplitude: ampWH,
475+ detuneCents: Number(detuneInput.value) || 0, rampTime: Math.max(0, Number(rampInput.value) || 0),
476+ driveTime: Math.max(0.05, Number(driveTimeInput.value) || 1),
477+ };
478+ if (mech === "decay") {
479+ try {
480+ driveStatus.textContent = "condensation: in-plane and rotation complement of the mode ...";
481+ const factor = derived.phiFactor;
482+ const o = await requestComplement(m);
483+ const of = Float64Array.from(o, (x) => x * factor);
484+ initial = modalInitialState(shape, of, modalAmplitude(modes, shape, ampWH));
485+ } catch (err) {
486+ driveStatus.textContent = `complement failed (${(err as Error).message}); releasing the pure-w shape`;
487+ initial = modalInitialState(shape, new Float64Array(4 * setup.mesh.n), modalAmplitude(modes, shape, ampWH));
488+ }
489+ } else {
490+ drive = buildDrive(spec_, modes, shape, Mw, derived.sigma[m], 2 * Math.PI * derived.freq[m], setup.dt);
491+ }
492+ backend?.dispose();
493+ backend = device ? new GpuSimulator(device, setup, 8192, { drive, projection, traceMode: m }) : new CpuSimulator(setup, { drive, projection, traceMode: m });
494+ if (initial) backend.setState(initial.q, initial.v);
495+ tracker = new ModalTracker(derived.freq, m);
496+ modeList.setDriven(m);
497+ spec.tracker = tracker; spec.duration = runDuration; spec.invalidate();
498+ bars.tracker = tracker; bars.snapshot = -1;
499+ // the two true spectrograms: the listener signal and the driven mode's own coordinate
500+ const modeFreqs = Float64Array.from(derived.freq);
501+ for (const v of [soundView, traceView]) { v.duration = runDuration; v.modeFreqs = modeFreqs; v.drivenMode = m; v.selectedMode = m; v.cursorTime = null; v.invalidate(); }
502+ stftSound = null; soundPushed = 0;
503+ stftTrace = new Stft(setup.fs, 2048, 256); tracePushed = 0;
504+ traceView.stft = stftTrace;
505+ traceArr = new Float32Array(setup.nSamples); traceFrontier = 0;
506+ cursorTime = null; followBtn.disabled = true;
507+ front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
508+ pushSound(true);
509+ player.stop();
510+ plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity),
511+ backend instanceof GpuSimulator ? backend.stateBuffer : null);
512+ plate.colorScale = ampWH * Math.min(...modes.h);
513+ $("plate-title").textContent = "Live";
514+ const detuneNote = mech === "forced" && spec_.detuneCents !== 0 ? `, detuned ${spec_.detuneCents} cents` : "";
515+ log(`run: mode ${m} at ${derived.freq[m].toFixed(1)} Hz, ${mech === "decay" ? "free decay from" : "forced towards"} ` +
516+ `${ampWH} thicknesses${detuneNote}, damping x${dampScale}, ${cfg.numerics.nonlinear ? "nonlinear" : "linear"}, ${runDuration} s`);
517+ if (mech === "forced" && dampScale < 1) log(`note: the force targets the amplitude at physical damping; with damping x${dampScale} the linear response is larger`);
518+ let lastPlate = 0, lastBars = 0;
519+ runState = "running"; updateRunUi();
520+ runner = new Runner(backend, setup, {
521+ onChunk(res: ChunkResult) {
522+ const n = res.pressureFront.length;
523+ if (n && setup) {
524+ front.set(res.pressureFront.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
525+ back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
526+ frontier = Math.min(setup.nSamples, res.firstSample + n);
527+ pushSound();
528+ }
529+ if (res.trace.length && setup && stftTrace) {
530+ const len = Math.min(res.trace.length, setup.nSamples - res.traceBase);
531+ traceArr.set(res.trace.subarray(0, len), res.traceBase);
532+ traceFrontier = res.traceBase + len;
533+ if (stftTrace.push(traceArr.subarray(tracePushed, traceFrontier)) > 0) traceView.invalidate();
534+ tracePushed = traceFrontier;
535+ }
536+ if (tracker && res.proj.length) { tracker.addChunk(res, runFs); spec.invalidate(); }
537+ if (performance.now() - lastBars > 250) { lastBars = performance.now(); updateBars(); }
538+ playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
539+ if (performance.now() - lastPlate > 100) {
540+ lastPlate = performance.now();
541+ if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
542+ }
543+ },
544+ onDiagnostics(d: Diagnostics) {
545+ const total = d.kinetic + d.membrane + d.bending;
546+ const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0;
547+ driveStatus.textContent =
548+ `t = ${d.time.toFixed(2)} s: kinetic ${d.kinetic.toExponential(2)} J, membrane ${d.membrane.toExponential(2)} J, ` +
549+ `bending ${d.bending.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}\n` +
550+ `modal sum ${modal.toExponential(2)} J (${total > 0 ? ((100 * modal) / total).toFixed(0) : "-"} % of the shell energy)`;
551+ if (!Number.isFinite(total)) { log("energy is not finite: the run has blown up (lower numerics.cfl_safety)"); runner?.stop(); }
552+ },
553+ onProgress(done, wall) {
554+ if (!setup) return;
555+ runProgress.value = done / setup.nSamples;
556+ const audio = done / setup.fs;
557+ runProgressText.textContent = `${audio.toFixed(2)} / ${runDuration.toFixed(2)} s (${(audio / Math.max(wall, 1e-3)).toFixed(2)}x)`;
558+ },
559+ onDone(stopped) {
560+ runState = stopped ? "stopped" : "done";
561+ updateBars();
562+ updateRunUi();
563+ plate.useOwnState();
564+ $("plate-title").textContent = "Mode shape";
565+ log(stopped ? "stopped" : "done");
566+ void backend?.diagnostics().then((d) => {
567+ const total = d.kinetic + d.membrane + d.bending;
568+ const modal = tracker && tracker.length ? tracker.totals[tracker.length - 1] : 0;
569+ log(`final: shell energy ${total.toExponential(2)} J, modal sum ${modal.toExponential(2)} J, max|w|/h ${d.maxWoverH.toFixed(2)}`);
570+ });
571+ },
572+ });
573+ void runner.run();
574+}
575+runBtn.onclick = () => void startRun();
576+pauseBtn.onclick = () => {
577+ if (!runner) return;
578+ if (runner.isPaused) { runner.unpause(); pauseBtn.textContent = "Pause"; runState = "running"; }
579+ else { runner.pause(); pauseBtn.textContent = "Continue"; runState = "paused"; }
580+};
581+stopBtn.onclick = () => runner?.stop();
582+
583+// ---------------------------------------------------------------- audio
584+const signal = () => {
585+ const out = new Float32Array(frontier);
586+ if (signalSel.value === "rayleigh") out.set(front.subarray(0, frontier));
587+ else for (let i = 0; i < frontier; i++) out[i] = front[i] - back[i];
588+ return out;
589+};
590+gain.oninput = () => { player.setGainDb(Number(gain.value)); $("gain-text").textContent = `${gain.value} dB`; };
591+signalSel.onchange = () => { pushSound(true); if (viewSel.value === "sound") redrawModal(); };
592+playBtn.onclick = () => {
593+ if (frontier === 0) return;
594+ player.setGainDb(Number(gain.value));
595+ player.play(scaleForPlayback(signal(), normalize.checked), runFs, Number(position.value) * runDuration);
596+ stopAudioBtn.disabled = false;
597+};
598+player.onEnded = () => { stopAudioBtn.disabled = true; position.value = "0"; timeEl.textContent = "0.000 s"; };
599+stopAudioBtn.onclick = () => { player.stop(); stopAudioBtn.disabled = true; };
600+position.oninput = () => { timeEl.textContent = `${(Number(position.value) * runDuration).toFixed(3)} s`; };
601+wavBtn.onclick = () => { if (frontier > 0) download(encodeWav(scaleForPlayback(signal(), normalize.checked), runFs), `cymmodes_${signalSel.value}.wav`); };
602+
603+// ---------------------------------------------------------------- start-up
604+panel.refresh();
605+describeDesign();
606+updateRunUi();
607+void tryCache();
src/modal/classify.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+// Heuristic (m, n) classification of a mode shape: m nodal diameters and n nodal circles, read
2+// off the discrete shape by counting sign changes around a ring and along a ray. On a hammered,
3+// warped cymbal the azimuthal patterns are only approximately sinusoidal and high modes mix, so
4+// the label is a description, not a mode number; it is computed lazily for the modes the user
5+// looks at.
6+import type { ModesData } from "../modes.ts";
7+
8+export interface ModeLabel { diameters: number; circles: number }
9+
10+/** Count sign changes of ``vals`` (ordered), ignoring excursions below ``rel`` of the peak. */
11+function signChanges(vals: number[], rel = 0.15): number {
12+ let peak = 0;
13+ for (const v of vals) peak = Math.max(peak, Math.abs(v));
14+ if (peak === 0) return 0;
15+ let last = 0, changes = 0;
16+ for (const v of vals) {
17+ if (Math.abs(v) < rel * peak) continue;
18+ const s = Math.sign(v);
19+ if (last !== 0 && s !== last) changes++;
20+ last = s;
21+ }
22+ return changes;
23+}
24+
25+export function classifyMode(modes: ModesData, m: number): ModeLabel {
26+ const { nNodes, xy, radius } = modes, Nm = modes.nModes;
27+ const phi = (n: number) => modes.phiQ[n * Nm + m] * modes.phiScale[m];
28+ // ring: nodes with 0.62 R < r < 0.85 R, ordered by angle; sign changes around it = 2 m
29+ const ring: { a: number; v: number }[] = [];
30+ // ray: nodes within a narrow wedge about the antinode's angle, ordered by radius
31+ let antinodeAngle = 0, best = 0;
32+ for (let n = 0; n < nNodes; n++) {
33+ const v = Math.abs(phi(n));
34+ if (v > best) { best = v; antinodeAngle = Math.atan2(xy[2 * n + 1], xy[2 * n]); }
35+ }
36+ const ray: { r: number; v: number }[] = [];
37+ for (let n = 0; n < nNodes; n++) {
38+ const x = xy[2 * n], y = xy[2 * n + 1], r = Math.hypot(x, y);
39+ if (r > 0.62 * radius && r < 0.85 * radius) ring.push({ a: Math.atan2(y, x), v: phi(n) });
40+ let da = Math.atan2(y, x) - antinodeAngle;
41+ da = Math.atan2(Math.sin(da), Math.cos(da));
42+ if (Math.abs(da) < Math.PI / 14) ray.push({ r, v: phi(n) });
43+ }
44+ ring.sort((p, q) => p.a - q.a);
45+ ray.sort((p, q) => p.r - q.r);
46+ // around the closed ring the count is even for a clean pattern; halve and round
47+ const diameters = Math.round(signChanges(ring.map((p) => p.v)) / 2);
48+ const circles = signChanges(ray.map((p) => p.v));
49+ return { diameters, circles };
50+}
src/modal/tracker.tsadded+69−0View file
@@ -0,0 +1,69 @@
1+// Collects the modal snapshots of a run and turns them into per-mode energies.
2+//
3+// A snapshot holds q_m and qdot_m for every mode (projections of w and w-dot onto the
4+// mass-weighted shapes). The energy of a mode is E_m = (qdot_m^2 + omega_m^2 q_m^2) / 2, exact
5+// for the linearised, condensed model; in the nonlinear run it is the standard measure of how
6+// much of the transverse motion lives in mode m. The sum over modes misses the in-plane and
7+// rotational kinetic energy and the part of the strain energy that the linear basis does not
8+// represent, so it is compared against, not equated to, the simulator's energy diagnostics.
9+import type { ChunkResult } from "../model/simulator.ts";
10+
11+export class ModalTracker {
12+ readonly nModes: number;
13+ readonly omega: Float64Array; // (Nm,) [rad/s] at the current material
14+ readonly times: number[] = []; // [s]
15+ readonly energies: Float32Array[] = []; // per snapshot, (Nm,) [J]
16+ readonly totals: number[] = []; // sum over modes per snapshot [J]
17+ maxEnergy = 0;
18+ drivenMode: number;
19+
20+ constructor(freq: Float64Array, drivenMode: number) {
21+ this.nModes = freq.length;
22+ this.omega = Float64Array.from(freq, (f) => 2 * Math.PI * f);
23+ this.drivenMode = drivenMode;
24+ }
25+
26+ addChunk(res: ChunkResult, fs: number) {
27+ const Nm = this.nModes;
28+ for (let s = 0; s < res.proj.length; s++) {
29+ const row = res.proj[s], E = new Float32Array(Nm);
30+ let total = 0;
31+ for (let m = 0; m < Nm; m++) {
32+ const q = row[m], v = row[Nm + m];
33+ const e = 0.5 * (v * v + this.omega[m] * this.omega[m] * q * q);
34+ E[m] = e; total += e;
35+ if (e > this.maxEnergy) this.maxEnergy = e;
36+ }
37+ this.times.push(res.projSamples[s] / fs);
38+ this.energies.push(E);
39+ this.totals.push(total);
40+ }
41+ }
42+
43+ get length() { return this.times.length; }
44+ get duration() { return this.times.length ? this.times[this.times.length - 1] : 0; }
45+
46+ /** Index of the snapshot at or before ``t``. */
47+ indexAtTime(t: number): number {
48+ const ts = this.times;
49+ let lo = 0, hi = ts.length - 1;
50+ if (hi < 0) return -1;
51+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (ts[mid] <= t) lo = mid; else hi = mid - 1; }
52+ return lo;
53+ }
54+
55+ /** Energy of every mode except the driven one, at snapshot ``s``. */
56+ otherEnergy(s: number): number {
57+ const E = this.energies[s];
58+ let sum = 0;
59+ for (let m = 0; m < this.nModes; m++) if (m !== this.drivenMode) sum += E[m];
60+ return sum;
61+ }
62+
63+ /** The ``k`` most energetic modes at snapshot ``s``, as (mode, energy) sorted descending. */
64+ topModes(s: number, k: number): { mode: number; energy: number }[] {
65+ const E = this.energies[s];
66+ const idx = Array.from(E.keys()).sort((a, b) => E[b] - E[a]).slice(0, k);
67+ return idx.map((mode) => ({ mode, energy: E[mode] }));
68+ }
69+}
src/model/dkt.tsadded+64−0View file
@@ -0,0 +1,64 @@
1+// The Discrete Kirchhoff Triangle (Batoz, Bathe, Ho 1980): 9 x 9 bending stiffness for nodal
2+// DOFs (w, theta_x, theta_y) x 3 with theta_x = dw/dy, theta_y = -dw/dx. This is the same
3+// element as cymsim/elements.py, with the derivatives of the rotation shape functions written
4+// out instead of obtained by automatic differentiation.
5+
6+const GAUSS = [[1 / 6, 1 / 6], [2 / 3, 1 / 6], [1 / 6, 2 / 3]];
7+
8+/** Quadratic shape functions N1..N6 and their (xi, eta) derivatives at (xi, eta). */
9+function shapes(xi: number, eta: number) {
10+ const lam = 1 - xi - eta;
11+ const N = [lam * (2 * lam - 1), xi * (2 * xi - 1), eta * (2 * eta - 1), 4 * xi * eta, 4 * eta * lam, 4 * xi * lam];
12+ const Nxi = [-(4 * lam - 1), 4 * xi - 1, 0, 4 * eta, -4 * eta, 4 * (lam - xi)];
13+ const Neta = [-(4 * lam - 1), 0, 4 * eta - 1, 4 * xi, 4 * (lam - eta), -4 * xi];
14+ return { N, Nxi, Neta };
15+}
16+
17+/** Row vectors Hx, Hy (length 9) as linear combinations of the six shape values N. */
18+function H(N: number[], a: number[], b: number[], c: number[], d: number[], e: number[]): [number[], number[]] {
19+ const [N1, N2, N3, N4, N5, N6] = N;
20+ const [a4, a5, a6] = a, [b4, b5, b6] = b, [c4, c5, c6] = c, [d4, d5, d6] = d, [e4, e5, e6] = e;
21+ const Hx = [1.5 * (a6 * N6 - a5 * N5), b5 * N5 + b6 * N6, N1 - c5 * N5 - c6 * N6,
22+ 1.5 * (a4 * N4 - a6 * N6), b6 * N6 + b4 * N4, N2 - c4 * N4 - c6 * N6,
23+ 1.5 * (a5 * N5 - a4 * N4), b4 * N4 + b5 * N5, N3 - c4 * N4 - c5 * N5];
24+ const Hy = [1.5 * (d6 * N6 - d5 * N5), -N1 + e5 * N5 + e6 * N6, -b5 * N5 - b6 * N6,
25+ 1.5 * (d4 * N4 - d6 * N6), -N2 + e4 * N4 + e6 * N6, -b6 * N6 - b4 * N4,
26+ 1.5 * (d5 * N5 - d4 * N4), -N3 + e4 * N4 + e5 * N5, -b4 * N4 - b5 * N5];
27+ return [Hx, Hy];
28+}
29+
30+/** DKT stiffness (row-major 9 x 9) of the triangle with nodes (xe[i], ye[i]), rigidity D, Poisson ratio nu. */
31+export function dktStiffness(xe: ArrayLike<number>, ye: ArrayLike<number>, D: number, nu: number): Float64Array {
32+ const I = [1, 2, 0], J = [2, 0, 1]; // sides k = 4, 5, 6 <-> ij = 23, 31, 12
33+ const a: number[] = [], b: number[] = [], c: number[] = [], d: number[] = [], e: number[] = [];
34+ for (let k = 0; k < 3; k++) {
35+ const xij = xe[I[k]] - xe[J[k]], yij = ye[I[k]] - ye[J[k]], l2 = xij * xij + yij * yij;
36+ a.push(-xij / l2); b.push((0.75 * xij * yij) / l2); c.push((0.25 * xij * xij - 0.5 * yij * yij) / l2);
37+ d.push(-yij / l2); e.push((0.25 * yij * yij - 0.5 * xij * xij) / l2);
38+ }
39+ const x21 = xe[1] - xe[0], x31 = xe[2] - xe[0], y21 = ye[1] - ye[0], y31 = ye[2] - ye[0];
40+ const det = x21 * y31 - x31 * y21;
41+ const xi_x = y31 / det, xi_y = -x31 / det, eta_x = -y21 / det, eta_y = x21 / det;
42+ const Db = [[D, D * nu, 0], [D * nu, D, 0], [0, 0, D * 0.5 * (1 - nu)]];
43+ const Ke = new Float64Array(81);
44+ for (const [xi, eta] of GAUSS) {
45+ const { Nxi, Neta } = shapes(xi, eta);
46+ const [Hx_xi, Hy_xi] = H(Nxi, a, b, c, d, e);
47+ const [Hx_eta, Hy_eta] = H(Neta, a, b, c, d, e);
48+ const B: number[][] = [[], [], []];
49+ for (let i = 0; i < 9; i++) {
50+ const Hx_x = Hx_xi[i] * xi_x + Hx_eta[i] * eta_x, Hx_y = Hx_xi[i] * xi_y + Hx_eta[i] * eta_y;
51+ const Hy_x = Hy_xi[i] * xi_x + Hy_eta[i] * eta_x, Hy_y = Hy_xi[i] * xi_y + Hy_eta[i] * eta_y;
52+ B[0][i] = Hx_x; B[1][i] = Hy_y; B[2][i] = Hx_y + Hy_x;
53+ }
54+ const w = (0.5 * det) / 3;
55+ for (let i = 0; i < 9; i++) {
56+ for (let j = 0; j < 9; j++) {
57+ let s = 0;
58+ for (let k = 0; k < 3; k++) for (let l = 0; l < 3; l++) s += B[k][i] * Db[k][l] * B[l][j];
59+ Ke[9 * i + j] += w * s;
60+ }
61+ }
62+ }
63+ return Ke;
64+}
src/model/exciter.tsadded+85−0View file
@@ -0,0 +1,85 @@
1+// Exciters and contact patches, as in cymsim/exciter.py: a rigid mass on P power-law springs
2+// F = k delta^alpha along a line (stick) or on a small disk (mallet), released at its hit time
3+// just above the moving surface. All hits are stacked into arrays padded to the largest patch.
4+import type { Hit } from "../config/schema.ts";
5+import type { CymbalProfile } from "./geometry.ts";
6+import { planeResiduals } from "./linalg.ts";
7+import { locate, type Mesh } from "./mesh.ts";
8+
9+export const HOLD_HEIGHT = 1e3;
10+
11+export interface ContactPatch {
12+ hit: Hit; x: Float64Array; y: Float64Array; nodes: Uint32Array; weights: Float64Array; offsets: Float64Array;
13+ kTotal: number; alpha: number; mass: number; gap: number; velocity: number; time: number;
14+}
15+
16+export function patchEnergy(p: ContactPatch): number { return 0.5 * p.mass * p.velocity * p.velocity; }
17+export function maxCompression(p: ContactPatch): number { return Math.pow(((p.alpha + 1) * patchEnergy(p)) / p.kTotal, 1 / (p.alpha + 1)); }
18+
19+/** Contact time against a rigid wall, 2 c(alpha) d_max / v with c = int_0^1 dx / sqrt(1 - x^(alpha+1)). */
20+export function freeContactTime(p: ContactPatch): number {
21+ // substitute x = 1 - s^2 to remove the endpoint singularity, then Simpson on s in [0, 1]
22+ const a1 = p.alpha + 1, n = 2000;
23+ const f = (s: number) => (s === 0 ? 2 / Math.sqrt(a1) : (2 * s) / Math.sqrt(1 - Math.pow(1 - s * s, a1)));
24+ let sum = f(0) + f(1);
25+ for (let i = 1; i < n; i++) sum += (i % 2 ? 4 : 2) * f(i / n);
26+ const c = sum / (3 * n);
27+ return (2 * c * maxCompression(p)) / p.velocity;
28+}
29+
30+export function buildPatch(hit: Hit, mesh: Mesh, profile: CymbalProfile): ContactPatch {
31+ const ex = hit.exciter;
32+ const xs = hit.r * Math.cos(hit.phi), ys = hit.r * Math.sin(hit.phi);
33+ let px: number[], py: number[], kTotal: number, alpha: number;
34+ if (ex.kind === "stick") {
35+ const ang = hit.phi + ex.patch_angle, P = Math.max(1, Math.floor(ex.patch_points));
36+ px = []; py = [];
37+ for (let i = 0; i < P; i++) {
38+ const xi = (P === 1 ? 0 : -0.5 + i / (P - 1)) * ex.patch_length;
39+ px.push(xs + xi * Math.cos(ang)); py.push(ys + xi * Math.sin(ang));
40+ }
41+ kTotal = ex.k; alpha = ex.alpha;
42+ } else {
43+ alpha = ex.alpha;
44+ kTotal = ((alpha + 1) * 0.5 * ex.mass * hit.velocity ** 2) / ex.compression ** (alpha + 1);
45+ px = [xs]; py = [ys];
46+ for (let k = 0; k < 6; k++) { const th = (k * Math.PI) / 3; px.push(xs + ex.radius * Math.cos(th)); py.push(ys + ex.radius * Math.sin(th)); }
47+ }
48+ const P = px.length, nodes = new Uint32Array(3 * P), weights = new Float64Array(3 * P), z0p = new Float64Array(P);
49+ for (let p = 0; p < P; p++) {
50+ const loc = locate(mesh, px[p], py[p]);
51+ nodes.set(loc.nodes, 3 * p); weights.set(loc.weights, 3 * p);
52+ z0p[p] = profile.z0(px[p], py[p]);
53+ }
54+ const offsets = planeResiduals(px.map((x) => x - xs), py.map((y) => y - ys), z0p);
55+ return { hit, x: Float64Array.from(px), y: Float64Array.from(py), nodes, weights, offsets, kTotal, alpha, mass: ex.mass,
56+ gap: ex.gap, velocity: hit.velocity, time: hit.time };
57+}
58+
59+export interface HitSchedule {
60+ patches: ContactPatch[]; K: number; P: number;
61+ nodes: Uint32Array; weights: Float64Array; offsets: Float64Array; // (K, P, 3), (K, P, 3), (K, P)
62+ kPoint: Float64Array; alpha: Float64Array; mass: Float64Array; gap: Float64Array; velocity: Float64Array;
63+ releaseStep: Uint32Array;
64+}
65+
66+export function buildSchedule(patches: ContactPatch[], dt: number): HitSchedule {
67+ // K = 0 (no exciters at all) is a valid schedule here: cymmodes drives the shell through
68+ // initial conditions and distributed forces instead of contacts.
69+ const K = patches.length, P = K ? Math.max(...patches.map((p) => p.x.length)) : 0;
70+ const nodes = new Uint32Array(K * P * 3), weights = new Float64Array(K * P * 3), offsets = new Float64Array(K * P).fill(-1e6);
71+ patches.forEach((p, k) => {
72+ const n = p.x.length;
73+ nodes.set(p.nodes, (k * P) * 3); weights.set(p.weights, (k * P) * 3); offsets.set(p.offsets, k * P);
74+ void n;
75+ });
76+ return {
77+ patches, K, P, nodes, weights, offsets,
78+ kPoint: Float64Array.from(patches, (p) => p.kTotal / p.x.length),
79+ alpha: Float64Array.from(patches, (p) => p.alpha),
80+ mass: Float64Array.from(patches, (p) => p.mass),
81+ gap: Float64Array.from(patches, (p) => p.gap),
82+ velocity: Float64Array.from(patches, (p) => p.velocity),
83+ releaseStep: Uint32Array.from(patches, (p) => Math.round(p.time / dt)),
84+ };
85+}
src/model/forces.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+// Element forces and energies: the analytic gradient of the Marguerre-von Karman membrane
2+// energy plus the DKT bending force, per element, in the node-major order (u, v, w, thx, thy) x 3.
3+// The same expressions are written in WGSL in gpu/shaders.ts; this float64 version is the
4+// reference for the tests and the CPU fallback.
5+import type { Shell } from "./shell.ts";
6+
7+/** Per-element forces f_e = -dU_e/dq_e (15 values) including the sigma1 damping term on w; writes out[15 e ..]. */
8+export function elementForces(sh: Shell, q: Float64Array, v: Float64Array, out: Float64Array) {
9+ const { mesh, nl, z0x, z0y, Cm, Ke, rhoHA } = sh, { tri, area, Gx, Gy } = mesh;
10+ const sigma1 = sh.material.sigma1;
11+ const qe = new Float64Array(15);
12+ for (let e = 0; e < mesh.e; e++) {
13+ const n0 = tri[3 * e], n1 = tri[3 * e + 1], n2 = tri[3 * e + 2];
14+ for (let d = 0; d < 5; d++) { qe[d] = q[5 * n0 + d]; qe[5 + d] = q[5 * n1 + d]; qe[10 + d] = q[5 * n2 + d]; }
15+ const g0x = Gx[3 * e], g1x = Gx[3 * e + 1], g2x = Gx[3 * e + 2], g0y = Gy[3 * e], g1y = Gy[3 * e + 1], g2y = Gy[3 * e + 2];
16+ const ux = g0x * qe[0] + g1x * qe[5] + g2x * qe[10], uy = g0y * qe[0] + g1y * qe[5] + g2y * qe[10];
17+ const vx = g0x * qe[1] + g1x * qe[6] + g2x * qe[11], vy = g0y * qe[1] + g1y * qe[6] + g2y * qe[11];
18+ const wx = g0x * qe[2] + g1x * qe[7] + g2x * qe[12], wy = g0y * qe[2] + g1y * qe[7] + g2y * qe[12];
19+ const ax = nl * wx + z0x[e], ay = nl * wy + z0y[e];
20+ const exx = ux + nl * 0.5 * wx * wx + z0x[e] * wx, eyy = vy + nl * 0.5 * wy * wy + z0y[e] * wy;
21+ const gxy = uy + vx + nl * wx * wy + z0x[e] * wy + z0y[e] * wx;
22+ const C11 = Cm[3 * e], C12 = Cm[3 * e + 1], C33 = Cm[3 * e + 2];
23+ const Nxx = C11 * exx + C12 * eyy, Nyy = C12 * exx + C11 * eyy, Nxy = C33 * gxy;
24+ const A = area[e];
25+ // sigma1: 2 sigma1 (rho h A) G^T (G w_dot)
26+ const vwx = g0x * v[5 * n0 + 2] + g1x * v[5 * n1 + 2] + g2x * v[5 * n2 + 2];
27+ const vwy = g0y * v[5 * n0 + 2] + g1y * v[5 * n1 + 2] + g2y * v[5 * n2 + 2];
28+ const cd = 2 * sigma1 * rhoHA[e];
29+ const gxs = [g0x, g1x, g2x], gys = [g0y, g1y, g2y];
30+ for (let i = 0; i < 3; i++) {
31+ const gx = gxs[i], gy = gys[i];
32+ out[15 * e + 5 * i] = -A * (Nxx * gx + Nxy * gy);
33+ out[15 * e + 5 * i + 1] = -A * (Nyy * gy + Nxy * gx);
34+ out[15 * e + 5 * i + 2] = -A * (Nxx * ax * gx + Nyy * ay * gy + Nxy * (ay * gx + ax * gy)) - cd * (gx * vwx + gy * vwy);
35+ out[15 * e + 5 * i + 3] = 0; out[15 * e + 5 * i + 4] = 0;
36+ }
37+ // bending: -Ke U on the (w, thx, thy) slots
38+ const slot = [2, 3, 4, 7, 8, 9, 12, 13, 14];
39+ for (let i = 0; i < 9; i++) {
40+ let s = 0;
41+ for (let j = 0; j < 9; j++) s += Ke[81 * e + 9 * i + j] * qe[slot[j]];
42+ out[15 * e + slot[i]] -= s;
43+ }
44+ }
45+}
46+
47+/** Membrane and bending strain energies summed over the elements. */
48+export function strainEnergies(sh: Shell, q: ArrayLike<number>): { membrane: number; bending: number } {
49+ const { mesh, nl, z0x, z0y, Cm, Ke } = sh, { tri, area, Gx, Gy } = mesh;
50+ let Em = 0, Eb = 0;
51+ const U = new Float64Array(9);
52+ for (let e = 0; e < mesh.e; e++) {
53+ const ns = [tri[3 * e], tri[3 * e + 1], tri[3 * e + 2]];
54+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0;
55+ for (let i = 0; i < 3; i++) {
56+ const gx = Gx[3 * e + i], gy = Gy[3 * e + i], b = 5 * ns[i];
57+ ux += gx * q[b]; uy += gy * q[b]; vx += gx * q[b + 1]; vy += gy * q[b + 1]; wx += gx * q[b + 2]; wy += gy * q[b + 2];
58+ U[3 * i] = q[b + 2]; U[3 * i + 1] = q[b + 3]; U[3 * i + 2] = q[b + 4];
59+ }
60+ const exx = ux + nl * 0.5 * wx * wx + z0x[e] * wx, eyy = vy + nl * 0.5 * wy * wy + z0y[e] * wy;
61+ const gxy = uy + vx + nl * wx * wy + z0x[e] * wy + z0y[e] * wx;
62+ const C11 = Cm[3 * e], C12 = Cm[3 * e + 1], C33 = Cm[3 * e + 2];
63+ Em += 0.5 * area[e] * (C11 * (exx * exx + eyy * eyy) + 2 * C12 * exx * eyy + C33 * gxy * gxy);
64+ let s = 0;
65+ for (let i = 0; i < 9; i++) for (let j = 0; j < 9; j++) s += U[i] * Ke[81 * e + 9 * i + j] * U[j];
66+ Eb += 0.5 * s;
67+ }
68+ return { membrane: Em, bending: Eb };
69+}
70+
71+/** Assembled internal force f_int(q) (no damping), for tests and checks. */
72+export function internalForce(sh: Shell, q: Float64Array): Float64Array {
73+ const N = sh.mesh.n;
74+ const ef = new Float64Array(15 * sh.mesh.e), f = new Float64Array(5 * N);
75+ elementForces(sh, q, new Float64Array(5 * N), ef);
76+ for (let n = 0; n < N; n++) {
77+ for (let a = sh.adjOffsets[n]; a < sh.adjOffsets[n + 1]; a++) {
78+ const ent = sh.adjEntries[a], e = (ent / 3) | 0, i = ent % 3;
79+ for (let d = 0; d < 5; d++) f[5 * n + d] += ef[15 * e + 5 * i + d];
80+ }
81+ }
82+ return f;
83+}
84+
85+export interface ShellChecks { rigidTiltResidual: number; constantCurvatureError: number; zeroModes: [number, number]; rigidModeRatio: number }
86+
87+/** The element and rigid-mode self-tests of the Python package (float64). */
88+export function shellChecks(sh: Shell): ShellChecks {
89+ const { mesh, Ke, De } = sh, { tri, xy, area } = mesh;
90+ let res = 0, kmax = 0, err = 0;
91+ for (let e = 0; e < mesh.e; e++) {
92+ const x = [0, 1, 2].map((i) => xy[2 * tri[3 * e + i]]), y = [0, 1, 2].map((i) => xy[2 * tri[3 * e + i] + 1]);
93+ const Ut = y.flatMap((yi) => [yi, 1, 0]), Uc = x.flatMap((xi) => [0.5 * xi * xi, 0, -xi]);
94+ let Eb = 0;
95+ for (let i = 0; i < 9; i++) {
96+ let st = 0, sc = 0;
97+ for (let j = 0; j < 9; j++) { const k = Ke[81 * e + 9 * i + j]; st += k * Ut[j]; sc += k * Uc[j]; kmax = Math.max(kmax, Math.abs(k)); }
98+ res = Math.max(res, Math.abs(st)); Eb += 0.5 * Uc[i] * sc;
99+ }
100+ err = Math.max(err, Math.abs(Eb / (0.5 * De[e] * area[e]) - 1));
101+ }
102+ // linearized force on the rigid modes (central difference of the cubic force is exact to O(eps^2))
103+ const N = mesh.n, L = 5 * N, eps = 1e-5;
104+ const rand = new Float64Array(L);
105+ let seed = 12345;
106+ for (let i = 0; i < L; i++) { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; rand[i] = seed / 4294967296 - 0.5; }
107+ const lin = (d: Float64Array) => {
108+ const fp = internalForce(sh, d.map((x) => eps * x)), fm = internalForce(sh, d.map((x) => -eps * x));
109+ let m = 0; for (let i = 0; i < L; i++) m = Math.max(m, Math.abs(fp[i] - fm[i]) / (2 * eps));
110+ return m;
111+ };
112+ let worst = 0;
113+ for (let k = 0; k < 6; k++) {
114+ const r = sh.Rm.subarray(k * L, (k + 1) * L);
115+ let nr = 0, nn = 0; for (let i = 0; i < L; i++) { nr += r[i] * r[i]; nn += rand[i] * rand[i]; }
116+ const scaled = rand.map((x) => (x * Math.sqrt(nr)) / Math.sqrt(nn));
117+ worst = Math.max(worst, lin(Float64Array.from(r)) / lin(scaled));
118+ }
119+ return { rigidTiltResidual: res / kmax, constantCurvatureError: err, zeroModes: sh.zeroModes, rigidModeRatio: worst };
120+}
src/model/geometry.tsadded+78−0View file
@@ -0,0 +1,78 @@
1+// Cymbal mid-surface height z0(x, y) and thickness h(x, y): bow, bell, rim warp and seeded
2+// hammer dents, as in cymsim/geometry.py. The slope that enters the strains is the CST
3+// gradient of the nodal heights (see shell.ts), so no analytic gradient is needed here.
4+import type { Imperfections, Profile } from "../config/schema.ts";
5+import { Rng } from "./rng.ts";
6+
7+export class CymbalProfile {
8+ readonly dentX: Float64Array; readonly dentY: Float64Array; readonly dentAmp: Float64Array; readonly dentSigma: Float64Array;
9+ readonly warpM: Float64Array; readonly warpA: Float64Array; readonly warpPhase: Float64Array;
10+ readonly ovalPhase: number; readonly bellCx: number; readonly bellCy: number;
11+
12+ readonly profile: Profile; readonly imperfections: Imperfections;
13+
14+ constructor(profile: Profile, imperfections: Imperfections) {
15+ this.profile = profile; this.imperfections = imperfections;
16+ const p = profile, im = imperfections, rng = new Rng(im.seed);
17+ const r0 = p.bell_shoulder_radius + im.dent_margin, r1 = p.radius - im.dent_margin;
18+ const n = Math.max(0, Math.floor(im.n_dents));
19+ this.dentX = new Float64Array(n); this.dentY = new Float64Array(n);
20+ this.dentAmp = new Float64Array(n); this.dentSigma = new Float64Array(n);
21+ for (let i = 0; i < n; i++) {
22+ const rr = Math.sqrt(rng.uniform(r0 * r0, r1 * r1)), tt = rng.uniform(0, 2 * Math.PI);
23+ this.dentX[i] = rr * Math.cos(tt); this.dentY[i] = rr * Math.sin(tt);
24+ }
25+ for (let i = 0; i < n; i++) this.dentAmp[i] = im.dent_depth * rng.sign() * rng.uniform(0.5, 1.5);
26+ for (let i = 0; i < n; i++) this.dentSigma[i] = im.dent_sigma * rng.uniform(0.7, 1.3);
27+ const modes = im.rim_warp_modes.map((m) => Math.floor(m));
28+ this.warpM = Float64Array.from(modes);
29+ this.warpA = Float64Array.from(modes.map((m) => (im.rim_warp * 2) / m));
30+ this.warpPhase = Float64Array.from(modes.map(() => rng.uniform(0, 2 * Math.PI)));
31+ this.ovalPhase = rng.uniform(0, 2 * Math.PI);
32+ const bo = rng.uniform(0, 2 * Math.PI);
33+ this.bellCx = im.bell_offset * Math.cos(bo); this.bellCy = im.bell_offset * Math.sin(bo);
34+ }
35+
36+ private shoulderRadius(phi: number): number {
37+ return this.profile.bell_shoulder_radius * (1 + this.imperfections.shoulder_ovality * Math.cos(2 * phi + this.ovalPhase));
38+ }
39+
40+ /** 1 on the bell plateau, 0 beyond the shoulder, C1 cosine ramp in between. */
41+ private bellBlend(rBell: number, rSh: number): number {
42+ const top = this.profile.bell_top_radius;
43+ const t = Math.min(Math.max((rBell - top) / (rSh - top), 0), 1);
44+ return 0.5 * (1 + Math.cos(Math.PI * t));
45+ }
46+
47+ private bowCoordinate(x: number, y: number) {
48+ const r = Math.sqrt(x * x + y * y + 1e-30);
49+ const phi = Math.atan2(y, r < 1e-6 ? 1e-6 : x);
50+ const rSh = this.shoulderRadius(phi);
51+ const rBell = Math.sqrt((x - this.bellCx) ** 2 + (y - this.bellCy) ** 2 + 1e-30);
52+ const s = Math.min(Math.max((this.profile.radius - r) / (this.profile.radius - rSh), 0), 1);
53+ return { s, phi, rBell, rSh };
54+ }
55+
56+ /** Mid-surface height [m]. */
57+ z0(x: number, y: number): number {
58+ const p = this.profile;
59+ const { s, phi, rBell, rSh } = this.bowCoordinate(x, y);
60+ const u = Math.max(1 - s, 1e-12);
61+ let z = p.bow_height * (1 - Math.pow(u, p.bow_power)) + p.bell_height * this.bellBlend(rBell, rSh);
62+ let warp = 0;
63+ for (let k = 0; k < this.warpM.length; k++) warp += this.warpA[k] * Math.cos(this.warpM[k] * phi + this.warpPhase[k]);
64+ z += u * warp;
65+ for (let i = 0; i < this.dentX.length; i++) {
66+ const dx = x - this.dentX[i], dy = y - this.dentY[i], s2 = this.dentSigma[i] * this.dentSigma[i];
67+ z += this.dentAmp[i] * Math.exp(-(dx * dx + dy * dy) / (2 * s2));
68+ }
69+ return z;
70+ }
71+
72+ /** Thickness [m]: linear taper along the bow, smooth step up into the bell. */
73+ thickness(x: number, y: number): number {
74+ const p = this.profile;
75+ const { s, rBell, rSh } = this.bowCoordinate(x, y);
76+ return p.h_rim + (p.h_shoulder - p.h_rim) * s + (p.h_bell - p.h_shoulder) * this.bellBlend(rBell, rSh);
77+ }
78+}
src/model/linalg.tsadded+85−0View file
@@ -0,0 +1,85 @@
1+// Small dense linear algebra: symmetric eigenvalues by cyclic Jacobi, and a least-squares plane fit.
2+
3+/** Eigenvalues (ascending) of a symmetric n x n matrix given row-major; the input is copied. */
4+export function symmetricEigenvalues(A: Float64Array | number[], n: number): Float64Array {
5+ const a = Float64Array.from(A);
6+ for (let sweep = 0; sweep < 60; sweep++) {
7+ let off = 0;
8+ for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) off += a[p * n + q] * a[p * n + q];
9+ let diag = 0;
10+ for (let p = 0; p < n; p++) diag += a[p * n + p] * a[p * n + p];
11+ if (off <= 1e-30 * (diag + 1e-300)) break;
12+ for (let p = 0; p < n; p++) {
13+ for (let q = p + 1; q < n; q++) {
14+ const apq = a[p * n + q];
15+ if (apq === 0) continue;
16+ const app = a[p * n + p], aqq = a[q * n + q];
17+ const theta = (aqq - app) / (2 * apq);
18+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
19+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
20+ for (let k = 0; k < n; k++) {
21+ const akp = a[k * n + p], akq = a[k * n + q];
22+ a[k * n + p] = c * akp - s * akq; a[k * n + q] = s * akp + c * akq;
23+ }
24+ for (let k = 0; k < n; k++) {
25+ const apk = a[p * n + k], aqk = a[q * n + k];
26+ a[p * n + k] = c * apk - s * aqk; a[q * n + k] = s * apk + c * aqk;
27+ }
28+ }
29+ }
30+ }
31+ const ev = new Float64Array(n);
32+ for (let i = 0; i < n; i++) ev[i] = a[i * n + i];
33+ return ev.sort();
34+}
35+
36+/** Solve the 3 x 3 system A x = b (row-major A) by Gaussian elimination with pivoting. */
37+export function solve3(A: number[], b: number[]): number[] {
38+ const M = [A.slice(0, 3).concat(b[0]), A.slice(3, 6).concat(b[1]), A.slice(6, 9).concat(b[2])];
39+ for (let c = 0; c < 3; c++) {
40+ let piv = c;
41+ for (let r = c + 1; r < 3; r++) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
42+ [M[c], M[piv]] = [M[piv], M[c]];
43+ if (Math.abs(M[c][c]) < 1e-300) throw new Error("singular 3x3 system");
44+ for (let r = 0; r < 3; r++) {
45+ if (r === c) continue;
46+ const f = M[r][c] / M[c][c];
47+ for (let k = c; k < 4; k++) M[r][k] -= f * M[c][k];
48+ }
49+ }
50+ return [M[0][3] / M[0][0], M[1][3] / M[1][1], M[2][3] / M[2][2]];
51+}
52+
53+/** Least-squares fit z = a x + b y + c through points (x_i, y_i, z_i); returns the residuals z - fit.
54+ *
55+ * The fit is done by Gram-Schmidt on the columns (1, x, y), dropping a column that is (nearly)
56+ * dependent on the previous ones, which is what numpy's minimum-norm least squares does in the
57+ * Python package: the collinear points of a stick's line get a line fit, a single point a zero
58+ * residual. The normal equations would be singular in those cases. */
59+export function planeResiduals(x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number>): Float64Array {
60+ const n = x.length;
61+ const res = Float64Array.from(z as number[]);
62+ const basis: Float64Array[] = [];
63+ for (const col of [Array.from({ length: n }, () => 1), Array.from(x), Array.from(y)]) {
64+ const v = Float64Array.from(col);
65+ let norm0 = 0;
66+ for (let i = 0; i < n; i++) norm0 += v[i] * v[i];
67+ for (const b of basis) {
68+ let d = 0;
69+ for (let i = 0; i < n; i++) d += b[i] * v[i];
70+ for (let i = 0; i < n; i++) v[i] -= d * b[i];
71+ }
72+ let norm = 0;
73+ for (let i = 0; i < n; i++) norm += v[i] * v[i];
74+ if (norm === 0 || norm <= 1e-20 * norm0) continue;
75+ const inv = 1 / Math.sqrt(norm);
76+ for (let i = 0; i < n; i++) v[i] *= inv;
77+ basis.push(v);
78+ }
79+ for (const b of basis) {
80+ let d = 0;
81+ for (let i = 0; i < n; i++) d += b[i] * res[i];
82+ for (let i = 0; i < n; i++) res[i] -= d * b[i];
83+ }
84+ return res;
85+}
src/model/mesh.tsadded+127−0View file
@@ -0,0 +1,127 @@
1+// Triangular mesh of the projected disk, as in cymsim/mesh.py: nodes on concentric rings,
2+// annuli stitched by an angular merge, optional Laplacian smoothing, and the constant
3+// gradient operators of the linear (CST) interpolation on every triangle.
4+
5+export interface Mesh {
6+ n: number; e: number;
7+ xy: Float64Array; // (N, 2)
8+ tri: Uint32Array; // (E, 3) counter-clockwise
9+ boundary: Uint32Array; // rim node indices in angular order
10+ area: Float64Array; // (E,)
11+ Gx: Float64Array; // (E, 3): d phi/dx = sum_i Gx[e, i] phi_i
12+ Gy: Float64Array;
13+ centroid: Float64Array; // (E, 2)
14+}
15+
16+function stitch(aIdx: Uint32Array, aAng: Float64Array, bIdx: Uint32Array, bAng: Float64Array, out: number[]) {
17+ const na = aIdx.length, nb = bIdx.length, twoPi = 2 * Math.PI;
18+ const bAngU = Array.from(bAng, (t) => (((t - aAng[0]) % twoPi) + twoPi) % twoPi + aAng[0]);
19+ const order = Array.from(bAngU.keys()).sort((i, j) => bAngU[i] - bAngU[j]);
20+ const bi = order.map((i) => bIdx[i]), ba = order.map((i) => bAngU[i]);
21+ const ta = Array.from(aAng); ta.push(aAng[0] + twoPi);
22+ const tb = ba.slice(); tb.push(ba[0] + twoPi);
23+ let i = 0, j = 0;
24+ while (i < na || j < nb) {
25+ const nextA = i < na ? ta[i + 1] : Infinity, nextB = j < nb ? tb[j + 1] : Infinity;
26+ if (nextA <= nextB) { out.push(aIdx[i % na], bi[j % nb], aIdx[(i + 1) % na]); i++; }
27+ else { out.push(aIdx[i % na], bi[j % nb], bi[(j + 1) % nb]); j++; }
28+ }
29+}
30+
31+function smooth(xy: Float64Array, tri: Uint32Array, fixed: Uint32Array, nIter: number): Float64Array {
32+ const N = xy.length / 2, E = tri.length / 3;
33+ const src: number[] = [], dst: number[] = [];
34+ for (let e = 0; e < E; e++) {
35+ for (let k = 0; k < 3; k++) {
36+ const a = tri[3 * e + k], b = tri[3 * e + ((k + 1) % 3)];
37+ src.push(a, b); dst.push(b, a);
38+ }
39+ }
40+ const deg = new Float64Array(N);
41+ for (const s of src) deg[s]++;
42+ const free = new Uint8Array(N).fill(1);
43+ for (const f of fixed) free[f] = 0;
44+ let cur = Float64Array.from(xy);
45+ for (let it = 0; it < nIter; it++) {
46+ const acc = new Float64Array(2 * N);
47+ for (let k = 0; k < src.length; k++) { acc[2 * src[k]] += cur[2 * dst[k]]; acc[2 * src[k] + 1] += cur[2 * dst[k] + 1]; }
48+ const next = Float64Array.from(cur);
49+ for (let n = 0; n < N; n++) if (free[n]) { next[2 * n] = acc[2 * n] / deg[n]; next[2 * n + 1] = acc[2 * n + 1] / deg[n]; }
50+ cur = next;
51+ }
52+ return cur;
53+}
54+
55+export function diskMesh(radius: number, elementSize: number, smoothing = 1): Mesh {
56+ const nRings = Math.max(2, Math.round(radius / elementSize));
57+ const dr = radius / nRings;
58+ const nodes: number[] = [0, 0];
59+ let prevIdx = new Uint32Array([0]), prevAng = new Float64Array([0]);
60+ const tris: number[] = [];
61+ for (let k = 1; k <= nRings; k++) {
62+ const r = k * dr;
63+ const nk = Math.max(6, Math.round((2 * Math.PI * r) / dr));
64+ const ang = new Float64Array(nk), idx = new Uint32Array(nk);
65+ const base = nodes.length / 2;
66+ for (let j = 0; j < nk; j++) {
67+ ang[j] = (2 * Math.PI * (j + 0.5 * (k % 2))) / nk;
68+ idx[j] = base + j;
69+ nodes.push(r * Math.cos(ang[j]), r * Math.sin(ang[j]));
70+ }
71+ if (k === 1) for (let j = 0; j < nk; j++) tris.push(0, idx[j], idx[(j + 1) % nk]);
72+ else stitch(prevIdx, prevAng, idx, ang, tris);
73+ prevIdx = idx; prevAng = ang;
74+ }
75+ let xy: Float64Array = Float64Array.from(nodes);
76+ const tri = Uint32Array.from(tris);
77+ if (smoothing > 0) xy = smooth(xy, tri, prevIdx, smoothing);
78+ const E = tri.length / 3;
79+ for (let e = 0; e < E; e++) {
80+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
81+ const s = (xy[2 * b] - xy[2 * a]) * (xy[2 * c + 1] - xy[2 * a + 1]) - (xy[2 * c] - xy[2 * a]) * (xy[2 * b + 1] - xy[2 * a + 1]);
82+ if (s < 0) { tri[3 * e + 1] = c; tri[3 * e + 2] = b; }
83+ }
84+ return makeMesh(xy, tri, prevIdx);
85+}
86+
87+export function makeMesh(xy: Float64Array, tri: Uint32Array, boundary: Uint32Array): Mesh {
88+ const N = xy.length / 2, E = tri.length / 3;
89+ const area = new Float64Array(E), Gx = new Float64Array(3 * E), Gy = new Float64Array(3 * E), centroid = new Float64Array(2 * E);
90+ for (let e = 0; e < E; e++) {
91+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
92+ const x0 = xy[2 * a], y0 = xy[2 * a + 1], x1 = xy[2 * b], y1 = xy[2 * b + 1], x2 = xy[2 * c], y2 = xy[2 * c + 1];
93+ const A = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0));
94+ if (!(A > 0)) throw new Error(`element ${e} has non-positive area`);
95+ area[e] = A;
96+ Gx[3 * e] = (y1 - y2) / (2 * A); Gx[3 * e + 1] = (y2 - y0) / (2 * A); Gx[3 * e + 2] = (y0 - y1) / (2 * A);
97+ Gy[3 * e] = (x2 - x1) / (2 * A); Gy[3 * e + 1] = (x0 - x2) / (2 * A); Gy[3 * e + 2] = (x1 - x0) / (2 * A);
98+ centroid[2 * e] = (x0 + x1 + x2) / 3; centroid[2 * e + 1] = (y0 + y1 + y2) / 3;
99+ }
100+ return { n: N, e: E, xy, tri, boundary, area, Gx, Gy, centroid };
101+}
102+
103+/** Containing element and barycentric weights of a point; throws when outside the mesh. */
104+export function locate(m: Mesh, px: number, py: number): { element: number; nodes: [number, number, number]; weights: [number, number, number] } {
105+ const { xy, tri, area } = m;
106+ for (let e = 0; e < m.e; e++) {
107+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
108+ const x0 = xy[2 * a], y0 = xy[2 * a + 1], x1 = xy[2 * b], y1 = xy[2 * b + 1], x2 = xy[2 * c], y2 = xy[2 * c + 1];
109+ const a2 = 2 * area[e];
110+ const l1 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / a2;
111+ const l2 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / a2;
112+ const l3 = 1 - l1 - l2;
113+ if (l1 >= -1e-9 && l2 >= -1e-9 && l3 >= -1e-9) return { element: e, nodes: [a, b, c], weights: [l1, l2, l3] };
114+ }
115+ throw new Error(`point (${px.toFixed(4)}, ${py.toFixed(4)}) lies outside the mesh`);
116+}
117+
118+export function edgeLengths(m: Mesh): Float64Array {
119+ const L = new Float64Array(3 * m.e);
120+ for (let e = 0; e < m.e; e++) {
121+ for (let k = 0; k < 3; k++) {
122+ const a = m.tri[3 * e + k], b = m.tri[3 * e + ((k + 1) % 3)];
123+ L[3 * e + k] = Math.hypot(m.xy[2 * a] - m.xy[2 * b], m.xy[2 * a + 1] - m.xy[2 * b + 1]);
124+ }
125+ }
126+ return L;
127+}
src/model/radiation.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+// Rayleigh-integral listener with the rim-diffraction back term, as in cymsim/radiation.py, but
2+// with the retarded-time sum evaluated at the audio rate: the per-element acceleration is
3+// decimated to the audio rate by a windowed-sinc low-pass spanning DECIMATION_SPAN audio samples
4+// (applied in the time loop through per-element accumulator slots) and delayed by an integer
5+// plus a fractional number of audio samples with linear interpolation. The per-element
6+// coefficients, the delays and the decimation taps are computed here.
7+import type { Listener } from "../config/schema.ts";
8+import type { CymbalProfile } from "./geometry.ts";
9+import type { Mesh } from "./mesh.ts";
10+
11+/** Audio samples spanned by the decimation kernel (accumulator slots per element). */
12+export const DECIMATION_SPAN = 8;
13+
14+/**
15+ * Decimation taps: taps[w * m + kk] is the weight with which the sub-step of phase kk (0..m-1)
16+ * within audio sample j0 contributes to audio sample j0 + w - (W/2 - 1), for slot w = 0..W-1.
17+ * The kernel is a Blackman-windowed sinc with cutoff 0.45 fs, normalised to unit DC gain.
18+ */
19+export function decimationTaps(m: number, W = DECIMATION_SPAN): Float64Array {
20+ const taps = new Float64Array(W * m), half = (W * m) / 2, fc = 0.45 / m;
21+ let sum = 0;
22+ for (let w = 0; w < W; w++) for (let kk = 0; kk < m; kk++) {
23+ const d = kk + (W / 2 - 1 - w) * m; // sub-step offset from the sample centre
24+ const x = Math.PI * fc * d, sinc = d === 0 ? 1 : Math.sin(2 * x) / (2 * x);
25+ const u = (d + half) / (2 * half), win = 0.42 - 0.5 * Math.cos(2 * Math.PI * u) + 0.08 * Math.cos(4 * Math.PI * u);
26+ taps[w * m + kk] = sinc * Math.max(win, 0);
27+ sum += taps[w * m + kk];
28+ }
29+ for (let i = 0; i < taps.length; i++) taps[i] /= sum;
30+ return taps;
31+}
32+
33+export interface Radiation {
34+ coefF: Float64Array; coefB: Float64Array; // rho A / (2 pi r), front and back
35+ i0F: Uint32Array; frF: Float64Array; // delay in audio samples: integer and fraction
36+ i0B: Uint32Array; frB: Float64Array;
37+ ringLength: number; // history ring length in audio samples
38+ taps: Float64Array; // (DECIMATION_SPAN, substeps) decimation weights
39+ rFront: Float64Array; rBack: Float64Array;
40+}
41+
42+export function buildRadiation(mesh: Mesh, profile: CymbalProfile, z0Node: Float64Array, listener: Listener, fs: number, substeps: number, nRim = 720): Radiation {
43+ const E = mesh.e, [Lx, Ly, Lz] = listener.position;
44+ const cx = new Float64Array(E), cy = new Float64Array(E), cz = new Float64Array(E);
45+ for (let e = 0; e < E; e++) {
46+ cx[e] = mesh.centroid[2 * e]; cy[e] = mesh.centroid[2 * e + 1];
47+ cz[e] = (z0Node[mesh.tri[3 * e]] + z0Node[mesh.tri[3 * e + 1]] + z0Node[mesh.tri[3 * e + 2]]) / 3;
48+ }
49+ let R = 0;
50+ for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
51+ const rimX = new Float64Array(nRim), rimY = new Float64Array(nRim), rimZ = new Float64Array(nRim), d2 = new Float64Array(nRim);
52+ for (let j = 0; j < nRim; j++) {
53+ const phi = (2 * Math.PI * j) / nRim;
54+ rimX[j] = R * Math.cos(phi); rimY[j] = R * Math.sin(phi); rimZ[j] = profile.z0(rimX[j], rimY[j]);
55+ d2[j] = Math.hypot(rimX[j] - Lx, rimY[j] - Ly, rimZ[j] - Lz);
56+ }
57+ const rFront = new Float64Array(E), rBack = new Float64Array(E);
58+ const coefF = new Float64Array(E), coefB = new Float64Array(E), i0F = new Uint32Array(E), frF = new Float64Array(E);
59+ const i0B = new Uint32Array(E), frB = new Float64Array(E);
60+ const { c_air: c, rho_air: rho, back_diffraction_gain: g } = listener;
61+ let maxI0 = 0;
62+ for (let e = 0; e < E; e++) {
63+ rFront[e] = Math.hypot(Lx - cx[e], Ly - cy[e], Lz - cz[e]);
64+ let best = Infinity;
65+ for (let j = 0; j < nRim; j++) best = Math.min(best, Math.hypot(cx[e] - rimX[j], cy[e] - rimY[j], cz[e] - rimZ[j]) + d2[j]);
66+ rBack[e] = best;
67+ coefF[e] = (rho * mesh.area[e]) / (2 * Math.PI * rFront[e]);
68+ coefB[e] = (g * rho * mesh.area[e]) / (2 * Math.PI * rBack[e]);
69+ const dF = (rFront[e] / c) * fs, dB = (rBack[e] / c) * fs;
70+ i0F[e] = Math.floor(dF); frF[e] = dF - i0F[e]; i0B[e] = Math.floor(dB); frB[e] = dB - i0B[e];
71+ maxI0 = Math.max(maxI0, i0F[e], i0B[e]);
72+ }
73+ return { coefF, coefB, i0F, frF, i0B, frB, ringLength: maxI0 + 4, taps: decimationTaps(substeps), rFront, rBack };
74+}
src/model/rng.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+// Seedable pseudo-random generator for the imperfections: xoshiro128** seeded through splitmix32.
2+// It is not numpy's PCG64, so a seed gives a different dent pattern than the Python package
3+// (same statistics, different sample); within the web app a seed is reproducible.
4+
5+export class Rng {
6+ private s = new Uint32Array(4);
7+
8+ constructor(seed: number) {
9+ let x = (seed >>> 0) ^ 0x9e3779b9;
10+ for (let i = 0; i < 4; i++) {
11+ x = (x + 0x9e3779b9) >>> 0;
12+ let z = x;
13+ z = Math.imul(z ^ (z >>> 16), 0x85ebca6b) >>> 0;
14+ z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35) >>> 0;
15+ this.s[i] = (z ^ (z >>> 16)) >>> 0;
16+ }
17+ }
18+
19+ next32(): number {
20+ const s = this.s;
21+ const r = Math.imul(rotl(Math.imul(s[1], 5) >>> 0, 7), 9) >>> 0;
22+ const t = (s[1] << 9) >>> 0;
23+ s[2] ^= s[0]; s[3] ^= s[1]; s[1] ^= s[2]; s[0] ^= s[3];
24+ s[2] ^= t;
25+ s[3] = rotl(s[3], 11);
26+ return r;
27+ }
28+
29+ /** Uniform double in [0, 1) with 53 random bits. */
30+ random(): number {
31+ const hi = this.next32() >>> 5, lo = this.next32() >>> 6;
32+ return (hi * 67108864 + lo) / 9007199254740992;
33+ }
34+
35+ uniform(lo: number, hi: number): number { return lo + (hi - lo) * this.random(); }
36+ sign(): number { return this.random() < 0.5 ? -1 : 1; }
37+}
38+
39+function rotl(x: number, k: number): number { return ((x << k) | (x >>> (32 - k))) >>> 0; }
src/model/setup.tsadded+61−0View file
@@ -0,0 +1,61 @@
1+// Everything the time loop needs, built once from a configuration: profile, mesh, shell, time
2+// step, hit schedule and radiation, plus a human-readable summary of the set-up.
3+import type { SimulationConfig } from "../config/schema.ts";
4+import { buildPatch, buildSchedule, freeContactTime, patchEnergy, type ContactPatch, type HitSchedule } from "./exciter.ts";
5+import { CymbalProfile } from "./geometry.ts";
6+import { diskMesh, type Mesh } from "./mesh.ts";
7+import { buildRadiation, type Radiation } from "./radiation.ts";
8+import { buildShell, type Shell } from "./shell.ts";
9+
10+export interface Setup {
11+ config: SimulationConfig; profile: CymbalProfile; mesh: Mesh; shell: Shell;
12+ fs: number; dt: number; substeps: number; nSamples: number; nSteps: number;
13+ patches: ContactPatch[]; schedule: HitSchedule; radiation: Radiation;
14+ summary: SetupSummary;
15+}
16+
17+export interface SetupSummary {
18+ nodes: number; elements: number; dofs: number; mass: number; height: number; rimWarp: number; dents: number;
19+ hMin: number; hMax: number; maxSlope: number; slopeWarning: boolean; omegaMax: number; limitingR: number; limitingH: number;
20+ dt: number; substeps: number; nSteps: number; setupSeconds: number;
21+ hits: { time: number; kind: string; points: number; r: number; kTotal: number; alpha: number; mass: number; velocity: number;
22+ energy: number; freeContact: number; directBelow: number; late: boolean }[];
23+}
24+
25+/** Build the set-up; ``progress`` is awaited between phases so a UI can repaint. */
26+export async function buildSetup(config: SimulationConfig, progress?: (phase: string) => Promise<void> | void): Promise<Setup> {
27+ const t0 = performance.now();
28+ const step = async (s: string) => { if (progress) await progress(s); };
29+ await step("profile and mesh");
30+ const profile = new CymbalProfile(config.profile, config.imperfections);
31+ const mesh = diskMesh(config.profile.radius, config.mesh.element_size, config.mesh.smoothing);
32+ await step(`shell assembly (${mesh.n} nodes, ${mesh.e} elements)`);
33+ const shell = buildShell(mesh, profile, config.material, config.numerics);
34+ const num = config.numerics, fs = num.fs;
35+ const substeps = Math.ceil(1 / (fs * num.cfl_safety * shell.dtStable));
36+ const dt = 1 / (fs * substeps);
37+ const nSamples = Math.ceil(num.duration * fs);
38+ const nSteps = nSamples * substeps;
39+ await step("hits and radiation");
40+ const patches = config.hits.map((h) => buildPatch(h, mesh, profile));
41+ const schedule = buildSchedule(patches, dt);
42+ const radiation = buildRadiation(mesh, profile, shell.z0Node, config.listener, fs, substeps);
43+ let zMax = -Infinity, rimMin = Infinity, rimMax = -Infinity, hMin = Infinity, hMax = -Infinity;
44+ for (const z of shell.z0Node) zMax = Math.max(zMax, z);
45+ for (const b of mesh.boundary) { rimMin = Math.min(rimMin, shell.z0Node[b]); rimMax = Math.max(rimMax, shell.z0Node[b]); }
46+ for (const h of shell.hE) { hMin = Math.min(hMin, h); hMax = Math.max(hMax, h); }
47+ const le = shell.limitingElement;
48+ const summary: SetupSummary = {
49+ nodes: mesh.n, elements: mesh.e, dofs: 5 * mesh.n, mass: shell.mass, height: zMax, rimWarp: rimMax - rimMin,
50+ dents: profile.dentX.length, hMin, hMax, maxSlope: shell.maxSlope, slopeWarning: shell.maxSlope > config.profile.max_slope,
51+ omegaMax: shell.omegaMax, limitingR: Math.hypot(mesh.centroid[2 * le], mesh.centroid[2 * le + 1]), limitingH: shell.hE[le],
52+ dt, substeps, nSteps, setupSeconds: 0,
53+ hits: patches.map((p) => {
54+ const tau = freeContactTime(p);
55+ return { time: p.time, kind: p.hit.exciter.kind, points: p.x.length, r: p.hit.r, kTotal: p.kTotal, alpha: p.alpha, mass: p.mass,
56+ velocity: p.velocity, energy: patchEnergy(p), freeContact: tau, directBelow: 0.5 / tau, late: p.time >= num.duration };
57+ }),
58+ };
59+ summary.setupSeconds = (performance.now() - t0) / 1000;
60+ return { config, profile, mesh, shell, fs, dt, substeps, nSamples, nSteps, patches, schedule, radiation, summary };
61+}
src/model/shell.tsadded+135−0View file
@@ -0,0 +1,135 @@
1+// The discrete shell, as in cymsim/shell.py: thickness and height sampled on the mesh, the CST
2+// slope of the nodal heights, element stiffnesses, lumped mass with the inflated rotational
3+// inertia, the six M-orthonormal rigid-body modes, the node-to-element adjacency used by the
4+// gather of element forces, and the Irons element bound on the largest eigenfrequency that sets
5+// the stable time step. Everything here is float64 and computed once per configuration.
6+import type { Material, Numerics } from "../config/schema.ts";
7+import { dktStiffness } from "./dkt.ts";
8+import type { CymbalProfile } from "./geometry.ts";
9+import { symmetricEigenvalues } from "./linalg.ts";
10+import type { Mesh } from "./mesh.ts";
11+
12+export const N_DOF = 5; // (u, v, w, theta_x, theta_y) per node
13+
14+export interface Shell {
15+ mesh: Mesh; material: Material; numerics: Numerics; nl: number;
16+ hE: Float64Array; hNode: Float64Array; z0Node: Float64Array; z0x: Float64Array; z0y: Float64Array;
17+ De: Float64Array; Cm: Float64Array; // (E,) and (E, 3) = (C11, C12, C33)
18+ Ke: Float64Array; // (E, 81)
19+ rhoHA: Float64Array; // (E,) rho h A
20+ M: Float64Array; Minv: Float64Array; // (N, 5)
21+ Rm: Float64Array; // (6, N, 5), M-orthonormal
22+ adjOffsets: Uint32Array; adjEntries: Uint32Array; // CSR: node -> list of (3 e + local index)
23+ mass: number; maxSlope: number; omegaMax: number; dtStable: number; limitingElement: number;
24+ zeroModes: [number, number]; // min and max count of zero-energy element modes (expect 6)
25+}
26+
27+export function buildShell(mesh: Mesh, profile: CymbalProfile, material: Material, numerics: Numerics): Shell {
28+ const N = mesh.n, E = mesh.e, { xy, tri, area, Gx, Gy } = mesh;
29+ const nl = numerics.nonlinear ? 1 : 0;
30+ const hE = new Float64Array(E), hNode = new Float64Array(N), z0Node = new Float64Array(N);
31+ for (let e = 0; e < E; e++) hE[e] = profile.thickness(mesh.centroid[2 * e], mesh.centroid[2 * e + 1]);
32+ for (let n = 0; n < N; n++) { hNode[n] = profile.thickness(xy[2 * n], xy[2 * n + 1]); z0Node[n] = profile.z0(xy[2 * n], xy[2 * n + 1]); }
33+ const z0x = new Float64Array(E), z0y = new Float64Array(E);
34+ let maxSlope = 0;
35+ for (let e = 0; e < E; e++) {
36+ let sx = 0, sy = 0;
37+ for (let i = 0; i < 3; i++) { const z = z0Node[tri[3 * e + i]]; sx += Gx[3 * e + i] * z; sy += Gy[3 * e + i] * z; }
38+ z0x[e] = sx; z0y[e] = sy; maxSlope = Math.max(maxSlope, Math.hypot(sx, sy));
39+ }
40+ const { E: Ey, nu, rho } = material;
41+ const De = new Float64Array(E), Cm = new Float64Array(3 * E), Ke = new Float64Array(81 * E), rhoHA = new Float64Array(E);
42+ for (let e = 0; e < E; e++) {
43+ De[e] = (Ey * hE[e] ** 3) / (12 * (1 - nu * nu));
44+ const c = (Ey * hE[e]) / (1 - nu * nu);
45+ Cm[3 * e] = c; Cm[3 * e + 1] = c * nu; Cm[3 * e + 2] = c * 0.5 * (1 - nu);
46+ rhoHA[e] = rho * hE[e] * area[e];
47+ const xe = [xy[2 * tri[3 * e]], xy[2 * tri[3 * e + 1]], xy[2 * tri[3 * e + 2]]];
48+ const ye = [xy[2 * tri[3 * e] + 1], xy[2 * tri[3 * e + 1] + 1], xy[2 * tri[3 * e + 2] + 1]];
49+ Ke.set(dktStiffness(xe, ye, De[e], nu), 81 * e);
50+ }
51+ // lumped mass
52+ const M = new Float64Array(N_DOF * N);
53+ const mE = new Float64Array(E), IE = new Float64Array(E);
54+ for (let e = 0; e < E; e++) {
55+ mE[e] = (rho * hE[e] * area[e]) / 3;
56+ IE[e] = mE[e] * ((hE[e] * hE[e]) / 12 + numerics.rot_inertia_factor * area[e]);
57+ for (let k = 0; k < 3; k++) {
58+ const n = tri[3 * e + k];
59+ M[5 * n] += mE[e] * numerics.inplane_mass_scale; M[5 * n + 1] += mE[e] * numerics.inplane_mass_scale;
60+ M[5 * n + 2] += mE[e]; M[5 * n + 3] += IE[e]; M[5 * n + 4] += IE[e];
61+ }
62+ }
63+ const Minv = M.map((m) => 1 / m);
64+ let mass = 0;
65+ for (let n = 0; n < N; n++) mass += M[5 * n + 2];
66+ // rigid-body modes of the curved shell, M-orthonormalised by Gram-Schmidt
67+ let cx = 0, cy = 0;
68+ for (let n = 0; n < N; n++) { cx += xy[2 * n]; cy += xy[2 * n + 1]; }
69+ cx /= N; cy /= N;
70+ const Rm = new Float64Array(6 * N_DOF * N);
71+ const set = (k: number, n: number, d: number, val: number) => { Rm[(k * N + n) * N_DOF + d] = val; };
72+ for (let n = 0; n < N; n++) {
73+ const X = xy[2 * n] - cx, Y = xy[2 * n + 1] - cy, z = z0Node[n];
74+ set(0, n, 0, 1); set(1, n, 1, 1);
75+ set(2, n, 0, -Y); set(2, n, 1, X);
76+ set(3, n, 2, 1);
77+ set(4, n, 2, Y); set(4, n, 3, 1); set(4, n, 1, -z);
78+ set(5, n, 2, -X); set(5, n, 4, 1); set(5, n, 0, z);
79+ }
80+ const L = N_DOF * N;
81+ const mdot = (a: number, b: number) => { let s = 0; for (let i = 0; i < L; i++) s += Rm[a * L + i] * M[i] * Rm[b * L + i]; return s; };
82+ for (let k = 0; k < 6; k++) {
83+ for (let l = 0; l < k; l++) { const c = mdot(l, k); for (let i = 0; i < L; i++) Rm[k * L + i] -= c * Rm[l * L + i]; }
84+ const nrm = Math.sqrt(mdot(k, k));
85+ for (let i = 0; i < L; i++) Rm[k * L + i] /= nrm;
86+ }
87+ // adjacency (CSR)
88+ const deg = new Uint32Array(N);
89+ for (let e = 0; e < E; e++) for (let k = 0; k < 3; k++) deg[tri[3 * e + k]]++;
90+ const adjOffsets = new Uint32Array(N + 1);
91+ for (let n = 0; n < N; n++) adjOffsets[n + 1] = adjOffsets[n] + deg[n];
92+ const adjEntries = new Uint32Array(adjOffsets[N]), fill = new Uint32Array(N);
93+ for (let e = 0; e < E; e++) for (let k = 0; k < 3; k++) { const n = tri[3 * e + k]; adjEntries[adjOffsets[n] + fill[n]++] = 3 * e + k; }
94+ // Irons bound: lambda_max(M^-1 K) <= max_e lambda_max(M_e^-1 K_e), K_e the element tangent at rest
95+ let lamMax = 0, limitingElement = 0, zmin = 15, zmax = 0;
96+ const S = new Float64Array(225), me = new Float64Array(15);
97+ for (let e = 0; e < E; e++) {
98+ elementTangent(e, Gx, Gy, area[e], z0x[e], z0y[e], Cm, Ke, S);
99+ for (let i = 0; i < 3; i++) {
100+ me[5 * i] = mE[e] * numerics.inplane_mass_scale; me[5 * i + 1] = mE[e] * numerics.inplane_mass_scale;
101+ me[5 * i + 2] = mE[e]; me[5 * i + 3] = IE[e]; me[5 * i + 4] = IE[e];
102+ }
103+ for (let i = 0; i < 15; i++) for (let j = 0; j < 15; j++) S[15 * i + j] /= Math.sqrt(me[i] * me[j]);
104+ const ev = symmetricEigenvalues(S, 15);
105+ const top = ev[14];
106+ let nz = 0; for (const v of ev) if (v < 1e-9 * top) nz++;
107+ zmin = Math.min(zmin, nz); zmax = Math.max(zmax, nz);
108+ if (top > lamMax) { lamMax = top; limitingElement = e; }
109+ }
110+ const omegaMax = Math.sqrt(lamMax);
111+ return { mesh, material, numerics, nl, hE, hNode, z0Node, z0x, z0y, De, Cm, Ke, rhoHA, M, Minv, Rm, adjOffsets, adjEntries,
112+ mass, maxSlope, omegaMax, dtStable: 2 / omegaMax, limitingElement, zeroModes: [zmin, zmax] };
113+}
114+
115+/** 15 x 15 tangent stiffness of one element at rest: A B^T C B (membrane, linear part) plus DKT on the bending slots. */
116+export function elementTangent(e: number, Gx: Float64Array, Gy: Float64Array, A: number, z0x: number, z0y: number,
117+ Cm: Float64Array, Ke: Float64Array, out: Float64Array) {
118+ out.fill(0);
119+ // B rows: exx, eyy, gxy over the 15 DOFs (node-major u, v, w, thx, thy)
120+ const B = [new Float64Array(15), new Float64Array(15), new Float64Array(15)];
121+ for (let i = 0; i < 3; i++) {
122+ const gx = Gx[3 * e + i], gy = Gy[3 * e + i];
123+ B[0][5 * i] = gx; B[0][5 * i + 2] = z0x * gx;
124+ B[1][5 * i + 1] = gy; B[1][5 * i + 2] = z0y * gy;
125+ B[2][5 * i] = gy; B[2][5 * i + 1] = gx; B[2][5 * i + 2] = z0x * gy + z0y * gx;
126+ }
127+ const C = [[Cm[3 * e], Cm[3 * e + 1], 0], [Cm[3 * e + 1], Cm[3 * e], 0], [0, 0, Cm[3 * e + 2]]];
128+ for (let i = 0; i < 15; i++) for (let j = 0; j < 15; j++) {
129+ let s = 0;
130+ for (let k = 0; k < 3; k++) for (let l = 0; l < 3; l++) s += B[k][i] * C[k][l] * B[l][j];
131+ out[15 * i + j] = A * s;
132+ }
133+ const slot = [2, 3, 4, 7, 8, 9, 12, 13, 14]; // (w, thx, thy) x 3 within the 15
134+ for (let i = 0; i < 9; i++) for (let j = 0; j < 9; j++) out[15 * slot[i] + slot[j]] += Ke[81 * e + 9 * i + j];
135+}
src/model/simulator.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+// The interface shared by the CPU and the GPU time loops, extended (relative to cymsimweb) with
2+// modal drive and projection: a backend can start from a prescribed state, apply a distributed
3+// harmonic force, and return snapshots of the state projected onto a set of transverse modes.
4+export interface ChunkResult {
5+ firstSample: number; // audio index of pressureFront[0]
6+ pressureFront: Float32Array; // baffled Rayleigh pressure [Pa]
7+ pressureBack: Float32Array; // diffracted back term [Pa]; unbaffled = front - back
8+ frames: Float32Array[]; // displacement w at every node, captured every `movieEvery` audio samples
9+ frameSamples: number[]; // audio sample index of each frame
10+ proj: Float32Array[]; // modal snapshots (2 Nm each: q_m then qdot_m), every `projEvery` audio samples
11+ projSamples: number[]; // audio sample index of each modal snapshot
12+ trace: Float32Array; // one mode's coordinate q_m at every audio sample (empty when off)
13+ traceBase: number; // audio sample index of trace[0]
14+}
15+
16+export interface HitState { peakForce: number; contactTime: number; velocity: number; height: number }
17+
18+export interface Diagnostics {
19+ time: number; kinetic: number; membrane: number; bending: number; exciters: number; maxWoverH: number; hits: HitState[];
20+}
21+
22+/** A distributed transverse drive f_w(n, t) = fNode[n] * env(t) * sin(omega t), env a linear ramp
23+ * over rampSteps sub-steps, cut off at stopStep. fNode is in Newtons per node. */
24+export interface DriveParams {
25+ fNode: Float64Array; // (N,) nodal force amplitudes on w
26+ omega: number; // [rad/s]
27+ rampSteps: number; // sub-steps of the linear onset ramp (>= 1)
28+ stopStep: number; // first sub-step at which the drive is off
29+}
30+
31+/** Projection of the running state onto Nm transverse modes: q_m = sum_n phiM[m N + n] w_n and
32+ * the same sum over the velocities. phiM is the mass-weighted mode matrix (mode-major), so with
33+ * mass-normalised shapes the q_m are the modal coordinates. */
34+export interface ProjectionSpec {
35+ phiM: Float32Array; // (Nm, N) mode-major, phi_m(n) * Mw(n)
36+ nModes: number;
37+ every: number; // audio samples between snapshots
38+}
39+
40+export interface BackendOptions {
41+ movieEvery?: number; // > 0 captures w every that many audio samples
42+ drive?: DriveParams;
43+ projection?: ProjectionSpec;
44+ /** Mode whose coordinate is captured at the full audio rate (needs `projection`), so its true
45+ * oscillation frequency, unlike the energy envelopes, can be read off an STFT. */
46+ traceMode?: number;
47+}
48+
49+export interface SimulatorBackend {
50+ readonly kind: "cpu" | "gpu";
51+ readonly samplesDone: number;
52+ /** Largest chunk (audio samples) runChunk accepts. */
53+ readonly maxChunk: number;
54+ /** Set the state before the run: q and v are (5 N) node-major (u, v, w, thx, thy). */
55+ setState(q: Float64Array, v: Float64Array): void;
56+ /** Advance by nSamples audio samples and return the pressure samples completed meanwhile. */
57+ runChunk(nSamples: number): Promise<ChunkResult>;
58+ diagnostics(): Promise<Diagnostics>;
59+ /** Transverse displacement w at every node, for the plate view. */
60+ displacement(): Promise<Float32Array>;
61+ dispose(): void;
62+}
src/modes.tsadded+204−0View file
@@ -0,0 +1,204 @@
1+/** The modal data file: what the precompute produces and the player consumes.
2+ *
3+ * Layout: magic "CYMB", uint32 LE header length, UTF-8 JSON header, raw little-endian arrays at
4+ * the byte offsets the header lists. Arrays are copied out of the file buffer so that every typed
5+ * array is correctly aligned regardless of the offsets.
6+ *
7+ * The file describes the shell's *shape*, not its material or its listener. Frequencies are
8+ * stored for the reference Young's modulus and density in the header; the damping is stored as
9+ * the quadratic form q_m of the sigma1 loss term rather than as a decay rate; the gains are
10+ * stored for the listener that was current when the file was made but can be recomputed. This is
11+ * what lets the design panel change damping, material and listener without a new eigensolve.
12+ * See the notes in precompute/pipeline.ts for the scaling relations.
13+ */
14+
15+import type { Exciter } from "./config/schema.ts";
16+
17+export const FORMAT_VERSION = 2;
18+
19+export interface ModesMeta {
20+ format_version: number;
21+ n_nodes: number;
22+ n_elements: number;
23+ n_modes: number;
24+ fmax: number;
25+ radius: number;
26+ mass: number;
27+ /** Young's modulus and density the frequencies and mode shapes were computed at. */
28+ E0: number;
29+ rho0: number;
30+ config: Record<string, unknown>;
31+ playback_element_size: number;
32+ exciters: { stick: Exciter; mallet: Exciter };
33+ notes: string;
34+}
35+
36+export interface ModesData {
37+ meta: ModesMeta;
38+ nNodes: number;
39+ nElements: number;
40+ nModes: number;
41+ radius: number;
42+ xy: Float32Array; // (N, 2)
43+ tri: Int32Array; // (E, 3)
44+ z0: Float32Array; // (N,)
45+ h: Float32Array; // (N,)
46+ freq: Float64Array; // (Nm,) at E0, rho0
47+ dampQ: Float64Array; // (Nm,) sigma_m = sigma0 + sigma1 * dampQ[m]
48+ gainFront: Float32Array; // (Nm, 2) re, im, for the stored listener
49+ gainBack: Float32Array; // (Nm, 2)
50+ phiScale: Float32Array; // (Nm,)
51+ phiQ: Int16Array; // (N, Nm) node-major
52+}
53+
54+interface ArrayEntry {
55+ dtype: string;
56+ shape: number[];
57+ offset: number;
58+ nbytes: number;
59+}
60+
61+const CTORS: Record<string, new (b: ArrayBuffer) => ArrayBufferView> = {
62+ float32: Float32Array,
63+ float64: Float64Array,
64+ int16: Int16Array,
65+ int32: Int32Array,
66+};
67+
68+export async function loadModes(url: string, onProgress?: (loaded: number, total: number) => void): Promise<ModesData> {
69+ const resp = await fetch(url);
70+ if (!resp.ok) throw new Error(`could not fetch ${url}: ${resp.status}`);
71+ const total = Number(resp.headers.get("content-length") ?? 0);
72+ let buffer: ArrayBuffer;
73+ if (resp.body && onProgress) {
74+ const reader = resp.body.getReader();
75+ const chunks: Uint8Array[] = [];
76+ let loaded = 0;
77+ for (;;) {
78+ const { done, value } = await reader.read();
79+ if (done) break;
80+ chunks.push(value);
81+ loaded += value.length;
82+ onProgress(loaded, total);
83+ }
84+ const out = new Uint8Array(loaded);
85+ let pos = 0;
86+ for (const c of chunks) {
87+ out.set(c, pos);
88+ pos += c.length;
89+ }
90+ buffer = out.buffer;
91+ } else {
92+ buffer = await resp.arrayBuffer();
93+ }
94+ return parseModes(buffer);
95+}
96+
97+export function parseModes(buffer: ArrayBuffer): ModesData {
98+ const bytes = new Uint8Array(buffer);
99+ const magic = String.fromCharCode(...bytes.subarray(0, 4));
100+ if (magic !== "CYMB") throw new Error("not a cymplay modes file");
101+ const hlen = new DataView(buffer).getUint32(4, true);
102+ const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + hlen)));
103+ const base = 8 + hlen;
104+ const arrays: Record<string, ArrayEntry> = header.arrays;
105+ delete header.arrays;
106+ const meta = header as ModesMeta;
107+ if (meta.format_version !== FORMAT_VERSION) {
108+ throw new Error(`modes file is version ${meta.format_version}, this build reads version ${FORMAT_VERSION}; recompute it`);
109+ }
110+ const take = (name: string) => {
111+ const e = arrays[name];
112+ if (!e) throw new Error(`modes file lacks array ${name}`);
113+ const ctor = CTORS[e.dtype];
114+ if (!ctor) throw new Error(`unsupported dtype ${e.dtype} for ${name}`);
115+ return new ctor(buffer.slice(base + e.offset, base + e.offset + e.nbytes));
116+ };
117+ return {
118+ meta,
119+ nNodes: meta.n_nodes,
120+ nElements: meta.n_elements,
121+ nModes: meta.n_modes,
122+ radius: meta.radius,
123+ xy: take("xy") as Float32Array,
124+ tri: take("tri") as Int32Array,
125+ z0: take("z0") as Float32Array,
126+ h: take("h") as Float32Array,
127+ freq: take("freq") as Float64Array,
128+ dampQ: take("damp_q") as Float64Array,
129+ gainFront: take("gain_front") as Float32Array,
130+ gainBack: take("gain_back") as Float32Array,
131+ phiScale: take("phi_scale") as Float32Array,
132+ phiQ: take("phi_q") as Int16Array,
133+ };
134+}
135+
136+/** Write a ModesData back into the file format (the inverse of parseModes). */
137+export function serializeModes(data: ModesData): ArrayBuffer {
138+ const entries: [string, ArrayBufferView, string, number[]][] = [
139+ ["xy", data.xy, "float32", [data.nNodes, 2]],
140+ ["tri", data.tri, "int32", [data.nElements, 3]],
141+ ["z0", data.z0, "float32", [data.nNodes]],
142+ ["h", data.h, "float32", [data.nNodes]],
143+ ["freq", data.freq, "float64", [data.nModes]],
144+ ["damp_q", data.dampQ, "float64", [data.nModes]],
145+ ["gain_front", data.gainFront, "float32", [data.nModes, 2]],
146+ ["gain_back", data.gainBack, "float32", [data.nModes, 2]],
147+ ["phi_scale", data.phiScale, "float32", [data.nModes]],
148+ ["phi_q", data.phiQ, "int16", [data.nNodes, data.nModes]],
149+ ];
150+ const table: Record<string, ArrayEntry> = {};
151+ let offset = 0;
152+ for (const [name, arr, dtype, shape] of entries) {
153+ table[name] = { dtype, shape, offset, nbytes: arr.byteLength };
154+ offset += arr.byteLength;
155+ }
156+ const header = new TextEncoder().encode(JSON.stringify({ ...data.meta, arrays: table }));
157+ const out = new Uint8Array(8 + header.length + offset);
158+ out.set([0x43, 0x59, 0x4d, 0x42], 0); // "CYMB"
159+ new DataView(out.buffer).setUint32(4, header.length, true);
160+ out.set(header, 8);
161+ const base = 8 + header.length;
162+ for (const [name, arr] of entries) {
163+ out.set(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength), base + table[name].offset);
164+ }
165+ return out.buffer;
166+}
167+
168+/** Containing triangle and barycentric weights of a point of the projected disk. */
169+export function locate(data: ModesData, px: number, py: number): { nodes: [number, number, number]; weights: [number, number, number] } | null {
170+ const { xy, tri, nElements } = data;
171+ for (let e = 0; e < nElements; e++) {
172+ const i0 = tri[3 * e], i1 = tri[3 * e + 1], i2 = tri[3 * e + 2];
173+ const x0 = xy[2 * i0], y0 = xy[2 * i0 + 1];
174+ const x1 = xy[2 * i1], y1 = xy[2 * i1 + 1];
175+ const x2 = xy[2 * i2], y2 = xy[2 * i2 + 1];
176+ const det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
177+ const l1 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / det;
178+ const l2 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / det;
179+ const l3 = 1 - l1 - l2;
180+ if (Math.min(l1, l2, l3) >= -1e-9) return { nodes: [i0, i1, i2], weights: [l1, l2, l3] };
181+ }
182+ // outside the mesh (e.g. between the rim polygon and the circle): not a valid strike point
183+ return null;
184+}
185+
186+/** Mode shape values at a point: phi_m(p) = sum_i lambda_i phi_m(node_i), for all modes. */
187+export function modeValuesAt(data: ModesData, nodes: number[], weights: number[], out: Float32Array, offset: number,
188+ factor = 1): void {
189+ const { nModes, phiQ, phiScale } = data;
190+ for (let m = 0; m < nModes; m++) out[offset + m] = 0;
191+ for (let i = 0; i < nodes.length; i++) {
192+ const row = nodes[i] * nModes;
193+ const w = weights[i];
194+ for (let m = 0; m < nModes; m++) out[offset + m] += w * phiQ[row + m];
195+ }
196+ for (let m = 0; m < nModes; m++) out[offset + m] *= phiScale[m] * factor;
197+}
198+
199+/** Linear interpolation of a nodal field at a point. */
200+export function interpolateNodal(field: Float32Array, nodes: number[], weights: number[]): number {
201+ let s = 0;
202+ for (let i = 0; i < nodes.length; i++) s += weights[i] * field[nodes[i]];
203+ return s;
204+}
src/precompute/assemble.tsadded+192−0View file
@@ -0,0 +1,192 @@
1+// Assembly of the linearised shell stiffness and static condensation onto the transverse DOFs.
2+//
3+// The tangent stiffness at rest couples five DOFs per node. The in-plane DOFs (u, v) carry
4+// stiff membrane waves and the DKT rotations (theta_x, theta_y) carry no physical inertia worth
5+// keeping, so both are eliminated statically: with q = (w, o) and K = [[Kww, Kwo], [Kow, Koo]],
6+// the condensed stiffness on w is S = Kww - Kwo Koo^-1 Kow. Koo is factorised as a banded
7+// Cholesky after a reverse Cuthill-McKee ordering of the nodes; three in-plane DOFs are pinned
8+// to remove the in-plane rigid motions. S keeps the transverse translation and the two tilts as
9+// (numerically) zero-frequency modes, which the caller drops.
10+import { elementTangent, type Shell } from "../model/shell.ts";
11+import type { Mesh } from "../model/mesh.ts";
12+import { bandMatrix, bandedCholesky, bandedSolve, reverseCuthillMcKee, type BandMatrix } from "./banded.ts";
13+
14+export type Progress = (phase: string, frac: number) => void;
15+
16+/** Node adjacency (CSR, sorted, without self loops) from the triangles. */
17+export function nodeGraph(mesh: Mesh): { ptr: Uint32Array; idx: Uint32Array } {
18+ const N = mesh.n, E = mesh.e, tri = mesh.tri;
19+ const sets: Set<number>[] = Array.from({ length: N }, () => new Set<number>());
20+ for (let e = 0; e < E; e++) {
21+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
22+ sets[a].add(b); sets[a].add(c); sets[b].add(a); sets[b].add(c); sets[c].add(a); sets[c].add(b);
23+ }
24+ const ptr = new Uint32Array(N + 1);
25+ for (let n = 0; n < N; n++) ptr[n + 1] = ptr[n] + sets[n].size;
26+ const idx = new Uint32Array(ptr[N]);
27+ for (let n = 0; n < N; n++) idx.set(Array.from(sets[n]).sort((p, q) => p - q), ptr[n]);
28+ return { ptr, idx };
29+}
30+
31+function bandZeroRowCol(A: BandMatrix, p: number): void {
32+ const { n, b, data } = A;
33+ const w = b + 1;
34+ for (let j = Math.max(0, p - b); j <= p; j++) data[p * w + (j - p + b)] = 0;
35+ for (let i = p + 1; i < Math.min(n, p + b + 1); i++) data[i * w + (p - i + b)] = 0;
36+ data[p * w + b] = 1;
37+}
38+
39+/** The condensed transverse stiffness S (row-major N x N, symmetric). */
40+export function condensedStiffness(mesh: Mesh, shell: Shell, progress: Progress): Float64Array {
41+ const N = mesh.n, E = mesh.e, tri = mesh.tri;
42+ progress("node ordering", 0);
43+ const graph = nodeGraph(mesh);
44+ const perm = reverseCuthillMcKee(N, graph.ptr, graph.idx);
45+ const inv = new Uint32Array(N);
46+ for (let p = 0; p < N; p++) inv[perm[p]] = p;
47+ let bw = 0;
48+ for (let n = 0; n < N; n++) for (let k = graph.ptr[n]; k < graph.ptr[n + 1]; k++) bw = Math.max(bw, Math.abs(inv[n] - inv[graph.idx[k]]));
49+ const b = 4 * bw + 3;
50+ const No = 4 * N;
51+ const Koo = bandMatrix(No, b);
52+ const R = new Float64Array(No * N); // K_ow, later overwritten by X = Koo^-1 K_ow
53+ const S = new Float64Array(N * N); // K_ww, later S
54+ const cooI: number[] = [], cooJ: number[] = [], cooV: number[] = [];
55+
56+ // DOF slot d of node n: 2 = w; the others map to o = 4 p + {0, 1, 2, 3} for (u, v, thx, thy)
57+ const oSlot = [0, 1, -1, 2, 3];
58+ const Ke = new Float64Array(225);
59+ progress("assembly", 0);
60+ for (let e = 0; e < E; e++) {
61+ elementTangent(e, mesh.Gx, mesh.Gy, mesh.area[e], shell.z0x[e], shell.z0y[e], shell.Cm, shell.Ke, Ke);
62+ for (let a = 0; a < 3; a++) {
63+ const na = tri[3 * e + a], pa = inv[na];
64+ for (let bb = 0; bb < 3; bb++) {
65+ const nb = tri[3 * e + bb], pb = inv[nb];
66+ for (let da = 0; da < 5; da++) {
67+ for (let db = 0; db < 5; db++) {
68+ const v = Ke[15 * (5 * a + da) + (5 * bb + db)];
69+ if (v === 0) continue;
70+ const wa = da === 2, wb = db === 2;
71+ if (wa && wb) {
72+ S[na * N + nb] += v;
73+ } else if (!wa && !wb) {
74+ const i = 4 * pa + oSlot[da], j = 4 * pb + oSlot[db];
75+ if (i >= j) Koo.data[i * (b + 1) + (j - i + b)] += v;
76+ } else if (!wa && wb) {
77+ const i = 4 * pa + oSlot[da];
78+ R[i * N + nb] += v;
79+ cooI.push(i); cooJ.push(nb); cooV.push(v);
80+ }
81+ }
82+ }
83+ }
84+ }
85+ if ((e & 1023) === 0) progress("assembly", e / E);
86+ }
87+ // pin u0, v0, v1 (in-plane rigid motions)
88+ const pins = [4 * inv[0] + 0, 4 * inv[0] + 1, 4 * inv[1] + 1];
89+ for (const p of pins) {
90+ bandZeroRowCol(Koo, p);
91+ R.fill(0, p * N, (p + 1) * N);
92+ }
93+ const pinned = new Uint8Array(No);
94+ for (const p of pins) pinned[p] = 1;
95+ for (let k = 0; k < cooI.length; k++) if (pinned[cooI[k]]) cooV[k] = 0;
96+
97+ progress("factorisation", 0);
98+ if (!bandedCholesky(Koo, (f) => progress("factorisation", f))) throw new Error("condensation: the in-plane/rotation stiffness is not positive definite");
99+ progress("condensation solve", 0);
100+ bandedSolve(Koo, R, N, (f) => progress("condensation solve", f));
101+ progress("Schur complement", 0);
102+ for (let k = 0; k < cooI.length; k++) {
103+ const v = cooV[k];
104+ if (v === 0) continue;
105+ const rowX = cooI[k] * N, rowS = cooJ[k] * N;
106+ for (let c = 0; c < N; c++) S[rowS + c] -= v * R[rowX + c];
107+ if ((k & 65535) === 0) progress("Schur complement", k / cooI.length);
108+ }
109+ for (let i = 0; i < N; i++) {
110+ for (let j = 0; j < i; j++) {
111+ const s = 0.5 * (S[i * N + j] + S[j * N + i]);
112+ S[i * N + j] = s;
113+ S[j * N + i] = s;
114+ }
115+ }
116+ return S;
117+}
118+
119+/** The condensation as an operator: given a transverse field w, the statically consistent
120+ * in-plane displacements and rotations o = -Koo^-1 Kow w, node-major (u, v, thx, thy) per node.
121+ * This is what makes a mode shape from the condensed eigenproblem a complete initial condition
122+ * for the five-DOF simulator: with o omitted, releasing a pure-w shape also launches spurious
123+ * fast membrane and rotation transients. The assembly repeats what condensedStiffness does but
124+ * keeps only Koo (factorised) and the sparse Kow, so building the operator costs a factorisation
125+ * and each application one banded solve. */
126+export interface CondensationOperator { complement(w: Float64Array): Float64Array }
127+
128+export function buildCondensationOperator(mesh: Mesh, shell: Shell, progress: Progress): CondensationOperator {
129+ const N = mesh.n, E = mesh.e, tri = mesh.tri;
130+ progress("node ordering", 0);
131+ const graph = nodeGraph(mesh);
132+ const perm = reverseCuthillMcKee(N, graph.ptr, graph.idx);
133+ const inv = new Uint32Array(N);
134+ for (let p = 0; p < N; p++) inv[perm[p]] = p;
135+ let bw = 0;
136+ for (let n = 0; n < N; n++) for (let k = graph.ptr[n]; k < graph.ptr[n + 1]; k++) bw = Math.max(bw, Math.abs(inv[n] - inv[graph.idx[k]]));
137+ const b = 4 * bw + 3;
138+ const No = 4 * N;
139+ const Koo = bandMatrix(No, b);
140+ const cooI: number[] = [], cooJ: number[] = [], cooV: number[] = [];
141+ const oSlot = [0, 1, -1, 2, 3];
142+ const Ke = new Float64Array(225);
143+ progress("assembly", 0);
144+ for (let e = 0; e < E; e++) {
145+ elementTangent(e, mesh.Gx, mesh.Gy, mesh.area[e], shell.z0x[e], shell.z0y[e], shell.Cm, shell.Ke, Ke);
146+ for (let a = 0; a < 3; a++) {
147+ const pa = inv[tri[3 * e + a]];
148+ for (let bb = 0; bb < 3; bb++) {
149+ const nb = tri[3 * e + bb], pb = inv[nb];
150+ for (let da = 0; da < 5; da++) {
151+ if (da === 2) continue;
152+ const i = 4 * pa + oSlot[da];
153+ for (let db = 0; db < 5; db++) {
154+ const v = Ke[15 * (5 * a + da) + (5 * bb + db)];
155+ if (v === 0) continue;
156+ if (db === 2) {
157+ cooI.push(i); cooJ.push(nb); cooV.push(v);
158+ } else {
159+ const j = 4 * pb + oSlot[db];
160+ if (i >= j) Koo.data[i * (b + 1) + (j - i + b)] += v;
161+ }
162+ }
163+ }
164+ }
165+ }
166+ if ((e & 1023) === 0) progress("assembly", e / E);
167+ }
168+ // pin u0, v0, v1 (in-plane rigid motions), as in condensedStiffness
169+ const pins = [4 * inv[0] + 0, 4 * inv[0] + 1, 4 * inv[1] + 1];
170+ const w1 = b + 1;
171+ for (const p of pins) {
172+ for (let j = Math.max(0, p - b); j <= p; j++) Koo.data[p * w1 + (j - p + b)] = 0;
173+ for (let i = p + 1; i < Math.min(No, p + b + 1); i++) Koo.data[i * w1 + (p - i + b)] = 0;
174+ Koo.data[p * w1 + b] = 1;
175+ }
176+ const pinned = new Uint8Array(No);
177+ for (const p of pins) pinned[p] = 1;
178+ for (let k = 0; k < cooI.length; k++) if (pinned[cooI[k]]) cooV[k] = 0;
179+ progress("factorisation", 0);
180+ if (!bandedCholesky(Koo, (f) => progress("factorisation", f))) throw new Error("condensation: the in-plane/rotation stiffness is not positive definite");
181+ progress("done", 1);
182+ return {
183+ complement(w: Float64Array): Float64Array {
184+ const rhs = new Float64Array(No);
185+ for (let k = 0; k < cooI.length; k++) rhs[cooI[k]] -= cooV[k] * w[cooJ[k]];
186+ bandedSolve(Koo, rhs, 1);
187+ const o = new Float64Array(4 * N);
188+ for (let n = 0; n < N; n++) for (let s = 0; s < 4; s++) o[4 * n + s] = rhs[4 * inv[n] + s];
189+ return o;
190+ },
191+ };
192+}
src/precompute/banded.tsadded+131−0View file
@@ -0,0 +1,131 @@
1+// Banded symmetric positive-definite linear algebra for the static condensation: a reverse
2+// Cuthill-McKee ordering of the node graph to make the bandwidth small, a banded Cholesky
3+// factorisation, and forward/back substitution for many right-hand sides at once.
4+
5+/** Reverse Cuthill-McKee ordering of an undirected graph given as CSR adjacency (no self loops
6+ * required). Returns perm with perm[newIndex] = oldIndex. */
7+export function reverseCuthillMcKee(n: number, ptr: Uint32Array, idx: Uint32Array): Uint32Array {
8+ const deg = new Uint32Array(n);
9+ for (let i = 0; i < n; i++) deg[i] = ptr[i + 1] - ptr[i];
10+ const visited = new Uint8Array(n);
11+ const order: number[] = [];
12+ const byDegree = Array.from({ length: n }, (_, i) => i).sort((a, b) => deg[a] - deg[b]);
13+ for (const start of byDegree) {
14+ if (visited[start]) continue;
15+ // pseudo-peripheral start: walk to the last node of a BFS level structure twice
16+ let s = start;
17+ for (let rep = 0; rep < 2; rep++) {
18+ const seen = new Uint8Array(n);
19+ let frontier = [s];
20+ seen[s] = 1;
21+ let last = s;
22+ while (frontier.length) {
23+ const next: number[] = [];
24+ for (const u of frontier) {
25+ for (let k = ptr[u]; k < ptr[u + 1]; k++) {
26+ const v = idx[k];
27+ if (!seen[v] && !visited[v]) { seen[v] = 1; next.push(v); }
28+ }
29+ }
30+ if (next.length) { next.sort((a, b) => deg[a] - deg[b]); last = next[0]; }
31+ frontier = next;
32+ }
33+ s = last;
34+ }
35+ const queue: number[] = [s];
36+ visited[s] = 1;
37+ let head = 0;
38+ while (head < queue.length) {
39+ const u = queue[head++];
40+ order.push(u);
41+ const nb: number[] = [];
42+ for (let k = ptr[u]; k < ptr[u + 1]; k++) {
43+ const v = idx[k];
44+ if (!visited[v]) { visited[v] = 1; nb.push(v); }
45+ }
46+ nb.sort((a, b) => deg[a] - deg[b]);
47+ for (const v of nb) queue.push(v);
48+ }
49+ }
50+ order.reverse();
51+ return Uint32Array.from(order);
52+}
53+
54+/** Symmetric band matrix, lower part stored row-wise: entry (i, j) with i - b <= j <= i lives at
55+ * data[i * (b + 1) + (j - i + b)]. */
56+export interface BandMatrix {
57+ n: number;
58+ b: number;
59+ data: Float64Array;
60+}
61+
62+export function bandMatrix(n: number, b: number): BandMatrix {
63+ return { n, b, data: new Float64Array(n * (b + 1)) };
64+}
65+
66+export function bandAdd(A: BandMatrix, i: number, j: number, v: number): void {
67+ if (j > i) { const t = i; i = j; j = t; }
68+ const off = j - i + A.b;
69+ if (off < 0) throw new Error(`entry (${i}, ${j}) outside the band ${A.b}`);
70+ A.data[i * (A.b + 1) + off] += v;
71+}
72+
73+/** In-place Cholesky A = L L^T of a banded SPD matrix; returns false if a pivot is not positive. */
74+export function bandedCholesky(A: BandMatrix, onProgress?: (frac: number) => void): boolean {
75+ const { n, b, data } = A;
76+ const w = b + 1;
77+ for (let i = 0; i < n; i++) {
78+ const ri = i * w;
79+ const j0 = Math.max(0, i - b);
80+ for (let j = j0; j <= i; j++) {
81+ const rj = j * w;
82+ let s = data[ri + (j - i + b)];
83+ const k0 = Math.max(j0, j - b);
84+ // s -= sum_k L[i,k] L[j,k] for k in [k0, j)
85+ let pi = ri + (k0 - i + b), pj = rj + (k0 - j + b);
86+ for (let k = k0; k < j; k++) s -= data[pi++] * data[pj++];
87+ if (j === i) {
88+ if (!(s > 0)) return false;
89+ data[ri + b] = Math.sqrt(s);
90+ } else {
91+ data[ri + (j - i + b)] = s / data[rj + b];
92+ }
93+ }
94+ if (onProgress && (i & 255) === 0) onProgress(i / n);
95+ }
96+ return true;
97+}
98+
99+/** Solve L L^T X = R for X, with R given row-major (n x m) and overwritten by X. */
100+export function bandedSolve(L: BandMatrix, R: Float64Array, m: number, onProgress?: (frac: number) => void): void {
101+ const { n, b, data } = L;
102+ const w = b + 1;
103+ // forward: L y = r
104+ for (let i = 0; i < n; i++) {
105+ const ri = i * w, rowI = i * m;
106+ const k0 = Math.max(0, i - b);
107+ for (let k = k0; k < i; k++) {
108+ const l = data[ri + (k - i + b)];
109+ if (l === 0) continue;
110+ const rowK = k * m;
111+ for (let c = 0; c < m; c++) R[rowI + c] -= l * R[rowK + c];
112+ }
113+ const d = 1 / data[ri + b];
114+ for (let c = 0; c < m; c++) R[rowI + c] *= d;
115+ if (onProgress && (i & 255) === 0) onProgress(0.5 * i / n);
116+ }
117+ // backward: L^T x = y (column i of L^T is row i of L)
118+ for (let i = n - 1; i >= 0; i--) {
119+ const ri = i * w, rowI = i * m;
120+ const d = 1 / data[ri + b];
121+ for (let c = 0; c < m; c++) R[rowI + c] *= d;
122+ const k0 = Math.max(0, i - b);
123+ for (let k = k0; k < i; k++) {
124+ const l = data[ri + (k - i + b)];
125+ if (l === 0) continue;
126+ const rowK = k * m;
127+ for (let c = 0; c < m; c++) R[rowK + c] -= l * R[rowI + c];
128+ }
129+ if (onProgress && (i & 255) === 0) onProgress(0.5 + 0.5 * (n - i) / n);
130+ }
131+}
src/precompute/eigen.tsadded+161−0View file
@@ -0,0 +1,161 @@
1+// Dense symmetric eigensolver: Householder reduction to tridiagonal form followed by the
2+// implicit QL algorithm with accumulation of the transformations (the tred2 / tql2 pair of
3+// EISPACK, in the form given by Numerical Recipes), rearranged so that every inner loop runs
4+// along a contiguous row. The eigenvectors are returned as the rows of vectorsT.
5+
6+const EPS = Number.EPSILON;
7+
8+function pythag(a: number, b: number): number {
9+ return Math.hypot(a, b);
10+}
11+
12+/** Householder tridiagonalisation of the symmetric row-major n x n matrix a (destroyed). On
13+ * return d holds the diagonal, e the sub-diagonal (e[0] = 0) and a the orthogonal matrix Q. */
14+function tred2(a: Float64Array, n: number, d: Float64Array, e: Float64Array, onProgress?: (frac: number) => void): void {
15+ const g = new Float64Array(n);
16+ for (let i = n - 1; i >= 1; i--) {
17+ const l = i - 1;
18+ const ri = i * n;
19+ let h = 0, scale = 0;
20+ if (l > 0) {
21+ for (let k = 0; k <= l; k++) scale += Math.abs(a[ri + k]);
22+ if (scale === 0) {
23+ e[i] = a[ri + l];
24+ } else {
25+ for (let k = 0; k <= l; k++) {
26+ a[ri + k] /= scale;
27+ h += a[ri + k] * a[ri + k];
28+ }
29+ let f = a[ri + l];
30+ const gg = f >= 0 ? -Math.sqrt(h) : Math.sqrt(h);
31+ e[i] = scale * gg;
32+ h -= f * gg;
33+ a[ri + l] = f - gg;
34+ f = 0;
35+ // p = A u / h, using full rows of the (symmetric) leading block
36+ for (let j = 0; j <= l; j++) {
37+ a[j * n + i] = a[ri + j] / h;
38+ let s = 0;
39+ const rj = j * n;
40+ for (let k = 0; k <= l; k++) s += a[rj + k] * a[ri + k];
41+ e[j] = s / h;
42+ f += e[j] * a[ri + j];
43+ }
44+ const hh = f / (h + h);
45+ for (let j = 0; j <= l; j++) e[j] -= hh * a[ri + j];
46+ // rank-two update of the full leading block: A -= u q^T + q u^T
47+ for (let j = 0; j <= l; j++) {
48+ const fj = a[ri + j], gj = e[j];
49+ const rj = j * n;
50+ for (let k = 0; k <= l; k++) a[rj + k] -= fj * e[k] + gj * a[ri + k];
51+ }
52+ }
53+ } else {
54+ e[i] = a[ri + l];
55+ }
56+ d[i] = h;
57+ if (onProgress && (i & 63) === 0) onProgress((n - i) / n);
58+ }
59+ d[0] = 0;
60+ e[0] = 0;
61+ // accumulate the transformations into a (Q)
62+ for (let i = 0; i < n; i++) {
63+ const l = i - 1;
64+ const ri = i * n;
65+ if (d[i] !== 0) {
66+ g.fill(0, 0, l + 1);
67+ for (let k = 0; k <= l; k++) {
68+ const aik = a[ri + k];
69+ if (aik === 0) continue;
70+ const rk = k * n;
71+ for (let j = 0; j <= l; j++) g[j] += aik * a[rk + j];
72+ }
73+ for (let k = 0; k <= l; k++) {
74+ const aki = a[k * n + i];
75+ if (aki === 0) continue;
76+ const rk = k * n;
77+ for (let j = 0; j <= l; j++) a[rk + j] -= g[j] * aki;
78+ }
79+ }
80+ d[i] = a[ri + i];
81+ a[ri + i] = 1;
82+ for (let j = 0; j <= l; j++) {
83+ a[j * n + i] = 0;
84+ a[ri + j] = 0;
85+ }
86+ }
87+}
88+
89+/** Implicit QL on the tridiagonal (d, e), with the rotations applied to the rows of zT. */
90+function tql2(d: Float64Array, e: Float64Array, n: number, zT: Float64Array, onProgress?: (frac: number) => void): void {
91+ for (let i = 1; i < n; i++) e[i - 1] = e[i];
92+ e[n - 1] = 0;
93+ for (let l = 0; l < n; l++) {
94+ let iter = 0;
95+ let m: number;
96+ do {
97+ for (m = l; m < n - 1; m++) {
98+ const dd = Math.abs(d[m]) + Math.abs(d[m + 1]);
99+ if (Math.abs(e[m]) <= EPS * dd) break;
100+ }
101+ if (m !== l) {
102+ if (iter++ === 100) throw new Error("tql2: too many iterations");
103+ let g = (d[l + 1] - d[l]) / (2 * e[l]);
104+ let r = pythag(g, 1);
105+ g = d[m] - d[l] + e[l] / (g + (g >= 0 ? Math.abs(r) : -Math.abs(r)));
106+ let s = 1, c = 1, p = 0;
107+ let i: number;
108+ for (i = m - 1; i >= l; i--) {
109+ const f = s * e[i], b = c * e[i];
110+ e[i + 1] = r = pythag(f, g);
111+ if (r === 0) {
112+ d[i + 1] -= p;
113+ e[m] = 0;
114+ break;
115+ }
116+ s = f / r;
117+ c = g / r;
118+ g = d[i + 1] - p;
119+ r = (d[i] - g) * s + 2 * c * b;
120+ p = s * r;
121+ d[i + 1] = g + p;
122+ g = c * r - b;
123+ const ri = i * n, rj = (i + 1) * n;
124+ for (let k = 0; k < n; k++) {
125+ const f2 = zT[rj + k], z1 = zT[ri + k];
126+ zT[rj + k] = s * z1 + c * f2;
127+ zT[ri + k] = c * z1 - s * f2;
128+ }
129+ }
130+ if (r === 0 && i >= l) continue;
131+ d[l] -= p;
132+ e[l] = g;
133+ e[m] = 0;
134+ }
135+ } while (m !== l);
136+ if (onProgress && (l & 15) === 0) onProgress(l / n);
137+ }
138+}
139+
140+export interface EigenResult {
141+ values: Float64Array; // ascending
142+ vectorsT: Float64Array; // (n, n): row k is the eigenvector of values[k]
143+}
144+
145+/** All eigenpairs of the symmetric row-major matrix A (n x n, destroyed). */
146+export function symmetricEigen(A: Float64Array, n: number, onProgress?: (phase: string, frac: number) => void): EigenResult {
147+ const d = new Float64Array(n), e = new Float64Array(n);
148+ tred2(A, n, d, e, onProgress ? (f) => onProgress("tridiagonalisation", f) : undefined);
149+ // Q is in A (column j = j-th transformation); tql2 wants the transposed layout
150+ const zT = new Float64Array(n * n);
151+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) zT[j * n + i] = A[i * n + j];
152+ tql2(d, e, n, zT, onProgress ? (f) => onProgress("QL iteration", f) : undefined);
153+ // sort ascending
154+ const order = Array.from({ length: n }, (_, i) => i).sort((p, q) => d[p] - d[q]);
155+ const values = new Float64Array(n), vectorsT = new Float64Array(n * n);
156+ order.forEach((k, i) => {
157+ values[i] = d[k];
158+ vectorsT.set(zT.subarray(k * n, (k + 1) * n), i * n);
159+ });
160+ return { values, vectorsT };
161+}
src/precompute/gains.tsadded+98−0View file
@@ -0,0 +1,98 @@
1+// Per-mode listener gains: the Rayleigh front sum and the diffracted back sum over the elements,
2+// with each element's travel delay folded into a phase at the mode frequency. The pressure of
3+// mode m is then Re(G_m * complex acceleration), and the unbaffled estimate is front minus back.
4+//
5+// This is the only part of the precompute that depends on the listener, and it is cheap enough
6+// (one pass over elements times modes) to redo whenever the listener or the frequencies change,
7+// which is what makes the listener and the material live controls in the design panel. The rim
8+// points of the diffraction path are the mesh's own boundary nodes rather than a separate polar
9+// sampling, so that the same function serves the first computation and every later one.
10+import type { Listener } from "../config/schema.ts";
11+import type { ModesData } from "../modes.ts";
12+
13+export interface Gains {
14+ front: Float32Array; // (Nm, 2) re, im
15+ back: Float32Array;
16+}
17+
18+interface Geometry {
19+ area: Float64Array; // (E,)
20+ rFront: Float64Array; // (E,)
21+ rBack: Float64Array; // (E,)
22+}
23+
24+/** Element areas and the two path lengths to the listener; depends on the listener position only
25+ * through the paths, so it is recomputed whenever the listener moves. */
26+export function radiationGeometry(data: ModesData, listener: Listener): Geometry {
27+ const { xy, tri, z0, nElements, nNodes } = data;
28+ const [Lx, Ly, Lz] = listener.position;
29+ const area = new Float64Array(nElements);
30+ const cx = new Float64Array(nElements), cy = new Float64Array(nElements), cz = new Float64Array(nElements);
31+ for (let e = 0; e < nElements; e++) {
32+ const a = tri[3 * e], b = tri[3 * e + 1], c = tri[3 * e + 2];
33+ const x0 = xy[2 * a], y0 = xy[2 * a + 1], x1 = xy[2 * b], y1 = xy[2 * b + 1], x2 = xy[2 * c], y2 = xy[2 * c + 1];
34+ area[e] = Math.abs(0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)));
35+ cx[e] = (x0 + x1 + x2) / 3;
36+ cy[e] = (y0 + y1 + y2) / 3;
37+ cz[e] = (z0[a] + z0[b] + z0[c]) / 3;
38+ }
39+ // rim points: the nodes of the outer ring, with their own heights
40+ let R = 0;
41+ for (let n = 0; n < nNodes; n++) R = Math.max(R, Math.hypot(xy[2 * n], xy[2 * n + 1]));
42+ const rimX: number[] = [], rimY: number[] = [], rimZ: number[] = [], rimD: number[] = [];
43+ for (let n = 0; n < nNodes; n++) {
44+ const x = xy[2 * n], y = xy[2 * n + 1];
45+ if (Math.hypot(x, y) < R * (1 - 1e-6)) continue;
46+ rimX.push(x);
47+ rimY.push(y);
48+ rimZ.push(z0[n]);
49+ rimD.push(Math.hypot(x - Lx, y - Ly, z0[n] - Lz));
50+ }
51+ const nRim = rimX.length;
52+ const rFront = new Float64Array(nElements), rBack = new Float64Array(nElements);
53+ for (let e = 0; e < nElements; e++) {
54+ rFront[e] = Math.hypot(Lx - cx[e], Ly - cy[e], Lz - cz[e]);
55+ let best = Infinity;
56+ for (let j = 0; j < nRim; j++) {
57+ const d = Math.hypot(cx[e] - rimX[j], cy[e] - rimY[j], cz[e] - rimZ[j]) + rimD[j];
58+ if (d < best) best = d;
59+ }
60+ rBack[e] = best;
61+ }
62+ return { area, rFront, rBack };
63+}
64+
65+/** Gains for the given frequencies. ``phiFactor`` rescales the stored mode shapes, which are
66+ * mass-normalised at the reference density; a density change scales them by 1/sqrt(rho/rho0). */
67+export function computeGains(data: ModesData, listener: Listener, freq: Float64Array, phiFactor: number,
68+ geom?: Geometry, onProgress?: (frac: number) => void): Gains {
69+ const g = geom ?? radiationGeometry(data, listener);
70+ const { tri, phiQ, phiScale, nElements, nModes } = data;
71+ const front = new Float32Array(2 * nModes), back = new Float32Array(2 * nModes);
72+ const fr = new Float64Array(2 * nModes), bk = new Float64Array(2 * nModes);
73+ const omega = new Float64Array(nModes);
74+ for (let m = 0; m < nModes; m++) omega[m] = 2 * Math.PI * freq[m];
75+ const c = listener.c_air, rhoAir = listener.rho_air, gBack = listener.back_diffraction_gain;
76+ const phiE = new Float64Array(nModes);
77+ for (let e = 0; e < nElements; e++) {
78+ const i0 = tri[3 * e] * nModes, i1 = tri[3 * e + 1] * nModes, i2 = tri[3 * e + 2] * nModes;
79+ const s = phiFactor / 3;
80+ for (let m = 0; m < nModes; m++) phiE[m] = s * phiScale[m] * (phiQ[i0 + m] + phiQ[i1 + m] + phiQ[i2 + m]);
81+ const cf = (rhoAir * g.area[e]) / (2 * Math.PI * g.rFront[e]);
82+ const cb = (gBack * rhoAir * g.area[e]) / (2 * Math.PI * g.rBack[e]);
83+ const tf = g.rFront[e] / c, tb = g.rBack[e] / c;
84+ for (let m = 0; m < nModes; m++) {
85+ const w = omega[m], p = phiE[m];
86+ const af = w * tf, ab = w * tb;
87+ const pf = cf * p, pb = cb * p;
88+ fr[2 * m] += pf * Math.cos(af);
89+ fr[2 * m + 1] -= pf * Math.sin(af);
90+ bk[2 * m] += pb * Math.cos(ab);
91+ bk[2 * m + 1] -= pb * Math.sin(ab);
92+ }
93+ if (onProgress && (e & 255) === 0) onProgress(e / nElements);
94+ }
95+ front.set(fr);
96+ back.set(bk);
97+ return { front, back };
98+}
src/precompute/pipeline.tsadded+141−0View file
@@ -0,0 +1,141 @@
1+// The whole precompute: from a cymsim configuration to the modal data the player needs.
2+//
3+// 1. profile, mesh (at the playback element size) and shell, as in the simulator;
4+// 2. static condensation of the in-plane and rotation DOFs (assemble.ts);
5+// 3. mass-normalised eigenmodes of the condensed transverse problem (eigen.ts);
6+// 4. per mode: the frequency, the quadratic form of the sigma1 loss, the complex listener gains
7+// (gains.ts) and the mode shape on the nodes, quantised to int16.
8+//
9+// What is *not* baked in, so that the design panel can change it without a new eigensolve:
10+//
11+// damping sigma_m = sigma0 + sigma1 q_m with q_m = phi_m^T L phi_m stored as ``dampQ``.
12+// q_m is invariant under a density change, because L is proportional to rho while a
13+// mass-normalised phi is proportional to 1/sqrt(rho).
14+// material the condensed stiffness is proportional to E and the transverse mass to rho, both
15+// exactly, so a change of either scales every frequency by sqrt((E/E0)/(rho/rho0))
16+// and leaves the mode shapes unchanged apart from the normalisation factor
17+// 1/sqrt(rho/rho0). Poisson's ratio does change the shapes and needs a recompute.
18+// listener the gains are a sum over elements and can be redone in a fraction of a second.
19+//
20+// Everything else (radius, thickness, bell and bow geometry, imperfections, Poisson's ratio,
21+// mesh size) changes the eigenproblem and needs the full computation again.
22+import { cloneConfig } from "../config/yaml.ts";
23+import { defaultMallet, defaultStick, type SimulationConfig } from "../config/schema.ts";
24+import { CymbalProfile } from "../model/geometry.ts";
25+import { diskMesh } from "../model/mesh.ts";
26+import { buildShell } from "../model/shell.ts";
27+import { FORMAT_VERSION, type ModesData, type ModesMeta } from "../modes.ts";
28+import { condensedStiffness, type Progress } from "./assemble.ts";
29+import { symmetricEigen } from "./eigen.ts";
30+import { computeGains } from "./gains.ts";
31+
32+export interface PrecomputeOptions {
33+ elementSize: number; // playback mesh element size [m]
34+ fmax: number; // keep modes below this frequency [Hz]
35+}
36+
37+/** The parts of a configuration that the eigenproblem depends on. Two configurations with the
38+ * same structural key share a modes file; the rest is applied when the file is loaded. */
39+export function structuralKey(cfg: SimulationConfig, opts: PrecomputeOptions) {
40+ return {
41+ profile: cfg.profile,
42+ imperfections: cfg.imperfections,
43+ smoothing: cfg.mesh.smoothing,
44+ nu: cfg.material.nu,
45+ options: opts,
46+ };
47+}
48+
49+export function computeModes(baseConfig: SimulationConfig, opts: PrecomputeOptions, progress: Progress): ModesData {
50+ const cfg = cloneConfig(baseConfig);
51+ cfg.mesh.element_size = opts.elementSize;
52+ progress("mesh and shell", 0);
53+ const profile = new CymbalProfile(cfg.profile, cfg.imperfections);
54+ const mesh = diskMesh(cfg.profile.radius, cfg.mesh.element_size, cfg.mesh.smoothing);
55+ const shell = buildShell(mesh, profile, cfg.material, cfg.numerics);
56+ const N = mesh.n, E = mesh.e, tri = mesh.tri;
57+
58+ const S = condensedStiffness(mesh, shell, progress);
59+
60+ // mass normalisation with the lumped transverse mass
61+ const Mw = new Float64Array(N), dinv = new Float64Array(N);
62+ for (let n = 0; n < N; n++) {
63+ Mw[n] = shell.M[5 * n + 2];
64+ dinv[n] = 1 / Math.sqrt(Mw[n]);
65+ }
66+ for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) S[i * N + j] *= dinv[i] * dinv[j];
67+ progress("eigenmodes", 0);
68+ const eig = symmetricEigen(S, N, (phase, f) => progress(`eigenmodes: ${phase}`, f));
69+
70+ // keep 5 Hz < f < fmax (drops the three rigid modes)
71+ const keep: number[] = [];
72+ for (let k = 0; k < N; k++) {
73+ const f = Math.sqrt(Math.max(eig.values[k], 0)) / (2 * Math.PI);
74+ if (f > 5 && f < opts.fmax) keep.push(k);
75+ }
76+ const Nm = keep.length;
77+ if (Nm === 0) throw new Error("no modes below the frequency limit: check the profile and the material");
78+ const freq = new Float64Array(Nm);
79+ const phi = new Float64Array(N * Nm); // node-major
80+ keep.forEach((k, m) => {
81+ freq[m] = Math.sqrt(eig.values[k]) / (2 * Math.PI);
82+ for (let n = 0; n < N; n++) phi[n * Nm + m] = eig.vectorsT[k * N + n] * dinv[n];
83+ });
84+ progress("damping and radiation", 0);
85+
86+ // sigma1 quadratic form q_m = phi^T L phi with L = sum_e (rho h A)_e G^T G on w
87+ const dampQ = new Float64Array(Nm);
88+ const gxm = new Float64Array(Nm), gym = new Float64Array(Nm);
89+ for (let e = 0; e < E; e++) {
90+ gxm.fill(0);
91+ gym.fill(0);
92+ for (let i = 0; i < 3; i++) {
93+ const row = tri[3 * e + i] * Nm, gx = mesh.Gx[3 * e + i], gy = mesh.Gy[3 * e + i];
94+ for (let m = 0; m < Nm; m++) {
95+ gxm[m] += gx * phi[row + m];
96+ gym[m] += gy * phi[row + m];
97+ }
98+ }
99+ const c = shell.rhoHA[e];
100+ for (let m = 0; m < Nm; m++) dampQ[m] += c * (gxm[m] * gxm[m] + gym[m] * gym[m]);
101+ }
102+
103+ // quantise the mode shapes
104+ const phiScale = new Float32Array(Nm), phiQ = new Int16Array(N * Nm);
105+ for (let m = 0; m < Nm; m++) {
106+ let top = 0;
107+ for (let n = 0; n < N; n++) top = Math.max(top, Math.abs(phi[n * Nm + m]));
108+ phiScale[m] = top / 32767 || 1;
109+ }
110+ for (let n = 0; n < N; n++) for (let m = 0; m < Nm; m++) phiQ[n * Nm + m] = Math.round(phi[n * Nm + m] / phiScale[m]);
111+
112+ let mass = 0;
113+ for (const mw of Mw) mass += mw;
114+ const meta: ModesMeta = {
115+ format_version: FORMAT_VERSION,
116+ n_nodes: N, n_elements: E, n_modes: Nm, fmax: opts.fmax,
117+ radius: cfg.profile.radius, mass,
118+ E0: cfg.material.E, rho0: cfg.material.rho,
119+ config: baseConfig as unknown as Record<string, unknown>,
120+ playback_element_size: opts.elementSize,
121+ exciters: { stick: defaultStick(), mallet: defaultMallet() },
122+ notes: "phi = phi_q * phi_scale[m] is the transverse mode shape, mass-normalised at rho0, with " +
123+ "the in-plane and rotation DOFs statically condensed. Modal equation a'' + 2 sigma a' + " +
124+ "(2 pi f)^2 a = phi(p) . F, with sigma_m = sigma0 + sigma1 damp_q[m] and f scaled by " +
125+ "sqrt((E/E0)/(rho/rho0)). Pressure = Re(sum_m G_m * complex acceleration); unbaffled = front - back.",
126+ };
127+
128+ const data: ModesData = {
129+ meta, nNodes: N, nElements: E, nModes: Nm, radius: cfg.profile.radius,
130+ xy: Float32Array.from(mesh.xy), tri: Int32Array.from(mesh.tri),
131+ z0: Float32Array.from(shell.z0Node), h: Float32Array.from(shell.hNode),
132+ freq, dampQ,
133+ gainFront: new Float32Array(2 * Nm), gainBack: new Float32Array(2 * Nm),
134+ phiScale, phiQ,
135+ };
136+ const g = computeGains(data, cfg.listener, freq, 1, undefined, (f) => progress("damping and radiation", f));
137+ data.gainFront = g.front;
138+ data.gainBack = g.back;
139+ progress("done", 1);
140+ return data;
141+}
src/precompute/worker.tsadded+77−0View file
@@ -0,0 +1,77 @@
1+// Web Worker entry: the computations that are too slow for the main thread. "modes" is the full
2+// eigensolve (seconds to minutes); "gains" is the listener sum, redone whenever the listener or
3+// the material changes (a fraction of a second); "complement" applies the static condensation to
4+// a transverse field, giving the in-plane displacements and rotations that make a mode shape a
5+// complete initial condition for the five-DOF simulator. The condensation operator (a banded
6+// Cholesky factor and the sparse coupling block) is built on first use and kept while the
7+// structural configuration stays the same.
8+import type { Listener, SimulationConfig } from "../config/schema.ts";
9+import { cloneConfig } from "../config/yaml.ts";
10+import { CymbalProfile } from "../model/geometry.ts";
11+import { diskMesh } from "../model/mesh.ts";
12+import { buildShell } from "../model/shell.ts";
13+import { parseModes, serializeModes, type ModesData } from "../modes.ts";
14+import { buildCondensationOperator, type CondensationOperator } from "./assemble.ts";
15+import { computeGains } from "./gains.ts";
16+import { computeModes, structuralKey, type PrecomputeOptions } from "./pipeline.ts";
17+
18+export type PrecomputeRequest =
19+ | { type: "modes"; token: number; config: SimulationConfig; options: PrecomputeOptions }
20+ | { type: "gains"; token: number; buffer?: ArrayBuffer; listener: Listener; freq: Float64Array; phiFactor: number }
21+ | { type: "complement"; token: number; config: SimulationConfig; options: PrecomputeOptions; w: Float64Array };
22+
23+export type PrecomputeReply =
24+ | { type: "progress"; token: number; phase: string; frac: number }
25+ | { type: "modes"; token: number; buffer: ArrayBuffer; seconds: number }
26+ | { type: "gains"; token: number; front: Float32Array; back: Float32Array; seconds: number }
27+ | { type: "complement"; token: number; o: Float64Array; seconds: number }
28+ | { type: "error"; token: number; message: string };
29+
30+/** The worker keeps the modes it was last given so that repeated gain requests, which happen
31+ * while a listener slider is being dragged, do not re-send a 12 MB buffer every time. */
32+let held: { buffer: ArrayBuffer; data: ModesData } | null = null;
33+/** The condensation operator of the last structural configuration. */
34+let condensation: { key: string; op: CondensationOperator } | null = null;
35+
36+self.onmessage = (ev: MessageEvent<PrecomputeRequest>) => {
37+ const msg = ev.data;
38+ const post = (reply: PrecomputeReply, transfer?: Transferable[]) => (self as unknown as Worker).postMessage(reply, transfer ?? []);
39+ const t0 = performance.now();
40+ let lastPost = 0;
41+ const progress = (token: number) => (phase: string, frac: number) => {
42+ const t = performance.now();
43+ if (t - lastPost > 100 || frac >= 1) {
44+ lastPost = t;
45+ post({ type: "progress", token, phase, frac });
46+ }
47+ };
48+ try {
49+ if (msg.type === "modes") {
50+ const data = computeModes(msg.config, msg.options, progress(msg.token));
51+ const buffer = serializeModes(data);
52+ held = { buffer, data };
53+ condensation = null; // the shape changed; rebuild on the next complement request
54+ post({ type: "modes", token: msg.token, buffer: buffer.slice(0), seconds: (performance.now() - t0) / 1000 });
55+ } else if (msg.type === "complement") {
56+ const key = JSON.stringify(structuralKey(msg.config, msg.options));
57+ if (!condensation || condensation.key !== key) {
58+ const cfg = cloneConfig(msg.config);
59+ cfg.mesh.element_size = msg.options.elementSize;
60+ const profile = new CymbalProfile(cfg.profile, cfg.imperfections);
61+ const mesh = diskMesh(cfg.profile.radius, cfg.mesh.element_size, cfg.mesh.smoothing);
62+ const shell = buildShell(mesh, profile, cfg.material, cfg.numerics);
63+ condensation = { key, op: buildCondensationOperator(mesh, shell, progress(msg.token)) };
64+ }
65+ const o = condensation.op.complement(msg.w);
66+ post({ type: "complement", token: msg.token, o, seconds: (performance.now() - t0) / 1000 }, [o.buffer]);
67+ } else {
68+ if (msg.buffer) held = { buffer: msg.buffer, data: parseModes(msg.buffer) };
69+ if (!held) throw new Error("no modes in the worker: send the buffer with the first gains request");
70+ const g = computeGains(held.data, msg.listener, msg.freq, msg.phiFactor);
71+ post({ type: "gains", token: msg.token, front: g.front, back: g.back, seconds: (performance.now() - t0) / 1000 },
72+ [g.front.buffer, g.back.buffer]);
73+ }
74+ } catch (err) {
75+ post({ type: "error", token: msg.token, message: (err as Error).message ?? String(err) });
76+ }
77+};
src/run.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+// Drives a simulator backend chunk by chunk with an adaptive chunk size, pausing and stopping on
2+// request, and reports pressure chunks, diagnostics and progress to the UI.
3+import type { Setup } from "./model/setup.ts";
4+import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
5+
6+export interface RunCallbacks {
7+ onChunk(res: ChunkResult): void;
8+ onDiagnostics(d: Diagnostics): void;
9+ onProgress(samplesDone: number, wallSeconds: number): void;
10+ onDone(stopped: boolean): void;
11+}
12+
13+export class Runner {
14+ private paused = false; private stopped = false; private resume: (() => void) | null = null;
15+ private chunk = 512;
16+ readonly backend: SimulatorBackend; readonly setup: Setup; private readonly cb: RunCallbacks;
17+
18+ constructor(backend: SimulatorBackend, setup: Setup, cb: RunCallbacks) { this.backend = backend; this.setup = setup; this.cb = cb; }
19+
20+ async run() {
21+ const { setup, backend } = this, t0 = performance.now();
22+ let lastDiag = t0;
23+ while (!this.stopped && backend.samplesDone < setup.nSamples) {
24+ if (this.paused) await new Promise<void>((r) => { this.resume = r; });
25+ if (this.stopped) break;
26+ const n = Math.min(this.chunk, backend.maxChunk, setup.nSamples - backend.samplesDone);
27+ const tc = performance.now();
28+ const res = await backend.runChunk(n);
29+ const dtc = (performance.now() - tc) / 1000;
30+ // aim at about 0.4 s of wall time per chunk, in multiples of 128 samples, at most 8192
31+ this.chunk = Math.max(128, Math.min(backend.maxChunk, Math.round((n * 0.4) / Math.max(dtc, 1e-3) / 128) * 128));
32+ this.cb.onChunk(res);
33+ this.cb.onProgress(backend.samplesDone, (performance.now() - t0) / 1000);
34+ if (performance.now() - lastDiag > 1000 || backend.samplesDone >= setup.nSamples) {
35+ this.cb.onDiagnostics(await backend.diagnostics());
36+ lastDiag = performance.now();
37+ }
38+ await new Promise((r) => setTimeout(r, 0));
39+ }
40+ this.cb.onDone(this.stopped);
41+ }
42+
43+ pause() { this.paused = true; }
44+ unpause() { this.paused = false; this.resume?.(); this.resume = null; }
45+ stop() { this.stopped = true; this.unpause(); }
46+ get isPaused() { return this.paused; }
47+}
src/style.cssadded+62−0View file
@@ -0,0 +1,62 @@
1+:root { --sidebar-w: 400px; --modelist-w: 300px; --top-h: 44vh; --bars-h: 170px; --bg: #14161a; --panel: #1d2026; --border: #2c3038; --text: #e6e6e6; --muted: #9aa0a8; --accent: #e0a23a; --danger: #d9534f; --good: #6fbf73; }
2+* { box-sizing: border-box; }
3+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 13px/1.4 system-ui, sans-serif; }
4+#app { display: grid; grid-template-columns: var(--sidebar-w) 6px 1fr; height: 100vh; }
5+.splitter { background: var(--border); flex: none; }
6+.splitter.vertical { width: 6px; cursor: col-resize; }
7+.splitter.horizontal { height: 6px; cursor: row-resize; }
8+.splitter:hover, .splitter.active { background: var(--accent); }
9+#sidebar { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
10+#sidebar header { padding: 10px 12px 6px; }
11+#sidebar h1 { margin: 0; font-size: 18px; display: inline; }
12+.subtitle { color: var(--muted); margin-left: 8px; }
13+#sidebar-scroll { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 10px; }
14+#sidebar details { border-top: 1px solid var(--border); padding: 4px 12px 8px; }
15+#sidebar summary { cursor: pointer; font-weight: 600; padding: 5px 0; }
16+.row { display: flex; gap: 8px; align-items: center; margin: 5px 0; flex-wrap: wrap; }
17+.row label { display: flex; gap: 5px; align-items: center; color: var(--muted); }
18+.row select { max-width: 220px; }
19+.row.buttons button { flex: 1; }
20+button { background: #2a2e36; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; cursor: pointer; }
21+button:hover:not(:disabled) { background: #343943; }
22+button:disabled { opacity: 0.45; cursor: default; }
23+button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
24+select, input[type="text"], input[type="number"] { background: #111318; color: var(--text); border: 1px solid var(--border); border-radius: 3px; padding: 3px 5px; }
25+input[type="number"] { width: 70px; }
26+progress { flex: 1; height: 10px; }
27+.muted { color: var(--muted); }
28+.status { color: var(--muted); font-size: 12px; white-space: pre-wrap; margin: 4px 0; }
29+.status.bad { color: var(--danger); }
30+.progress { height: 5px; background: #111318; border-radius: 3px; overflow: hidden; margin: 4px 0; }
31+.progress > div { height: 100%; width: 0; background: var(--accent); transition: width 0.15s; }
32+.log { padding: 6px 12px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; color: var(--muted); white-space: pre-wrap; }
33+.forced-only.hidden { display: none; }
34+
35+/* design panel (as in cymplay) */
36+#design details.group { border: 1px solid var(--border); border-radius: 4px; margin: 4px 0; padding: 2px 8px 6px; }
37+#design summary { font-weight: 600; padding: 3px 0; cursor: pointer; }
38+#design .note { color: var(--muted); font-size: 11px; margin: 2px 0 4px; }
39+#design .field { display: grid; grid-template-columns: 1fr 90px 64px; gap: 6px; align-items: center; margin: 2px 0; }
40+#design .field label { color: var(--muted); font-size: 12px; }
41+#design .field.live label::after { content: " •"; color: var(--good); }
42+#design .field input[type="number"] { width: 100%; }
43+
44+main { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
45+main section { min-height: 0; min-width: 0; display: flex; flex-direction: column; }
46+#top-section { height: var(--top-h); flex: none; flex-direction: row; }
47+#plate-section { flex: 1; }
48+#modelist-section { width: var(--modelist-w); flex: none; border-left: none; }
49+#modelist { flex: 1; overflow-y: auto; min-height: 0; }
50+#modelist table { border-collapse: collapse; width: 100%; font-size: 12px; }
51+#modelist th, #modelist td { padding: 1px 8px; text-align: right; border-bottom: 1px solid var(--border); }
52+#modelist th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-weight: normal; }
53+#modelist tbody tr { cursor: pointer; }
54+#modelist tbody tr:hover { background: #23262d; }
55+#modelist tbody tr.selected { background: #3a3320; }
56+#modelist tbody tr.driven td:first-child { color: var(--good); font-weight: 600; }
57+#modal-section { flex: 1; min-height: 80px; }
58+#bars-section { height: var(--bars-h); flex: none; }
59+.toolbar { display: flex; gap: 14px; align-items: center; padding: 4px 10px; font-weight: 600; flex: none; }
60+.toolbar label { font-weight: normal; color: var(--muted); display: flex; gap: 6px; align-items: center; }
61+canvas { flex: 1; width: 100%; height: 100%; display: block; min-height: 0; }
62+@media (max-width: 900px) { #app { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; } #sidebar { max-height: 45vh; } .splitter.vertical { display: none; } }
src/ui/modalView.tsadded+383−0View file
@@ -0,0 +1,383 @@
1+// The modal views of a run. The spectrogram is time against mode frequency with colour the modal
2+// energy in dB below the loudest value seen so far: each mode is one horizontal trace, so energy
3+// arriving in a mode is visible as its trace lighting up, resolved per mode rather than smeared
4+// by an STFT window. The bar view is the energy of every mode at the cursor time. Both share the
5+// frequency axis (linear or logarithmic, with a selectable top) and the dB range, and clicking
6+// near a trace or a bar selects that mode.
7+import type { Stft } from "../analysis/spectrogram.ts";
8+import type { ModalTracker } from "../modal/tracker.ts";
9+
10+export type FrequencyScale = "linear" | "log";
11+
12+/** Perceptual-ish dark-to-bright colour ramp for t in [0, 1]. */
13+function rampColor(t: number): [number, number, number] {
14+ const stops: [number, number, number][] = [
15+ [12, 13, 18], [48, 26, 74], [120, 35, 107], [193, 62, 88], [240, 122, 52], [252, 200, 96], [252, 252, 200],
16+ ];
17+ const x = Math.min(1, Math.max(0, t)) * (stops.length - 1), i = Math.min(stops.length - 2, Math.floor(x)), f = x - i;
18+ return [0, 1, 2].map((k) => Math.round(stops[i][k] + f * (stops[i + 1][k] - stops[i][k]))) as [number, number, number];
19+}
20+
21+class FrequencyAxis {
22+ scale: FrequencyScale = "linear";
23+ fmax = 20000;
24+ fmin = 30;
25+ /** Pixel y (0 at the top) of frequency f within a plot of height H. */
26+ y(f: number, H: number): number {
27+ if (this.scale === "linear") return H * (1 - f / this.fmax);
28+ const l0 = Math.log(this.fmin), l1 = Math.log(this.fmax);
29+ return H * (1 - (Math.log(Math.max(f, this.fmin)) - l0) / (l1 - l0));
30+ }
31+ /** Frequency at pixel y, the inverse of ``y``. */
32+ f(y: number, H: number): number {
33+ if (this.scale === "linear") return this.fmax * (1 - y / H);
34+ const l0 = Math.log(this.fmin), l1 = Math.log(this.fmax);
35+ return Math.exp(l0 + (1 - y / H) * (l1 - l0));
36+ }
37+ ticks(): number[] {
38+ if (this.scale === "linear") {
39+ const step = this.fmax > 10000 ? 2000 : this.fmax > 4000 ? 1000 : this.fmax > 1500 ? 500 : 200;
40+ const out = [];
41+ for (let f = step; f <= this.fmax; f += step) out.push(f);
42+ return out;
43+ }
44+ return [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000].filter((f) => f >= this.fmin && f <= this.fmax);
45+ }
46+}
47+
48+const LEFT = 44, BOTTOM = 16;
49+
50+export class ModalSpectrogram {
51+ private readonly canvas: HTMLCanvasElement;
52+ private readonly image = document.createElement("canvas");
53+ readonly axis = new FrequencyAxis();
54+ dbRange = 80;
55+ tracker: ModalTracker | null = null;
56+ duration = 1; // x extent [s]
57+ cursorTime: number | null = null;
58+ selectedMode = -1;
59+ onSelect: ((mode: number, time: number) => void) | null = null;
60+ onHover: ((text: string) => void) | null = null;
61+ private dirty = true;
62+
63+ constructor(canvas: HTMLCanvasElement) {
64+ this.canvas = canvas;
65+ canvas.addEventListener("pointerdown", (e) => {
66+ const pick = this.pick(e);
67+ if (pick) this.onSelect?.(pick.mode, pick.time);
68+ });
69+ canvas.addEventListener("pointermove", (e) => {
70+ const pick = this.pick(e);
71+ if (!pick || !this.tracker) { this.onHover?.(""); return; }
72+ const s = this.tracker.indexAtTime(pick.time);
73+ const E = s >= 0 ? this.tracker.energies[s][pick.mode] : 0;
74+ this.onHover?.(`mode ${pick.mode} at ${this.freqOf(pick.mode).toFixed(1)} Hz, ${this.db(E).toFixed(0)} dB at t = ${pick.time.toFixed(2)} s`);
75+ });
76+ canvas.addEventListener("pointerleave", () => this.onHover?.(""));
77+ }
78+
79+ private freqOf(m: number) { return this.tracker ? this.tracker.omega[m] / (2 * Math.PI) : 0; }
80+ private db(E: number) { return 10 * Math.log10(Math.max(E, 1e-300) / Math.max(this.tracker?.maxEnergy ?? 1, 1e-300)); }
81+
82+ private pick(e: PointerEvent): { mode: number; time: number } | null {
83+ const t = this.tracker;
84+ if (!t || !t.length) return null;
85+ const r = this.canvas.getBoundingClientRect();
86+ const W = r.width - LEFT, H = r.height - BOTTOM;
87+ const px = e.clientX - r.left - LEFT, py = e.clientY - r.top;
88+ if (px < 0 || py > H) return null;
89+ const time = Math.max(0, Math.min(this.duration, (px / W) * this.duration));
90+ let best = -1, bestD = 8; // within 8 px of a trace
91+ for (let m = 0; m < t.nModes; m++) {
92+ const d = Math.abs(this.axis.y(this.freqOf(m), H) - py);
93+ if (d < bestD) { bestD = d; best = m; }
94+ }
95+ return best >= 0 ? { mode: best, time } : null;
96+ }
97+
98+ invalidate() { this.dirty = true; }
99+
100+ /** Rebuild the cached image: max-aggregate the snapshots into pixel columns. */
101+ private renderImage(W: number, H: number) {
102+ const img = this.image;
103+ if (img.width !== W || img.height !== H) { img.width = W; img.height = H; }
104+ const g = img.getContext("2d")!;
105+ g.fillStyle = "#0c0d12"; g.fillRect(0, 0, W, H);
106+ const t = this.tracker;
107+ if (!t || !t.length) return;
108+ const data = g.getImageData(0, 0, W, H), px = data.data;
109+ const cols = new Float32Array(W * H).fill(-1e30);
110+ const maxE = Math.max(t.maxEnergy, 1e-300);
111+ for (let s = 0; s < t.length; s++) {
112+ const x = Math.min(W - 1, Math.floor((t.times[s] / this.duration) * W));
113+ const E = t.energies[s];
114+ for (let m = 0; m < t.nModes; m++) {
115+ const f = t.omega[m] / (2 * Math.PI);
116+ if (f > this.axis.fmax) continue;
117+ const y = Math.round(this.axis.y(f, H));
118+ if (y < 0 || y >= H) continue;
119+ const dB = 10 * Math.log10(Math.max(E[m], 1e-300) / maxE);
120+ const i = y * W + x;
121+ if (dB > cols[i]) cols[i] = dB;
122+ }
123+ }
124+ for (let i = 0; i < W * H; i++) {
125+ if (cols[i] < -1e29) continue;
126+ const [r, gg, b] = rampColor(1 + cols[i] / this.dbRange);
127+ px[4 * i] = r; px[4 * i + 1] = gg; px[4 * i + 2] = b; px[4 * i + 3] = 255;
128+ }
129+ g.putImageData(data, 0, 0);
130+ this.dirty = false;
131+ }
132+
133+ draw() {
134+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
135+ const CW = Math.max(1, Math.round(cv.clientWidth * dpr)), CH = Math.max(1, Math.round(cv.clientHeight * dpr));
136+ if (cv.width !== CW || cv.height !== CH) { cv.width = CW; cv.height = CH; this.dirty = true; }
137+ const ctx = cv.getContext("2d")!;
138+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
139+ const W = cv.clientWidth - LEFT, H = cv.clientHeight - BOTTOM;
140+ if (W < 10 || H < 10) return;
141+ if (this.dirty || this.image.width !== W || this.image.height !== H) this.renderImage(W, H);
142+ ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cv.clientWidth, cv.clientHeight);
143+ ctx.drawImage(this.image, LEFT, 0);
144+ // axes
145+ ctx.fillStyle = "#9aa0a8"; ctx.font = "10px system-ui"; ctx.textAlign = "right"; ctx.textBaseline = "middle";
146+ for (const f of this.axis.ticks()) {
147+ const y = this.axis.y(f, H);
148+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), LEFT - 4, y);
149+ ctx.strokeStyle = "rgba(154,160,168,0.12)"; ctx.beginPath(); ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + W, y); ctx.stroke();
150+ }
151+ ctx.textAlign = "center"; ctx.textBaseline = "top";
152+ const tStep = this.duration > 8 ? 2 : this.duration > 3 ? 1 : this.duration > 1.2 ? 0.5 : 0.2;
153+ for (let tt = 0; tt <= this.duration + 1e-9; tt += tStep) {
154+ const x = LEFT + (tt / this.duration) * W;
155+ ctx.fillText(`${tt.toFixed(tt < 1 && tStep < 1 ? 1 : 0)}s`, x, H + 3);
156+ }
157+ // selected mode trace and cursor
158+ if (this.tracker && this.selectedMode >= 0 && this.selectedMode < this.tracker.nModes) {
159+ const y = this.axis.y(this.freqOf(this.selectedMode), H);
160+ ctx.strokeStyle = "rgba(224,162,58,0.8)"; ctx.setLineDash([3, 4]);
161+ ctx.beginPath(); ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + W, y); ctx.stroke(); ctx.setLineDash([]);
162+ }
163+ if (this.cursorTime !== null) {
164+ const x = LEFT + (this.cursorTime / this.duration) * W;
165+ ctx.strokeStyle = "rgba(230,230,230,0.7)";
166+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
167+ }
168+ }
169+}
170+
171+export class EnergyBars {
172+ private readonly canvas: HTMLCanvasElement;
173+ readonly axis: FrequencyAxis; // shared with the spectrogram
174+ dbRange = 80;
175+ tracker: ModalTracker | null = null;
176+ snapshot = -1;
177+ selectedMode = -1;
178+ onSelect: ((mode: number) => void) | null = null;
179+ onHover: ((text: string) => void) | null = null;
180+
181+ constructor(canvas: HTMLCanvasElement, axis: FrequencyAxis) {
182+ this.canvas = canvas; this.axis = axis;
183+ canvas.addEventListener("pointerdown", (e) => { const m = this.pick(e); if (m >= 0) this.onSelect?.(m); });
184+ canvas.addEventListener("pointermove", (e) => {
185+ const m = this.pick(e);
186+ if (m < 0 || !this.tracker || this.snapshot < 0) { this.onHover?.(""); return; }
187+ const E = this.tracker.energies[this.snapshot][m];
188+ this.onHover?.(`mode ${m} at ${(this.tracker.omega[m] / (2 * Math.PI)).toFixed(1)} Hz, ${this.db(E).toFixed(0)} dB`);
189+ });
190+ canvas.addEventListener("pointerleave", () => this.onHover?.(""));
191+ }
192+
193+ private db(E: number) { return 10 * Math.log10(Math.max(E, 1e-300) / Math.max(this.tracker?.maxEnergy ?? 1, 1e-300)); }
194+
195+ /** Frequency is on x here (the transpose of the spectrogram's y). */
196+ private x(f: number, W: number): number { return W - this.axis.y(f, W); }
197+
198+ private pick(e: PointerEvent): number {
199+ const t = this.tracker;
200+ if (!t) return -1;
201+ const r = this.canvas.getBoundingClientRect();
202+ const W = r.width - LEFT, px = e.clientX - r.left - LEFT;
203+ let best = -1, bestD = 5;
204+ for (let m = 0; m < t.nModes; m++) {
205+ const d = Math.abs(this.x(t.omega[m] / (2 * Math.PI), W) - px);
206+ if (d < bestD) { bestD = d; best = m; }
207+ }
208+ return best;
209+ }
210+
211+ draw() {
212+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
213+ const CW = Math.max(1, Math.round(cv.clientWidth * dpr)), CH = Math.max(1, Math.round(cv.clientHeight * dpr));
214+ if (cv.width !== CW || cv.height !== CH) { cv.width = CW; cv.height = CH; }
215+ const ctx = cv.getContext("2d")!;
216+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
217+ const W = cv.clientWidth - LEFT, H = cv.clientHeight - BOTTOM;
218+ ctx.fillStyle = "#0c0d12"; ctx.fillRect(0, 0, cv.clientWidth, cv.clientHeight);
219+ if (W < 10 || H < 10) return;
220+ ctx.fillStyle = "#9aa0a8"; ctx.font = "10px system-ui"; ctx.textAlign = "center"; ctx.textBaseline = "top";
221+ for (const f of this.axis.ticks()) ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), LEFT + this.x(f, W), H + 3);
222+ ctx.textAlign = "right"; ctx.textBaseline = "middle";
223+ for (let dB = 0; dB >= -this.dbRange; dB -= 20) {
224+ const y = (-dB / this.dbRange) * H;
225+ ctx.fillText(String(dB), LEFT - 4, Math.min(H - 6, Math.max(6, y)));
226+ ctx.strokeStyle = "rgba(154,160,168,0.12)"; ctx.beginPath(); ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + W, y); ctx.stroke();
227+ }
228+ const t = this.tracker;
229+ if (!t || this.snapshot < 0 || this.snapshot >= t.length) return;
230+ const E = t.energies[this.snapshot];
231+ for (let m = 0; m < t.nModes; m++) {
232+ const f = t.omega[m] / (2 * Math.PI);
233+ if (f > this.axis.fmax) continue;
234+ const dB = this.db(E[m]);
235+ if (dB < -this.dbRange) continue;
236+ const x = LEFT + this.x(f, W), h = (1 + dB / this.dbRange) * H;
237+ const driven = m === t.drivenMode, sel = m === this.selectedMode;
238+ ctx.strokeStyle = sel ? "#e0a23a" : driven ? "#6fbf73" : "#7f9fd0";
239+ ctx.lineWidth = sel || driven ? 2 : 1;
240+ ctx.beginPath(); ctx.moveTo(x, H); ctx.lineTo(x, H - h); ctx.stroke();
241+ }
242+ ctx.lineWidth = 1;
243+ }
244+}
245+
246+/** A true spectrogram (of the listener signal or of one modal coordinate) on the same frequency
247+ * axis as the modal view, with the linear mode frequencies overlaid as ticks at the left edge.
248+ * This is the complement of the modal-energy view: the modal view says which shapes hold the
249+ * energy, pinned at their linear frequencies; this view says at which frequencies the signal
250+ * actually oscillates, so combination tones between the modes, amplitude-shifted peaks and the
251+ * broadband turbulent floor are visible against the modal grid. */
252+export class StftView {
253+ private readonly canvas: HTMLCanvasElement;
254+ private readonly image = document.createElement("canvas");
255+ readonly axis: FrequencyAxis;
256+ dbRange = 80;
257+ stft: Stft | null = null;
258+ duration = 1;
259+ cursorTime: number | null = null;
260+ modeFreqs: Float64Array | null = null;
261+ selectedMode = -1;
262+ drivenMode = -1;
263+ onSelect: ((time: number) => void) | null = null;
264+ onHover: ((text: string) => void) | null = null;
265+ private dirty = true;
266+
267+ constructor(canvas: HTMLCanvasElement, axis: FrequencyAxis) {
268+ this.canvas = canvas; this.axis = axis;
269+ canvas.addEventListener("pointerdown", (e) => {
270+ const p = this.pos(e);
271+ if (p) this.onSelect?.(p.time);
272+ });
273+ canvas.addEventListener("pointermove", (e) => {
274+ const p = this.pos(e);
275+ if (!p) { this.onHover?.(""); return; }
276+ let text = `t = ${p.time.toFixed(2)} s, f = ${p.f.toFixed(0)} Hz`;
277+ if (this.modeFreqs && this.modeFreqs.length) {
278+ let best = 0;
279+ for (let m = 1; m < this.modeFreqs.length; m++) if (Math.abs(this.modeFreqs[m] - p.f) < Math.abs(this.modeFreqs[best] - p.f)) best = m;
280+ const df = p.f - this.modeFreqs[best];
281+ text += `; nearest mode ${best} at ${this.modeFreqs[best].toFixed(0)} Hz (${df >= 0 ? "+" : ""}${df.toFixed(0)} Hz)`;
282+ }
283+ this.onHover?.(text);
284+ });
285+ canvas.addEventListener("pointerleave", () => this.onHover?.(""));
286+ }
287+
288+ private pos(e: PointerEvent): { time: number; f: number } | null {
289+ const r = this.canvas.getBoundingClientRect();
290+ const W = r.width - LEFT, H = r.height - BOTTOM;
291+ const px = e.clientX - r.left - LEFT, py = e.clientY - r.top;
292+ if (px < 0 || py > H || W < 10) return null;
293+ return { time: Math.max(0, Math.min(this.duration, (px / W) * this.duration)), f: this.axis.f(py, H) };
294+ }
295+
296+ invalidate() { this.dirty = true; }
297+
298+ private renderImage(W: number, H: number) {
299+ const img = this.image;
300+ if (img.width !== W || img.height !== H) { img.width = W; img.height = H; }
301+ const g = img.getContext("2d")!;
302+ g.fillStyle = "#0c0d12"; g.fillRect(0, 0, W, H);
303+ const st = this.stft;
304+ if (!st || !st.columns.length) { this.dirty = false; return; }
305+ let maxP = 0;
306+ for (const col of st.columns) for (const p of col) maxP = Math.max(maxP, p);
307+ if (!(maxP > 0)) { this.dirty = false; return; }
308+ const data = g.getImageData(0, 0, W, H), px = data.data;
309+ const cells = new Float32Array(W * H).fill(-1e30);
310+ for (let c = 0; c < st.columns.length; c++) {
311+ const x = Math.min(W - 1, Math.floor((st.columnTime(c) / this.duration) * W));
312+ if (x < 0) continue;
313+ const col = st.columns[c];
314+ for (let b = 1; b < st.nBins; b++) {
315+ const f = st.binFrequency(b);
316+ if (f > this.axis.fmax) break;
317+ const y = Math.round(this.axis.y(f, H));
318+ if (y < 0 || y >= H) continue;
319+ const dB = 10 * Math.log10(col[b] / maxP);
320+ const i = y * W + x;
321+ if (dB > cells[i]) cells[i] = dB;
322+ }
323+ }
324+ for (let i = 0; i < W * H; i++) {
325+ if (cells[i] < -1e29) continue;
326+ const [r, gg, b] = rampColor(1 + cells[i] / this.dbRange);
327+ px[4 * i] = r; px[4 * i + 1] = gg; px[4 * i + 2] = b; px[4 * i + 3] = 255;
328+ }
329+ g.putImageData(data, 0, 0);
330+ this.dirty = false;
331+ }
332+
333+ draw() {
334+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
335+ const CW = Math.max(1, Math.round(cv.clientWidth * dpr)), CH = Math.max(1, Math.round(cv.clientHeight * dpr));
336+ if (cv.width !== CW || cv.height !== CH) { cv.width = CW; cv.height = CH; this.dirty = true; }
337+ const ctx = cv.getContext("2d")!;
338+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
339+ const W = cv.clientWidth - LEFT, H = cv.clientHeight - BOTTOM;
340+ if (W < 10 || H < 10) return;
341+ if (this.dirty || this.image.width !== W || this.image.height !== H) this.renderImage(W, H);
342+ ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cv.clientWidth, cv.clientHeight);
343+ ctx.drawImage(this.image, LEFT, 0);
344+ // axes
345+ ctx.fillStyle = "#9aa0a8"; ctx.font = "10px system-ui"; ctx.textAlign = "right"; ctx.textBaseline = "middle";
346+ for (const f of this.axis.ticks()) {
347+ const y = this.axis.y(f, H);
348+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), LEFT - 4, y);
349+ ctx.strokeStyle = "rgba(154,160,168,0.12)"; ctx.beginPath(); ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + W, y); ctx.stroke();
350+ }
351+ ctx.textAlign = "center"; ctx.textBaseline = "top";
352+ const tStep = this.duration > 8 ? 2 : this.duration > 3 ? 1 : this.duration > 1.2 ? 0.5 : 0.2;
353+ for (let tt = 0; tt <= this.duration + 1e-9; tt += tStep) {
354+ const x = LEFT + (tt / this.duration) * W;
355+ ctx.fillText(`${tt.toFixed(tt < 1 && tStep < 1 ? 1 : 0)}s`, x, H + 3);
356+ }
357+ // the linear modal grid: a tick at the left edge for every mode frequency
358+ if (this.modeFreqs) {
359+ ctx.strokeStyle = "rgba(127,159,208,0.55)";
360+ ctx.beginPath();
361+ for (let m = 0; m < this.modeFreqs.length; m++) {
362+ const f = this.modeFreqs[m];
363+ if (f > this.axis.fmax) continue;
364+ const y = this.axis.y(f, H);
365+ ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + 6, y);
366+ }
367+ ctx.stroke();
368+ const line = (m: number, color: string) => {
369+ if (m < 0 || m >= this.modeFreqs!.length) return;
370+ const y = this.axis.y(this.modeFreqs![m], H);
371+ ctx.strokeStyle = color; ctx.setLineDash([3, 4]);
372+ ctx.beginPath(); ctx.moveTo(LEFT, y); ctx.lineTo(LEFT + W, y); ctx.stroke(); ctx.setLineDash([]);
373+ };
374+ line(this.drivenMode, "rgba(111,191,115,0.7)");
375+ if (this.selectedMode !== this.drivenMode) line(this.selectedMode, "rgba(224,162,58,0.8)");
376+ }
377+ if (this.cursorTime !== null) {
378+ const x = LEFT + (this.cursorTime / this.duration) * W;
379+ ctx.strokeStyle = "rgba(230,230,230,0.7)";
380+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
381+ }
382+ }
383+}
src/ui/modeList.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+// The mode table: one row per mode with its frequency, decay time, heuristic (m, n) label and,
2+// during or after a run, its energy in dB below the loudest mode. Clicking a row selects the
3+// mode. The labels are filled in lazily in idle chunks; the energy column is refreshed at a few
4+// hertz, not per frame.
5+import { classifyMode } from "../modal/classify.ts";
6+import type { ModesData } from "../modes.ts";
7+
8+export class ModeList {
9+ private readonly body: HTMLTableSectionElement;
10+ private rows: HTMLTableRowElement[] = [];
11+ private energyCells: HTMLTableCellElement[] = [];
12+ private labelCells: HTMLTableCellElement[] = [];
13+ private modes: ModesData | null = null;
14+ private labelTimer = 0;
15+ selected = -1;
16+ driven = -1;
17+ onSelect: ((mode: number) => void) | null = null;
18+
19+ constructor(container: HTMLElement) {
20+ const table = document.createElement("table");
21+ table.innerHTML = "<thead><tr><th>#</th><th>f [Hz]</th><th>T60 [s]</th><th>(m, n)</th><th>E [dB]</th></tr></thead>";
22+ this.body = document.createElement("tbody");
23+ table.appendChild(this.body);
24+ container.appendChild(table);
25+ }
26+
27+ setModes(modes: ModesData | null, freq: Float64Array | null, sigma: Float64Array | null) {
28+ clearTimeout(this.labelTimer);
29+ this.modes = modes;
30+ this.body.replaceChildren();
31+ this.rows = []; this.energyCells = []; this.labelCells = [];
32+ if (!modes || !freq || !sigma) return;
33+ const frag = document.createDocumentFragment();
34+ for (let m = 0; m < modes.nModes; m++) {
35+ const tr = document.createElement("tr");
36+ const cells = [String(m), freq[m] < 1000 ? freq[m].toFixed(1) : freq[m].toFixed(0), (6.907755 / sigma[m]).toFixed(2), "", ""];
37+ for (const c of cells) { const td = document.createElement("td"); td.textContent = c; tr.appendChild(td); }
38+ tr.onclick = () => this.onSelect?.(m);
39+ this.rows.push(tr);
40+ this.labelCells.push(tr.children[3] as HTMLTableCellElement);
41+ this.energyCells.push(tr.children[4] as HTMLTableCellElement);
42+ frag.appendChild(tr);
43+ }
44+ this.body.appendChild(frag);
45+ this.fillLabels(0);
46+ this.applyMarks();
47+ }
48+
49+ /** Refresh the frequency and T60 columns after a live material or damping change. */
50+ setDerived(freq: Float64Array, sigma: Float64Array) {
51+ for (let m = 0; m < this.rows.length; m++) {
52+ const c = this.rows[m].children;
53+ (c[1] as HTMLElement).textContent = freq[m] < 1000 ? freq[m].toFixed(1) : freq[m].toFixed(0);
54+ (c[2] as HTMLElement).textContent = (6.907755 / sigma[m]).toFixed(2);
55+ }
56+ }
57+
58+ private fillLabels(from: number) {
59+ const modes = this.modes;
60+ if (!modes) return;
61+ const t0 = performance.now();
62+ let m = from;
63+ while (m < modes.nModes && performance.now() - t0 < 12) {
64+ const { diameters, circles } = classifyMode(modes, m);
65+ this.labelCells[m].textContent = `(${diameters}, ${circles})`;
66+ m++;
67+ }
68+ if (m < modes.nModes) this.labelTimer = window.setTimeout(() => this.fillLabels(m), 30);
69+ }
70+
71+ setEnergies(E: Float32Array | null, maxE: number) {
72+ for (let m = 0; m < this.energyCells.length; m++) {
73+ const cell = this.energyCells[m];
74+ if (!E || !(maxE > 0)) { if (cell.textContent) cell.textContent = ""; continue; }
75+ const dB = 10 * Math.log10(Math.max(E[m], 1e-300) / maxE);
76+ cell.textContent = dB > -120 ? dB.toFixed(0) : "";
77+ }
78+ }
79+
80+ select(mode: number) {
81+ this.selected = mode;
82+ this.applyMarks();
83+ const row = this.rows[mode];
84+ if (row) row.scrollIntoView({ block: "nearest" });
85+ }
86+
87+ setDriven(mode: number) { this.driven = mode; this.applyMarks(); }
88+
89+ private applyMarks() {
90+ for (let m = 0; m < this.rows.length; m++) {
91+ this.rows[m].classList.toggle("selected", m === this.selected);
92+ this.rows[m].classList.toggle("driven", m === this.driven);
93+ }
94+ }
95+}
src/ui/plateView.tsadded+185−0View file
@@ -0,0 +1,185 @@
1+// Live view of the cymbal: a lit 3D height field rendered with WebGPU from the mesh and the
2+// simulation's state buffer (w read straight from it, no readback), or a 2D colour map on a
3+// canvas when WebGPU is unavailable. Drag to orbit, wheel to zoom.
4+
5+/** What the renderer needs of a mesh; both a live Setup's mesh and a modes file satisfy it. */
6+export interface PlateMesh { n: number; e: number; xy: Float32Array | Float64Array; tri: Int32Array | Uint32Array }
7+export type NodalField = Float32Array | Float64Array;
8+
9+type Mat4 = Float32Array;
10+function perspective(fovy: number, aspect: number, near: number, far: number): Mat4 {
11+ const f = 1 / Math.tan(fovy / 2), m = new Float32Array(16);
12+ m[0] = f / aspect; m[5] = f; m[10] = far / (near - far); m[11] = -1; m[14] = (near * far) / (near - far);
13+ return m;
14+}
15+function lookAt(eye: number[], target: number[], up: number[]): Mat4 {
16+ const sub = (a: number[], b: number[]) => a.map((x, i) => x - b[i]);
17+ const norm = (a: number[]) => { const l = Math.hypot(...a); return a.map((x) => x / l); };
18+ const cross = (a: number[], b: number[]) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
19+ const z = norm(sub(eye, target)), x = norm(cross(up, z)), y = cross(z, x);
20+ const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
21+ return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
22+}
23+function mul(a: Mat4, b: Mat4): Mat4 {
24+ const m = new Float32Array(16);
25+ for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k]; m[i * 4 + j] = s; }
26+ return m;
27+}
28+
29+const SHADER = /* wgsl */ `
30+struct U { mvp: mat4x4<f32>, exag: f32, wScale: f32, zMid: f32, pad: f32 }
31+@group(0) @binding(0) var<uniform> u: U;
32+@group(0) @binding(1) var<storage, read> pos: array<f32>;
33+@group(0) @binding(2) var<storage, read> state: array<f32>;
34+struct VSOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) color: vec3<f32> }
35+@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
36+ let w = state[5u * vi + 2u];
37+ let p = vec3<f32>(pos[3u * vi], pos[3u * vi + 1u], pos[3u * vi + 2u] + u.exag * w - u.zMid);
38+ let t = clamp(w / u.wScale, -1.0, 1.0);
39+ let bronze = vec3<f32>(0.80, 0.62, 0.32);
40+ let c = mix(bronze, select(vec3<f32>(0.15, 0.35, 1.0), vec3<f32>(1.0, 0.2, 0.15), t > 0.0), abs(t));
41+ var o: VSOut; o.clip = u.mvp * vec4<f32>(p, 1.0); o.world = p; o.color = c; return o;
42+}
43+@fragment fn fs(in: VSOut) -> @location(0) vec4<f32> {
44+ let n = normalize(cross(dpdx(in.world), dpdy(in.world)));
45+ let l = normalize(vec3<f32>(0.4, 0.3, 1.0));
46+ let shade = 0.3 + 0.7 * abs(dot(n, l));
47+ return vec4<f32>(in.color * shade, 1.0);
48+}`;
49+
50+export class PlateView {
51+ private readonly canvas: HTMLCanvasElement;
52+ private mesh: PlateMesh | null = null; private z0: NodalField | null = null; private hRef = 1e-3;
53+ private w: Float32Array | null = null;
54+ // camera
55+ private az = 0.6; private el = 0.9; private dist = 0.7;
56+ exaggeration = 20;
57+ // WebGPU
58+ private device: GPUDevice | null = null; private ctx: GPUCanvasContext | null = null; private format: GPUTextureFormat = "bgra8unorm";
59+ private pipeline: GPURenderPipeline | null = null; private uniform: GPUBuffer | null = null; private posBuf: GPUBuffer | null = null;
60+ private stateBuf: GPUBuffer | null = null; private ownState = false; private indexBuf: GPUBuffer | null = null;
61+ private depth: GPUTexture | null = null; private bindGroup: GPUBindGroup | null = null;
62+
63+ constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
64+ this.canvas = canvas; this.device = device;
65+ if (device) {
66+ this.ctx = canvas.getContext("webgpu");
67+ if (this.ctx) {
68+ this.format = navigator.gpu.getPreferredCanvasFormat();
69+ this.ctx.configure({ device, format: this.format, alphaMode: "opaque" });
70+ const module = device.createShaderModule({ code: SHADER });
71+ this.pipeline = device.createRenderPipeline({
72+ layout: "auto", vertex: { module, entryPoint: "vs" }, fragment: { module, entryPoint: "fs", targets: [{ format: this.format }] },
73+ primitive: { topology: "triangle-list", cullMode: "none" }, depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
74+ });
75+ this.uniform = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
76+ } else this.device = null;
77+ }
78+ let dragging = false, lx = 0, ly = 0;
79+ canvas.addEventListener("pointerdown", (e) => { dragging = true; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
80+ canvas.addEventListener("pointermove", (e) => {
81+ if (!dragging) return;
82+ this.az -= (e.clientX - lx) * 0.01; this.el = Math.min(1.5, Math.max(0.05, this.el + (e.clientY - ly) * 0.01)); lx = e.clientX; ly = e.clientY; this.render();
83+ });
84+ canvas.addEventListener("pointerup", () => { dragging = false; });
85+ canvas.addEventListener("wheel", (e) => { e.preventDefault(); this.dist *= Math.exp(e.deltaY * 0.001); this.render(); }, { passive: false });
86+ }
87+
88+ get usesWebGPU() { return this.device !== null; }
89+
90+ resetView() { this.az = 0.6; this.el = 0.9; this.dist = 0.7; this.render(); }
91+
92+ /** Switch between reading w from the live simulation buffer and uploading frames from the CPU. */
93+ useOwnState(): void {
94+ const d = this.device;
95+ if (!d || !this.mesh || this.ownState) return;
96+ this.stateBuf = d.createBuffer({ size: 5 * this.mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
97+ this.ownState = true;
98+ this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
99+ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf! } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
100+ }
101+
102+ /** Install a mesh; ``stateBuffer`` is the simulator's state buffer (5 floats per node) or null to upload w from the CPU. */
103+ setMesh(mesh: PlateMesh, z0: NodalField, hRef: number, stateBuffer: GPUBuffer | null) {
104+ this.mesh = mesh; this.z0 = z0; this.hRef = hRef; this.w = new Float32Array(mesh.n);
105+ const d = this.device;
106+ if (d) {
107+ this.posBuf?.destroy(); this.indexBuf?.destroy(); if (this.ownState) this.stateBuf?.destroy();
108+ const pos = new Float32Array(3 * mesh.n);
109+ for (let n = 0; n < mesh.n; n++) { pos[3 * n] = mesh.xy[2 * n]; pos[3 * n + 1] = mesh.xy[2 * n + 1]; pos[3 * n + 2] = z0[n]; }
110+ this.posBuf = d.createBuffer({ size: pos.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
111+ d.queue.writeBuffer(this.posBuf, 0, pos);
112+ this.indexBuf = d.createBuffer({ size: mesh.tri.byteLength, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
113+ d.queue.writeBuffer(this.indexBuf, 0, new Uint32Array(mesh.tri));
114+ if (stateBuffer) { this.stateBuf = stateBuffer; this.ownState = false; }
115+ else { this.stateBuf = d.createBuffer({ size: 5 * mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); this.ownState = true; }
116+ this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
117+ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
118+ }
119+ this.render();
120+ }
121+
122+ /** Fixed reference amplitude for the colour map [m]; 0 restores the thickness-based default. */
123+ colorScale = 0;
124+
125+ private uploadBuf: Float32Array<ArrayBuffer> | null = null;
126+
127+ /** Update w from the CPU (used with the CPU backend, for movie frames, or before a run). */
128+ setDisplacement(w: Float32Array) {
129+ this.w = w;
130+ if (this.device && this.ownState && this.stateBuf) {
131+ if (!this.uploadBuf || this.uploadBuf.length !== 5 * w.length) this.uploadBuf = new Float32Array(new ArrayBuffer(5 * w.length * 4));
132+ const st = this.uploadBuf;
133+ for (let n = 0; n < w.length; n++) st[5 * n + 2] = w[n];
134+ this.device.queue.writeBuffer(this.stateBuf, 0, st);
135+ }
136+ this.render();
137+ }
138+
139+ render() {
140+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
141+ const W = Math.max(1, Math.round(cv.clientWidth * dpr)), H = Math.max(1, Math.round(cv.clientHeight * dpr));
142+ if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; this.depth?.destroy(); this.depth = null; }
143+ if (!this.mesh) return;
144+ if (this.device && this.ctx && this.bindGroup) this.renderGpu(W, H); else this.render2d();
145+ }
146+
147+ private renderGpu(W: number, H: number) {
148+ const d = this.device!, mesh = this.mesh!;
149+ if (!this.depth) this.depth = d.createTexture({ size: [W, H], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
150+ let zMin = Infinity, zMax = -Infinity;
151+ for (const z of this.z0!) { zMin = Math.min(zMin, z); zMax = Math.max(zMax, z); }
152+ const zMid = 0.5 * (zMin + zMax), R = this.dist;
153+ const eye = [R * Math.cos(this.el) * Math.cos(this.az), R * Math.cos(this.el) * Math.sin(this.az), R * Math.sin(this.el)];
154+ const mvp = mul(perspective(0.6, W / H, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 0, 1]));
155+ const u = new Float32Array(20); u.set(mvp); u[16] = this.exaggeration; u[17] = this.colorScale > 0 ? this.colorScale : 3 * this.hRef; u[18] = zMid;
156+ d.queue.writeBuffer(this.uniform!, 0, u);
157+ const enc = d.createCommandEncoder();
158+ const pass = enc.beginRenderPass({
159+ colorAttachments: [{ view: this.ctx!.getCurrentTexture().createView(), clearValue: { r: 0.08, g: 0.086, b: 0.1, a: 1 }, loadOp: "clear", storeOp: "store" }],
160+ depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
161+ });
162+ pass.setPipeline(this.pipeline!); pass.setBindGroup(0, this.bindGroup!); pass.setIndexBuffer(this.indexBuf!, "uint32");
163+ pass.drawIndexed(3 * mesh.e); pass.end();
164+ d.queue.submit([enc.finish()]);
165+ }
166+
167+ private render2d() {
168+ const cv = this.canvas, ctx = cv.getContext("2d")!, mesh = this.mesh!, w = this.w!;
169+ const dpr = window.devicePixelRatio || 1;
170+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
171+ const cw = cv.clientWidth, ch = cv.clientHeight;
172+ ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cw, ch);
173+ let R = 0; for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
174+ const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = this.colorScale > 0 ? this.colorScale : 3 * this.hRef;
175+ for (let e = 0; e < mesh.e; e++) {
176+ const a = mesh.tri[3 * e], b = mesh.tri[3 * e + 1], c = mesh.tri[3 * e + 2];
177+ const t = Math.max(-1, Math.min(1, (w[a] + w[b] + w[c]) / (3 * scale)));
178+ const base = [204, 158, 82], tint = t > 0 ? [255, 51, 38] : [38, 89, 255], k = Math.abs(t);
179+ ctx.fillStyle = `rgb(${base.map((v, i) => Math.round(v + k * (tint[i] - v))).join(",")})`;
180+ ctx.beginPath(); ctx.moveTo(cx + s * mesh.xy[2 * a], cy - s * mesh.xy[2 * a + 1]);
181+ ctx.lineTo(cx + s * mesh.xy[2 * b], cy - s * mesh.xy[2 * b + 1]); ctx.lineTo(cx + s * mesh.xy[2 * c], cy - s * mesh.xy[2 * c + 1]);
182+ ctx.closePath(); ctx.fill();
183+ }
184+ }
185+}
tests/gpu.check.tsadded+97−0View file
@@ -0,0 +1,97 @@
1+// Runs the WGSL kernels under Dawn (the `webgpu` package) and compares them with the float64 CPU
2+// loop: same modal initial condition or drive, same projection, no hits. This is a plain script
3+// rather than a node:test file because Dawn's threads abort the process under the test runner.
4+// Run with `node tests/gpu.check.ts`; a failed check exits non-zero.
5+import assert from "node:assert/strict";
6+import { create, globals } from "webgpu";
7+import { cloneConfig } from "../src/config/yaml.ts";
8+import { CpuSimulator } from "../src/cpu/simulator.ts";
9+import { buildDrive, buildProjection, modalAmplitude, modalInitialState, modeShape, type DriveSpec } from "../src/drive.ts";
10+import { GpuSimulator } from "../src/gpu/simulator.ts";
11+import { buildSetup } from "../src/model/setup.ts";
12+import type { BackendOptions } from "../src/model/simulator.ts";
13+import { buildCondensationOperator } from "../src/precompute/assemble.ts";
14+import { computeModes } from "../src/precompute/pipeline.ts";
15+import { miniConfig, noProgress, OPTIONS } from "./helpers.ts";
16+
17+Object.assign(globalThis, globals);
18+
19+const gpuInstance = create([]);
20+const deviceP = gpuInstance.requestAdapter().then((a) => { if (!a) throw new Error("no WebGPU adapter"); return a.requestDevice(); });
21+
22+let failures = 0;
23+async function test(name: string, fn: () => Promise<void>) {
24+ const t0 = performance.now();
25+ try { await fn(); console.log(`ok - ${name} (${(performance.now() - t0).toFixed(0)} ms)`); }
26+ catch (err) { failures++; console.log(`not ok - ${name}\n ${(err as Error).message}`); }
27+}
28+
29+const base = miniConfig();
30+const modes = computeModes(base, OPTIONS, noProgress);
31+const MODE = 3;
32+
33+function mw(setup: Awaited<ReturnType<typeof buildSetup>>): Float64Array {
34+ const N = setup.mesh.n, Mw = new Float64Array(N);
35+ for (let n = 0; n < N; n++) Mw[n] = setup.shell.M[5 * n + 2];
36+ return Mw;
37+}
38+
39+async function runBoth(nonlinear: boolean, mechanism: "decay" | "forced", nSamples: number, chunk: number) {
40+ const cfg = cloneConfig(base);
41+ cfg.numerics.nonlinear = nonlinear;
42+ const setup = await buildSetup(cfg);
43+ const shape = modeShape(modes, MODE, 1);
44+ const projection = buildProjection(modes, mw(setup), 1, 16);
45+ const opts: BackendOptions = { projection, traceMode: MODE };
46+ let initial: { q: Float64Array; v: Float64Array } | null = null;
47+ if (mechanism === "decay") {
48+ const cond = buildCondensationOperator(setup.mesh, setup.shell, noProgress);
49+ initial = modalInitialState(shape, cond.complement(shape), modalAmplitude(modes, shape, 2));
50+ } else {
51+ const spec: DriveSpec = { mode: MODE, mechanism: "forced", amplitude: 0.3, detuneCents: 0, rampTime: 0.003, driveTime: 1 };
52+ opts.drive = buildDrive(spec, modes, shape, mw(setup), cfg.material.sigma0, 2 * Math.PI * modes.freq[MODE], setup.dt);
53+ }
54+ const device = await deviceP;
55+ const cpu = new CpuSimulator(setup, opts), gpu = new GpuSimulator(device, setup, 4096, opts);
56+ if (initial) { cpu.setState(initial.q, initial.v); gpu.setState(initial.q, initial.v); }
57+ let projChecked = 0, traceErr = 0, traceRef = 0;
58+ for (let done = 0; done < nSamples; done += chunk) {
59+ const n = Math.min(chunk, nSamples - done);
60+ const rc = await cpu.runChunk(n), rg = await gpu.runChunk(n);
61+ assert.deepEqual(rg.projSamples, rc.projSamples);
62+ assert.equal(rg.pressureFront.length, rc.pressureFront.length);
63+ for (let s = 0; s < rc.proj.length; s++) {
64+ let num = 0, den = 0;
65+ for (let i = 0; i < rc.proj[s].length; i++) { num += (rc.proj[s][i] - rg.proj[s][i]) ** 2; den += rc.proj[s][i] ** 2; }
66+ if (den > 0) { assert.ok(Math.sqrt(num / den) < 1e-2, `snapshot ${projChecked} rel error ${Math.sqrt(num / den)}`); projChecked++; }
67+ }
68+ assert.equal(rg.traceBase, rc.traceBase);
69+ assert.equal(rg.trace.length, rc.trace.length);
70+ for (let i = 0; i < rc.trace.length; i++) { traceErr += (rc.trace[i] - rg.trace[i]) ** 2; traceRef += rc.trace[i] ** 2; }
71+ }
72+ if (traceRef > 0) assert.ok(Math.sqrt(traceErr / traceRef) < 1e-2, `trace rel error ${Math.sqrt(traceErr / traceRef)}`);
73+ const dc = await cpu.diagnostics(), dg = await gpu.diagnostics();
74+ gpu.dispose();
75+ assert.ok(projChecked > 5, `only ${projChecked} modal snapshots compared`);
76+ return { dc, dg };
77+}
78+
79+const rel = (a: number, b: number) => Math.abs(a - b) / Math.max(Math.abs(b), 1e-30);
80+
81+await test("GPU matches the CPU loop on a nonlinear free decay with projection", async () => {
82+ const { dc, dg } = await runBoth(true, "decay", 400, 150);
83+ assert.ok(rel(dg.kinetic, dc.kinetic) < 2e-3, `kinetic ${dg.kinetic} vs ${dc.kinetic}`);
84+ assert.ok(rel(dg.membrane, dc.membrane) < 2e-3, `membrane ${dg.membrane} vs ${dc.membrane}`);
85+ assert.ok(rel(dg.bending, dc.bending) < 2e-3, `bending ${dg.bending} vs ${dc.bending}`);
86+ assert.ok(rel(dg.maxWoverH, dc.maxWoverH) < 2e-3);
87+});
88+
89+await test("GPU matches the CPU loop on a linear forced run", async () => {
90+ const { dc, dg } = await runBoth(false, "forced", 400, 130);
91+ assert.ok(rel(dg.kinetic, dc.kinetic) < 2e-3, `kinetic ${dg.kinetic} vs ${dc.kinetic}`);
92+ assert.ok(rel(dg.bending, dc.bending) < 2e-3, `bending ${dg.bending} vs ${dc.bending}`);
93+});
94+
95+if (failures) { console.error(`${failures} check(s) failed`); process.exit(1); }
96+console.log("gpu checks passed");
97+process.exit(0);
tests/helpers.tsadded+33−0View file
@@ -0,0 +1,33 @@
1+// A small cymbal for the tests: 30 cm across on a 30 mm mesh (about a hundred nodes), asymmetry
2+// through rim warp, ovality and bell offset rather than dents (which a 30 mm mesh cannot see),
3+// uniform damping only (sigma1 = 0) so the expected decay of every mode is exp(-2 sigma0 t).
4+import { defaultConfig, type SimulationConfig } from "../src/config/schema.ts";
5+import type { PrecomputeOptions } from "../src/precompute/pipeline.ts";
6+
7+export const ELEMENT_SIZE = 0.03;
8+
9+export function miniConfig(): SimulationConfig {
10+ const cfg = defaultConfig();
11+ cfg.hits = [];
12+ cfg.profile.radius = 0.15;
13+ cfg.profile.bell_height = 0.01;
14+ cfg.profile.bell_top_radius = 0.02;
15+ cfg.profile.bell_shoulder_radius = 0.06;
16+ cfg.profile.bow_height = 0.02;
17+ cfg.imperfections.n_dents = 0;
18+ cfg.imperfections.rim_warp = 0.0005;
19+ cfg.imperfections.shoulder_ovality = 0.03;
20+ cfg.imperfections.bell_offset = 0.001;
21+ cfg.mesh.element_size = ELEMENT_SIZE;
22+ cfg.material.sigma0 = 8;
23+ cfg.material.sigma1 = 0;
24+ cfg.numerics.fs = 32000;
25+ cfg.numerics.duration = 0.06;
26+ cfg.numerics.cfl_safety = 0.8;
27+ cfg.numerics.nonlinear = false;
28+ return cfg;
29+}
30+
31+export const OPTIONS: PrecomputeOptions = { elementSize: ELEMENT_SIZE, fmax: 4000 };
32+
33+export const noProgress = () => {};
tests/modal.test.tsadded+166−0View file
@@ -0,0 +1,166 @@
1+// The modal drive and projection against the float64 CPU loop, on the small test cymbal.
2+//
3+// The linear runs are the calibration: released from a mode shape (with the condensation
4+// complement) the linearised model must keep the energy in that mode and decay it at exp(-2
5+// sigma0 t); driven at resonance with the mass-weighted shape it must respond in that mode only.
6+// The linear basis and the simulator are not identical (the simulator carries in-plane and
7+// rotational inertia that the condensed eigenproblem drops), so "keeps the energy" is a
8+// tolerance, not an identity, and the nonlinear run is compared against the linear one rather
9+// than against zero.
10+import assert from "node:assert/strict";
11+import { test } from "node:test";
12+import { cloneConfig } from "../src/config/yaml.ts";
13+import { CpuSimulator } from "../src/cpu/simulator.ts";
14+import { buildDrive, buildProjection, modalAmplitude, modalInitialState, modeShape, type DriveSpec } from "../src/drive.ts";
15+import { ModalTracker } from "../src/modal/tracker.ts";
16+import { internalForce } from "../src/model/forces.ts";
17+import { buildSetup } from "../src/model/setup.ts";
18+import type { BackendOptions } from "../src/model/simulator.ts";
19+import { buildCondensationOperator } from "../src/precompute/assemble.ts";
20+import { computeModes } from "../src/precompute/pipeline.ts";
21+import { miniConfig, noProgress, OPTIONS } from "./helpers.ts";
22+
23+const base = miniConfig();
24+const modes = computeModes(base, OPTIONS, noProgress);
25+const MODE = 3;
26+
27+function transverseMass(setup: Awaited<ReturnType<typeof buildSetup>>): Float64Array {
28+ const N = setup.mesh.n, Mw = new Float64Array(N);
29+ for (let n = 0; n < N; n++) Mw[n] = setup.shell.M[5 * n + 2];
30+ return Mw;
31+}
32+
33+interface RunResult { tracker: ModalTracker; freq: Float64Array }
34+
35+async function runDecay(amplitude: number, nonlinear: boolean, o: Float64Array): Promise<RunResult> {
36+ const cfg = cloneConfig(base);
37+ cfg.numerics.nonlinear = nonlinear;
38+ const setup = await buildSetup(cfg);
39+ assert.equal(setup.mesh.n, modes.nNodes);
40+ const shape = modeShape(modes, MODE, 1);
41+ const initial = modalInitialState(shape, o, modalAmplitude(modes, shape, amplitude));
42+ const Mw = transverseMass(setup);
43+ const opts: BackendOptions = { projection: buildProjection(modes, Mw, 1, Math.max(1, Math.round(setup.fs / 500))) };
44+ const sim = new CpuSimulator(setup, opts);
45+ sim.setState(initial.q, initial.v);
46+ const tracker = new ModalTracker(modes.freq, MODE);
47+ let left = setup.nSamples;
48+ while (left > 0) { const n = Math.min(2048, left); tracker.addChunk(await sim.runChunk(n), setup.fs); left -= n; }
49+ return { tracker, freq: modes.freq };
50+}
51+
52+function drivenFraction(t: ModalTracker, s: number): number {
53+ const total = t.totals[s];
54+ return total > 0 ? t.energies[s][t.drivenMode] / total : 1;
55+}
56+
57+const baseSetup = await buildSetup(base);
58+const cond = buildCondensationOperator(baseSetup.mesh, baseSetup.shell, noProgress);
59+const complement = cond.complement(modeShape(modes, MODE, 1));
60+
61+test("the complement makes the mode an equilibrium of the in-plane and rotation DOFs", async () => {
62+ const cfg = cloneConfig(base);
63+ cfg.numerics.nonlinear = false;
64+ const setup = await buildSetup(cfg);
65+ const shape = modeShape(modes, MODE, 1);
66+ const a = modalAmplitude(modes, shape, 0.05);
67+ const { q } = modalInitialState(shape, complement, a);
68+ const f = internalForce(setup.shell, q);
69+ const N = setup.mesh.n;
70+ let fw = 0, fo = 0;
71+ for (let n = 0; n < N; n++) {
72+ fw += f[5 * n + 2] ** 2;
73+ fo += f[5 * n] ** 2 + f[5 * n + 1] ** 2 + f[5 * n + 3] ** 2 + f[5 * n + 4] ** 2;
74+ }
75+ assert.ok(Math.sqrt(fo / fw) < 0.05, `off-transverse force ${Math.sqrt(fo / fw)} of the transverse force`);
76+ // and the transverse force is -omega^2 Mw w of the eigenproblem
77+ const omega = 2 * Math.PI * modes.freq[MODE];
78+ let num = 0, den = 0;
79+ for (let n = 0; n < N; n++) {
80+ const expected = -omega * omega * setup.shell.M[5 * n + 2] * q[5 * n + 2];
81+ num += (f[5 * n + 2] - expected) ** 2; den += expected ** 2;
82+ }
83+ assert.ok(Math.sqrt(num / den) < 0.05, `transverse force off the eigenvalue by ${Math.sqrt(num / den)}`);
84+});
85+
86+test("projection of the initial state is the driven mode alone", async () => {
87+ const cfg = cloneConfig(base);
88+ const setup = await buildSetup(cfg);
89+ const shape = modeShape(modes, MODE, 1);
90+ const a = modalAmplitude(modes, shape, 0.05);
91+ const { q } = modalInitialState(shape, complement, a);
92+ const proj = buildProjection(modes, transverseMass(setup), 1, 1);
93+ const N = setup.mesh.n;
94+ let worst = 0;
95+ for (let m = 0; m < modes.nModes; m++) {
96+ let s = 0;
97+ for (let n = 0; n < N; n++) s += proj.phiM[m * N + n] * q[5 * n + 2];
98+ if (m === MODE) assert.ok(Math.abs(s / a - 1) < 0.01, `driven coordinate ${s} vs ${a}`);
99+ else worst = Math.max(worst, Math.abs(s / a));
100+ }
101+ assert.ok(worst < 0.02, `foreign coordinate up to ${worst} of the driven one`);
102+});
103+
104+const linear = await runDecay(0.05, false, complement);
105+const linearEnd = linear.tracker.length - 1;
106+
107+test("a small-amplitude linear release stays in its mode and decays at exp(-2 sigma0 t)", () => {
108+ const t = linear.tracker;
109+ assert.ok(t.length > 20);
110+ assert.ok(drivenFraction(t, 0) > 0.99, `initial fraction ${drivenFraction(t, 0)}`);
111+ assert.ok(drivenFraction(t, linearEnd) > 0.85, `final fraction ${drivenFraction(t, linearEnd)}`);
112+ // total modal energy against the exact initial value and the expected decay
113+ const shape = modeShape(modes, MODE, 1);
114+ const a = modalAmplitude(modes, shape, 0.05);
115+ const omega = 2 * Math.PI * modes.freq[MODE];
116+ const E0 = 0.5 * omega * omega * a * a;
117+ assert.ok(Math.abs(t.totals[0] / E0 - 1) < 0.02, `E(0) = ${t.totals[0]} vs ${E0}`);
118+ const span = t.times[linearEnd] - t.times[0];
119+ const expected = Math.exp(-2 * base.material.sigma0 * span);
120+ const measured = t.totals[linearEnd] / t.totals[0];
121+ assert.ok(Math.abs(measured / expected - 1) < 0.15, `decay ${measured} vs ${expected}`);
122+});
123+
124+test("a strong nonlinear release moves energy into other modes; the linear run does not", async () => {
125+ const nl = await runDecay(4, true, complement);
126+ const s = Math.min(linearEnd, nl.tracker.length - 1);
127+ const linOther = 1 - drivenFraction(linear.tracker, s);
128+ const nlOther = 1 - drivenFraction(nl.tracker, s);
129+ assert.ok(nlOther > 0.05, `nonlinear transfer only ${nlOther}`);
130+ assert.ok(nlOther > 5 * linOther, `nonlinear ${nlOther} vs linear ${linOther}`);
131+});
132+
133+test("harmonic forcing at resonance excites the driven mode alone (linear)", async () => {
134+ const cfg = cloneConfig(base);
135+ cfg.numerics.nonlinear = false;
136+ cfg.numerics.duration = 0.05;
137+ const setup = await buildSetup(cfg);
138+ const shape = modeShape(modes, MODE, 1);
139+ const spec: DriveSpec = { mode: MODE, mechanism: "forced", amplitude: 0.2, detuneCents: 0, rampTime: 0.005, driveTime: 1 };
140+ const sigma = cfg.material.sigma0;
141+ const drive = buildDrive(spec, modes, shape, transverseMass(setup), sigma, 2 * Math.PI * modes.freq[MODE], setup.dt);
142+ const sim = new CpuSimulator(setup, { drive, traceMode: MODE,
143+ projection: buildProjection(modes, transverseMass(setup), 1, Math.max(1, Math.round(setup.fs / 500))) });
144+ const tracker = new ModalTracker(modes.freq, MODE);
145+ let left = setup.nSamples, traceChecked = 0;
146+ while (left > 0) {
147+ const n = Math.min(2048, left);
148+ const res = await sim.runChunk(n);
149+ // the audio-rate trace agrees with the snapshot projection where they coincide
150+ assert.equal(res.trace.length, n);
151+ for (let s = 0; s < res.proj.length; s++) {
152+ const q = res.proj[s][MODE], t = res.trace[res.projSamples[s] - res.traceBase];
153+ assert.ok(Math.abs(t - q) <= 1e-6 * Math.max(Math.abs(q), 1e-12), `trace ${t} vs projection ${q}`);
154+ traceChecked++;
155+ }
156+ tracker.addChunk(res, setup.fs);
157+ left -= n;
158+ }
159+ assert.ok(traceChecked > 10, `only ${traceChecked} trace samples checked`);
160+ const end = tracker.length - 1;
161+ assert.ok(tracker.energies[end][MODE] > 0, "no response to the drive");
162+ assert.ok(drivenFraction(tracker, end) > 0.95, `driven fraction ${drivenFraction(tracker, end)}`);
163+ // the response grows towards the steady state (far from it after 0.05 s, but it must be rising)
164+ const mid = tracker.indexAtTime(0.02);
165+ assert.ok(tracker.energies[end][MODE] > 2 * tracker.energies[mid][MODE], "the driven mode is not growing");
166+});
tsconfig.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "strict": true,
7+ "noUnusedLocals": true,
8+ "noUnusedParameters": true,
9+ "noFallthroughCasesInSwitch": true,
10+ "isolatedModules": true,
11+ "verbatimModuleSyntax": true,
12+ "skipLibCheck": true,
13+ "allowImportingTsExtensions": true,
14+ "noEmit": true,
15+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
16+ "types": ["@webgpu/types", "vite/client", "node"]
17+ },
18+ "include": ["src", "tests"]
19+}
vite.config.tsadded+3−0View file
@@ -0,0 +1,3 @@
1+import { defineConfig } from "vite";
2+
3+export default defineConfig({ base: "./", build: { target: "es2022" }, worker: { format: "es" } });
↑↓ move↵openescclose