cymplay: design and play a cymsim cymbal in the browser
A browser instrument that precomputes the linear modes of a cymsim shell,
plays it from the keyboard in real time, and exports the recorded hits as a
cymsim configuration for the full nonlinear render.
Everything runs in the browser. A Web Worker assembles the linearised shell
on the playback mesh, condenses the in-plane and rotation degrees of freedom
with a banded Cholesky factorisation after a reverse Cuthill-McKee ordering,
and takes the eigenmodes of the condensed transverse problem by Householder
tridiagonalisation and implicit QL. An AudioWorklet runs the resulting bank of
damped resonators at one step per audio sample, driven by rigid exciters on
power-law contact springs whose forces are solved implicitly against a small
precomputed compliance, which is stable for the stiff stick contact where an
explicit coupling would need sub-stepping.
The modes file stores the shape alone: frequencies at a reference material,
the quadratic form of the sigma1 loss rather than a decay rate, and gains that
can be recomputed. Damping, Young's modulus, density and the listener
therefore act while a slider moves, reproducing a full recompute to 1e-8;
geometry and hammering change the eigenproblem and need the modes computed
again, which the panel says.
Checked against the Python package on the same mesh: mode frequencies agree to
1e-5 in the low modes and the synthesis agrees with an independent numpy
implementation to 1e-6 in rms.
40 changed files+5667−0
.github/workflows/deploy.ymladded+33−0View file
@@ -0,0 +1,33 @@
1+name: deploy
2+on:
3+ push:
4+ branches: [main]
5+ workflow_dispatch:
6+
7+permissions:
8+ contents: read
9+ pages: write
10+ id-token: write
11+
12+concurrency:
13+ group: pages
14+ cancel-in-progress: false
15+
16+jobs:
17+ build-deploy:
18+ runs-on: ubuntu-latest
19+ steps:
20+ - uses: actions/checkout@v4
21+ - uses: actions/setup-node@v4
22+ with:
23+ node-version: 24
24+ - run: npm ci
25+ # Precompute, modes file round trip, live material relations, the worklet, and the export.
26+ - run: npm test
27+ - run: npm run build
28+ - uses: actions/configure-pages@v5
29+ - uses: actions/upload-pages-artifact@v3
30+ with:
31+ path: dist
32+ - id: deployment
33+ uses: actions/deploy-pages@v4
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+node_modules/
2+dist/
3+precompute/__pycache__/
4+*.wav
README.mdadded+93−0View file
@@ -0,0 +1,93 @@
1+# cymplay
2+
3+A browser instrument for designing a [cymsim](../cym2) cymbal, playing it in real time, and recording the hits for a full render. Everything runs in the browser: the precomputation of the linear modes (a Web Worker), the synthesis (an AudioWorklet), the keyboard and mouse interface, and the export of the recorded hits as a cymsim YAML configuration.
4+
5+The real-time sound is an approximation: the *linear* response of the same shell model that cymsim solves, with the same profile, thickness taper, seeded imperfections, finite elements, contact law, exciters and listener model. What it lacks is the geometric nonlinearity, that is, the upward cascade that turns a stroke into a crash. The recorded hits, rendered with `cymsim simulate`, restore it.
6+
7+## Running
8+
9+ npm install
10+ npm run dev # development server
11+ npm run build # static site in dist/
12+
13+Press **Compute modes** (cached in the browser for that shape), then **Start audio**, then play.
14+
15+## Playing
16+
17+Three rows of keys strike three concentric arcs across the near half of the cymbal, drawn with their letters. The layout matches the keyboard: the bottom row is the edge closest to you, the top row the inner bow, and a row reads from the left of the cymbal, round the near side, to the right. Pressing Q, A and Z in turn walks outwards along one ray.
18+
19+| keys | where |
20+|---|---|
21+| Q W E R T Y U I O P | inner bow, 0.42 R |
22+| A S D F G H J K L ; | mid bow, 0.64 R |
23+| Z X C V B N M , . / | near the rim, 0.86 R |
24+| space | the bell |
25+
26+One exciter, chosen under *exciter*, applies to every key and to mouse clicks, so position is the only thing the keys change.
27+
28+**Dynamics.** Each position carries its own strike velocity, printed under its letter, and the marker grows and shrinks with it. Scroll the wheel over a position to make it harder or softer, in steps of about 2 dB between a quarter and four times the master level; **Level the positions** puts them all back. The weights are remembered between sessions, so a kit of accents and ghost notes stays where you put it.
29+
30+The master level scales every position together: digits 1 to 9 set it and `[` and `]` nudge it by 0.5 m/s. There are no modifier keys to hold; a key always strikes its position at the velocity shown. Clicking anywhere on the cymbal strikes there at the master level.
31+
32+Be aware that in linear theory velocity is *exactly* a change of gain and nothing else: the measured pressure is proportional to velocity to the last digit. Real cymbals get brighter when struck harder, and the rendered file will show that, but the preview will not.
33+
34+**Stick and mallet are not interchangeable here.** Measured on the default 18 inch crash, at the same position and velocity, the soft mallet is 17 to 23 dB *quieter* than the stick, although it delivers ten times the energy; at matched energy the gap is about 50 dB. A 4 ms mallet contact injects almost nothing above 120 Hz, and an unbaffled cymbal barely radiates there because the front and back faces cancel. In the full model that low-frequency energy climbs into the audible band, which is the crash; linear theory cannot preview it. The *lift the mallet preview level* box therefore raises the mallet by 18 dB so that it is audible. It affects the output gain only, never the recorded hits and never the captured WAV.
35+
36+## Designing the cymbal
37+
38+The design panel exposes every parameter of the shell, in two tiers.
39+
40+**Live** parameters take effect while the slider moves, because they enter the modal bank analytically rather than through the eigenproblem:
41+
42+- the two loss coefficients, which set the sustain. `sigma0` gives every mode the same decay, so the 60 dB time of the low modes is about `6.9 / sigma0`; `sigma1` adds a decay proportional to the squared wavenumber, so raising it darkens the tail by killing the high modes first. The panel reports the resulting T60 at the bottom and top of the band.
43+- Young's modulus and the density, which scale every frequency by `sqrt((E/E0)/(rho/rho0))` exactly, leaving the mode shapes alone.
44+- the listener position, the back-side cancellation, the speed of sound and the air density, which enter only through the per-mode gains. Those are recomputed in the worker a quarter of a second after the slider stops.
45+
46+Checked against a full recompute at a different material and damping, the live path reproduces the frequencies to 7e-12 and the decay rates to 2e-9 relative. One caveat: a softer or denser material lowers every frequency, so the top of the band empties out and the panel says so; a recompute fills it again.
47+
48+**Structural** parameters change the eigenproblem itself, so the panel marks the modes stale and you press Compute modes: the radius, the three thicknesses, the bow rise and shape, the bell geometry, Poisson's ratio, and the hammering and asymmetry (seed, number and size of hammer marks, rim warp, shoulder ovality, bell offset). Keep the mark width at or above the playback element size or the mesh cannot see the marks.
49+
50+**Download design YAML** saves the cymbal alone; **Download YAML** under *Recorded hits* saves it together with the hits.
51+
52+## Exporting
53+
54+Every hit is logged with its time (from the audio clock), position, velocity and exciter, and, once the exciter has rebounded, its peak force and contact time. **Download YAML** writes the designed configuration with those hits, times shifted so the first falls at the lead-in and the duration set to the last hit plus the tail. Render it with
55+
56+ cymsim simulate cymplay.yaml
57+
58+**Start capture** and **Stop capture** record the real-time output and **Download WAV** saves it, for comparison with the full render.
59+
60+## The precompute
61+
62+The linearised shell has five degrees of freedom per node (two in-plane displacements, the transverse displacement and two rotations). The in-plane and rotation DOFs are eliminated by static condensation, with a banded Cholesky factorisation after a reverse Cuthill-McKee ordering and three in-plane DOFs pinned against rigid motion, leaving a dense stiffness on the transverse DOFs. Its mass-normalised eigenmodes, by Householder tridiagonalisation and implicit QL in TypeScript, give per mode the frequency, the quadratic form of the `sigma1` loss, the complex listener gains (the Rayleigh front sum and the diffracted back sum, each element's delay folded into a phase at the mode frequency) and the mode shape on the nodes, quantised to int16.
63+
64+The eigensolve is dense and costs about the cube of the node count, so the playback mesh is coarser than the render mesh: 10 mm by default (1736 nodes, about 1500 modes below 20 kHz) takes about 20 s; 8 mm about 1.5 min; 6 mm, the render resolution, about 15 min.
65+
66+### How far to trust it
67+
68+Against the Python package on the same mesh and geometry, the mode frequencies agree to 1e-5 relative in the lowest hundred modes and to about 1e-3 at 10 kHz. The residual comes from a tie-break in the ring stitching of the mesher, not from the elements: the DKT element stiffness matches the Python one to 1e-16. Comparing the two as instruments, through the point-force-to-listener transfer function, which does not care how degenerate pairs are split between the two eigenbases, the mean difference over 80 Hz to 15 kHz is about 8 %, concentrated above 9 kHz.
69+
70+Against a linear cymsim FEM run on the same 10 mm mesh, the ringing spectrum agrees within 0.6 dB up to 3 kHz. Above that the modal engine is quieter, because the FEM run still carries content the modal band does not. The stick contact on a quiet cymbal matches in contact time (0.82 ms against 0.82 ms) and rebound (0.65 against 0.75 m/s), with the peak force about 30 % low at 44.1 kHz; sub-sampling the contact closes that gap and costs more than it is worth for a preview.
71+
72+Two deliberate differences from the Python package: the condensed model has no in-plane and no rotational inertia, where the Python package inflates the latter as a time-step device that slows its high modes by a few percent; and the imperfections are drawn from a different generator (xoshiro128** rather than numpy's PCG64), so a seed gives a different dent pattern with the same statistics. A configuration exported from here and rendered in Python is the same cymbal in every respect but the particular dents.
73+
74+**Download modes** saves the precomputed file (a JSON header plus raw arrays) and **load modes** restores it. `tools/make_modes_reference.py` produces the same file from Python, and `tools/modal_reference.py` is an independent numpy implementation of the synthesis; the worklet agrees with it to 1e-6 relative in rms.
75+
76+## The synthesis
77+
78+Each mode is a damped resonator with a complex state, advanced with its exact pole once per audio sample. An exciter is a rigid mass on power-law springs at its patch points, a line for a stick and a centre plus hexagon for a mallet, released just above the moving surface at its hit time. The contact force of a sample is found implicitly: the surface under the patch responds to that sample's forces through a small precomputed compliance matrix, so the end-of-sample penetration is affine in the forces and the contact law is solved by a few Newton iterations. That is unconditionally stable for the stiff stick contact, which an explicit coupling could only survive by sub-stepping. The number of springs used in real time is selectable; the exported configuration always keeps the full patches.
79+
80+The listener pressure is the sum over modes of the gain times the modal acceleration, plus the direct term of the instantaneous force. Between hits the bank costs about 13 % of a core for 1500 modes at 44.1 kHz, and a contact block two to four times that. The first hit after the audio starts can still overrun its block once while the JavaScript engine optimises the contact path, despite a warm-up pass at start-up.
81+
82+## Layout
83+
84+| path | contents |
85+|---|---|
86+| `src/main.ts` | the page: modes, design panel, audio, keyboard, hit log, export |
87+| `src/design.ts` | the parameter schema and the panel that edits it |
88+| `src/derived.ts` | the live material and damping relations |
89+| `src/worklet.ts` | the AudioWorklet processor (modal bank, exciters, implicit contact) |
90+| `src/precompute/` | assembly and condensation, banded Cholesky, eigensolver, listener gains, worker |
91+| `src/model/`, `src/config/` | mesh, profile, DKT element, shell, schema and YAML (shared with cymsimweb) |
92+| `src/modes.ts`, `src/patch.ts` | the modes file format; contact patches on it |
93+| `tools/`, `tests/` | Python cross-checks and node harnesses |
index.htmladded+114−0View file
@@ -0,0 +1,114 @@
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>cymplay</title>
7+ <link rel="stylesheet" href="./src/style.css" />
8+ </head>
9+ <body>
10+ <header>
11+ <h1>cymplay</h1>
12+ <span class="sub">design a cymsim cymbal, play it from the keyboard, export the hits for the full render</span>
13+ </header>
14+ <main>
15+ <section class="left">
16+ <canvas id="cymbal"></canvas>
17+ <div class="keys" id="keyhelp"></div>
18+ </section>
19+ <section class="right">
20+ <details id="setup" open>
21+ <summary>Modes</summary>
22+ <div class="row">
23+ <label>start from <select id="preset"></select></label>
24+ <label class="file">load YAML <input type="file" id="yamlfile" accept=".yaml,.yml" /></label>
25+ </div>
26+ <div class="row">
27+ <label>playback mesh <select id="esize">
28+ <option value="0.015">15 mm (fast, rough)</option>
29+ <option value="0.012">12 mm</option>
30+ <option value="0.010" selected>10 mm (default)</option>
31+ <option value="0.008">8 mm (slow)</option>
32+ <option value="0.006">6 mm (very slow)</option>
33+ </select></label>
34+ <label>modes up to <input type="number" id="fmax" value="20000" step="1000" min="500" max="24000" /> Hz</label>
35+ </div>
36+ <div class="row">
37+ <button id="compute">Compute modes</button>
38+ <button id="download-modes" disabled>Download modes</button>
39+ <label class="file">load modes <input type="file" id="modesfile" accept=".bin" /></label>
40+ <button id="clear-cache" title="forget every cached modes file">Clear cache</button>
41+ </div>
42+ <div class="progress"><div id="progress-bar"></div></div>
43+ <div id="status" class="status">no modes yet</div>
44+ </details>
45+ <details id="designbox" open>
46+ <summary>Design the cymbal</summary>
47+ <div class="row small">
48+ Sliders marked live take effect at once. The others change the shape, so the modes must be
49+ recomputed; the panel says when they are stale.
50+ </div>
51+ <div id="design"></div>
52+ <div class="row">
53+ <button id="revert">Revert to the preset</button>
54+ <button id="download-design-yaml">Download design YAML</button>
55+ </div>
56+ <div id="design-status" class="status"></div>
57+ </details>
58+ <details id="play" open>
59+ <summary>Play</summary>
60+ <div class="row">
61+ <button id="audio-start">Start audio</button>
62+ <label>volume <input type="range" id="volume" min="0" max="1" step="0.01" value="0.4" /></label>
63+ <label>output <select id="signal">
64+ <option value="unbaffled">unbaffled (front minus back)</option>
65+ <option value="front">baffled (front only)</option>
66+ </select></label>
67+ </div>
68+ <div class="row">
69+ <label>exciter <select id="exciter">
70+ <option value="stick">stick (hard, bright)</option>
71+ <option value="mallet">mallet (soft, nearly silent without the cascade)</option>
72+ </select></label>
73+ <label>contact springs <select id="points" title="springs per patch in real time; the exported YAML always keeps the full patches">
74+ <option value="cheap">fewest (mallet 1, stick 3)</option>
75+ <option value="mixed" selected>mallet 1, stick 7</option>
76+ <option value="full">as in cymsim (7 and 7)</option>
77+ </select></label>
78+ <label title="the mallet is about 18 dB quieter than the stick in linear theory; this makes it audible and is never written into the exported YAML">
79+ <input type="checkbox" id="preview-gain" checked /> lift the mallet preview level
80+ </label>
81+ </div>
82+ <div class="row">
83+ <label>master level <input type="range" id="velocity" min="0.3" max="16" step="0.1" value="6" /> <span id="velocity-value">6.0 m/s</span></label>
84+ <button id="reset-weights" title="put every position back to the master level">Level the positions</button>
85+ </div>
86+ <div class="row small">
87+ Each position is struck at the velocity printed under its letter. Scroll the wheel over a
88+ position to make it harder or softer; the master level scales them all together.
89+ </div>
90+ <div class="row small" id="audio-info">audio not started</div>
91+ </details>
92+ <details id="record" open>
93+ <summary>Recorded hits</summary>
94+ <div class="row">
95+ <button id="clear-hits">Clear</button>
96+ <button id="download-yaml" disabled>Download YAML</button>
97+ <button id="capture">Start capture</button>
98+ <button id="download-wav" disabled>Download WAV</button>
99+ </div>
100+ <div class="row">
101+ <label>lead-in <input type="number" id="leadin" value="0.05" step="0.05" min="0" /> s</label>
102+ <label>tail <input type="number" id="tail" value="3" step="0.5" min="0.1" /> s</label>
103+ <label>prefix <input type="text" id="prefix" value="cymplay" size="10" /></label>
104+ </div>
105+ <table id="hits">
106+ <thead><tr><th>#</th><th>time [s]</th><th>key</th><th>exciter</th><th>r [m]</th><th>phi [rad]</th><th>v [m/s]</th><th>F peak [N]</th><th>contact [ms]</th></tr></thead>
107+ <tbody></tbody>
108+ </table>
109+ </details>
110+ </section>
111+ </main>
112+ <script type="module" src="./src/main.ts"></script>
113+ </body>
114+</html>
package-lock.jsonadded+1236−0View file
@@ -0,0 +1,1236 @@
1+{
2+ "name": "cymplay",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "cymplay",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "js-yaml": "^5.4.2"
12+ },
13+ "devDependencies": {
14+ "@types/js-yaml": "^4.0.9",
15+ "@types/node": "^26.6.2",
16+ "typescript": "^5.6.0",
17+ "vite": "^6.0.0"
18+ }
19+ },
20+ "node_modules/@esbuild/aix-ppc64": {
21+ "version": "0.25.12",
22+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
23+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
24+ "cpu": [
25+ "ppc64"
26+ ],
27+ "dev": true,
28+ "license": "MIT",
29+ "optional": true,
30+ "os": [
31+ "aix"
32+ ],
33+ "engines": {
34+ "node": ">=18"
35+ }
36+ },
37+ "node_modules/@esbuild/android-arm": {
38+ "version": "0.25.12",
39+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
40+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
41+ "cpu": [
42+ "arm"
43+ ],
44+ "dev": true,
45+ "license": "MIT",
46+ "optional": true,
47+ "os": [
48+ "android"
49+ ],
50+ "engines": {
51+ "node": ">=18"
52+ }
53+ },
54+ "node_modules/@esbuild/android-arm64": {
55+ "version": "0.25.12",
56+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
57+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
58+ "cpu": [
59+ "arm64"
60+ ],
61+ "dev": true,
62+ "license": "MIT",
63+ "optional": true,
64+ "os": [
65+ "android"
66+ ],
67+ "engines": {
68+ "node": ">=18"
69+ }
70+ },
71+ "node_modules/@esbuild/android-x64": {
72+ "version": "0.25.12",
73+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
74+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
75+ "cpu": [
76+ "x64"
77+ ],
78+ "dev": true,
79+ "license": "MIT",
80+ "optional": true,
81+ "os": [
82+ "android"
83+ ],
84+ "engines": {
85+ "node": ">=18"
86+ }
87+ },
88+ "node_modules/@esbuild/darwin-arm64": {
89+ "version": "0.25.12",
90+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
91+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
92+ "cpu": [
93+ "arm64"
94+ ],
95+ "dev": true,
96+ "license": "MIT",
97+ "optional": true,
98+ "os": [
99+ "darwin"
100+ ],
101+ "engines": {
102+ "node": ">=18"
103+ }
104+ },
105+ "node_modules/@esbuild/darwin-x64": {
106+ "version": "0.25.12",
107+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
108+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
109+ "cpu": [
110+ "x64"
111+ ],
112+ "dev": true,
113+ "license": "MIT",
114+ "optional": true,
115+ "os": [
116+ "darwin"
117+ ],
118+ "engines": {
119+ "node": ">=18"
120+ }
121+ },
122+ "node_modules/@esbuild/freebsd-arm64": {
123+ "version": "0.25.12",
124+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
125+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
126+ "cpu": [
127+ "arm64"
128+ ],
129+ "dev": true,
130+ "license": "MIT",
131+ "optional": true,
132+ "os": [
133+ "freebsd"
134+ ],
135+ "engines": {
136+ "node": ">=18"
137+ }
138+ },
139+ "node_modules/@esbuild/freebsd-x64": {
140+ "version": "0.25.12",
141+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
142+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
143+ "cpu": [
144+ "x64"
145+ ],
146+ "dev": true,
147+ "license": "MIT",
148+ "optional": true,
149+ "os": [
150+ "freebsd"
151+ ],
152+ "engines": {
153+ "node": ">=18"
154+ }
155+ },
156+ "node_modules/@esbuild/linux-arm": {
157+ "version": "0.25.12",
158+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
159+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
160+ "cpu": [
161+ "arm"
162+ ],
163+ "dev": true,
164+ "license": "MIT",
165+ "optional": true,
166+ "os": [
167+ "linux"
168+ ],
169+ "engines": {
170+ "node": ">=18"
171+ }
172+ },
173+ "node_modules/@esbuild/linux-arm64": {
174+ "version": "0.25.12",
175+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
176+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
177+ "cpu": [
178+ "arm64"
179+ ],
180+ "dev": true,
181+ "license": "MIT",
182+ "optional": true,
183+ "os": [
184+ "linux"
185+ ],
186+ "engines": {
187+ "node": ">=18"
188+ }
189+ },
190+ "node_modules/@esbuild/linux-ia32": {
191+ "version": "0.25.12",
192+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
193+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
194+ "cpu": [
195+ "ia32"
196+ ],
197+ "dev": true,
198+ "license": "MIT",
199+ "optional": true,
200+ "os": [
201+ "linux"
202+ ],
203+ "engines": {
204+ "node": ">=18"
205+ }
206+ },
207+ "node_modules/@esbuild/linux-loong64": {
208+ "version": "0.25.12",
209+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
210+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
211+ "cpu": [
212+ "loong64"
213+ ],
214+ "dev": true,
215+ "license": "MIT",
216+ "optional": true,
217+ "os": [
218+ "linux"
219+ ],
220+ "engines": {
221+ "node": ">=18"
222+ }
223+ },
224+ "node_modules/@esbuild/linux-mips64el": {
225+ "version": "0.25.12",
226+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
227+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
228+ "cpu": [
229+ "mips64el"
230+ ],
231+ "dev": true,
232+ "license": "MIT",
233+ "optional": true,
234+ "os": [
235+ "linux"
236+ ],
237+ "engines": {
238+ "node": ">=18"
239+ }
240+ },
241+ "node_modules/@esbuild/linux-ppc64": {
242+ "version": "0.25.12",
243+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
244+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
245+ "cpu": [
246+ "ppc64"
247+ ],
248+ "dev": true,
249+ "license": "MIT",
250+ "optional": true,
251+ "os": [
252+ "linux"
253+ ],
254+ "engines": {
255+ "node": ">=18"
256+ }
257+ },
258+ "node_modules/@esbuild/linux-riscv64": {
259+ "version": "0.25.12",
260+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
261+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
262+ "cpu": [
263+ "riscv64"
264+ ],
265+ "dev": true,
266+ "license": "MIT",
267+ "optional": true,
268+ "os": [
269+ "linux"
270+ ],
271+ "engines": {
272+ "node": ">=18"
273+ }
274+ },
275+ "node_modules/@esbuild/linux-s390x": {
276+ "version": "0.25.12",
277+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
278+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
279+ "cpu": [
280+ "s390x"
281+ ],
282+ "dev": true,
283+ "license": "MIT",
284+ "optional": true,
285+ "os": [
286+ "linux"
287+ ],
288+ "engines": {
289+ "node": ">=18"
290+ }
291+ },
292+ "node_modules/@esbuild/linux-x64": {
293+ "version": "0.25.12",
294+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
295+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
296+ "cpu": [
297+ "x64"
298+ ],
299+ "dev": true,
300+ "license": "MIT",
301+ "optional": true,
302+ "os": [
303+ "linux"
304+ ],
305+ "engines": {
306+ "node": ">=18"
307+ }
308+ },
309+ "node_modules/@esbuild/netbsd-arm64": {
310+ "version": "0.25.12",
311+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
312+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
313+ "cpu": [
314+ "arm64"
315+ ],
316+ "dev": true,
317+ "license": "MIT",
318+ "optional": true,
319+ "os": [
320+ "netbsd"
321+ ],
322+ "engines": {
323+ "node": ">=18"
324+ }
325+ },
326+ "node_modules/@esbuild/netbsd-x64": {
327+ "version": "0.25.12",
328+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
329+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
330+ "cpu": [
331+ "x64"
332+ ],
333+ "dev": true,
334+ "license": "MIT",
335+ "optional": true,
336+ "os": [
337+ "netbsd"
338+ ],
339+ "engines": {
340+ "node": ">=18"
341+ }
342+ },
343+ "node_modules/@esbuild/openbsd-arm64": {
344+ "version": "0.25.12",
345+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
346+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
347+ "cpu": [
348+ "arm64"
349+ ],
350+ "dev": true,
351+ "license": "MIT",
352+ "optional": true,
353+ "os": [
354+ "openbsd"
355+ ],
356+ "engines": {
357+ "node": ">=18"
358+ }
359+ },
360+ "node_modules/@esbuild/openbsd-x64": {
361+ "version": "0.25.12",
362+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
363+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
364+ "cpu": [
365+ "x64"
366+ ],
367+ "dev": true,
368+ "license": "MIT",
369+ "optional": true,
370+ "os": [
371+ "openbsd"
372+ ],
373+ "engines": {
374+ "node": ">=18"
375+ }
376+ },
377+ "node_modules/@esbuild/openharmony-arm64": {
378+ "version": "0.25.12",
379+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
380+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
381+ "cpu": [
382+ "arm64"
383+ ],
384+ "dev": true,
385+ "license": "MIT",
386+ "optional": true,
387+ "os": [
388+ "openharmony"
389+ ],
390+ "engines": {
391+ "node": ">=18"
392+ }
393+ },
394+ "node_modules/@esbuild/sunos-x64": {
395+ "version": "0.25.12",
396+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
397+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
398+ "cpu": [
399+ "x64"
400+ ],
401+ "dev": true,
402+ "license": "MIT",
403+ "optional": true,
404+ "os": [
405+ "sunos"
406+ ],
407+ "engines": {
408+ "node": ">=18"
409+ }
410+ },
411+ "node_modules/@esbuild/win32-arm64": {
412+ "version": "0.25.12",
413+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
414+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
415+ "cpu": [
416+ "arm64"
417+ ],
418+ "dev": true,
419+ "license": "MIT",
420+ "optional": true,
421+ "os": [
422+ "win32"
423+ ],
424+ "engines": {
425+ "node": ">=18"
426+ }
427+ },
428+ "node_modules/@esbuild/win32-ia32": {
429+ "version": "0.25.12",
430+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
431+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
432+ "cpu": [
433+ "ia32"
434+ ],
435+ "dev": true,
436+ "license": "MIT",
437+ "optional": true,
438+ "os": [
439+ "win32"
440+ ],
441+ "engines": {
442+ "node": ">=18"
443+ }
444+ },
445+ "node_modules/@esbuild/win32-x64": {
446+ "version": "0.25.12",
447+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
448+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
449+ "cpu": [
450+ "x64"
451+ ],
452+ "dev": true,
453+ "license": "MIT",
454+ "optional": true,
455+ "os": [
456+ "win32"
457+ ],
458+ "engines": {
459+ "node": ">=18"
460+ }
461+ },
462+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
463+ "version": "1.5.1",
464+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
465+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
466+ "cpu": [
467+ "x64"
468+ ],
469+ "dev": true,
470+ "libc": [
471+ "glibc"
472+ ],
473+ "license": "MIT",
474+ "optional": true,
475+ "os": [
476+ "linux"
477+ ],
478+ "engines": {
479+ "node": "^22.20 || ^24.12 || >=25"
480+ }
481+ },
482+ "node_modules/@rollup/rollup-android-arm-eabi": {
483+ "version": "4.63.4",
484+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
485+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
486+ "cpu": [
487+ "arm"
488+ ],
489+ "dev": true,
490+ "license": "MIT",
491+ "optional": true,
492+ "os": [
493+ "android"
494+ ]
495+ },
496+ "node_modules/@rollup/rollup-android-arm64": {
497+ "version": "4.63.4",
498+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
499+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
500+ "cpu": [
501+ "arm64"
502+ ],
503+ "dev": true,
504+ "license": "MIT",
505+ "optional": true,
506+ "os": [
507+ "android"
508+ ]
509+ },
510+ "node_modules/@rollup/rollup-darwin-arm64": {
511+ "version": "4.63.4",
512+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
513+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
514+ "cpu": [
515+ "arm64"
516+ ],
517+ "dev": true,
518+ "license": "MIT",
519+ "optional": true,
520+ "os": [
521+ "darwin"
522+ ]
523+ },
524+ "node_modules/@rollup/rollup-darwin-x64": {
525+ "version": "4.63.4",
526+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
527+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
528+ "cpu": [
529+ "x64"
530+ ],
531+ "dev": true,
532+ "license": "MIT",
533+ "optional": true,
534+ "os": [
535+ "darwin"
536+ ]
537+ },
538+ "node_modules/@rollup/rollup-freebsd-arm64": {
539+ "version": "4.63.4",
540+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
541+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
542+ "cpu": [
543+ "arm64"
544+ ],
545+ "dev": true,
546+ "license": "MIT",
547+ "optional": true,
548+ "os": [
549+ "freebsd"
550+ ]
551+ },
552+ "node_modules/@rollup/rollup-freebsd-x64": {
553+ "version": "4.63.4",
554+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
555+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
556+ "cpu": [
557+ "x64"
558+ ],
559+ "dev": true,
560+ "license": "MIT",
561+ "optional": true,
562+ "os": [
563+ "freebsd"
564+ ]
565+ },
566+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
567+ "version": "4.63.4",
568+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
569+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
570+ "cpu": [
571+ "arm"
572+ ],
573+ "dev": true,
574+ "libc": [
575+ "glibc"
576+ ],
577+ "license": "MIT",
578+ "optional": true,
579+ "os": [
580+ "linux"
581+ ]
582+ },
583+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
584+ "version": "4.63.4",
585+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
586+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
587+ "cpu": [
588+ "arm"
589+ ],
590+ "dev": true,
591+ "libc": [
592+ "musl"
593+ ],
594+ "license": "MIT",
595+ "optional": true,
596+ "os": [
597+ "linux"
598+ ]
599+ },
600+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
601+ "version": "4.63.4",
602+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
603+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
604+ "cpu": [
605+ "arm64"
606+ ],
607+ "dev": true,
608+ "libc": [
609+ "glibc"
610+ ],
611+ "license": "MIT",
612+ "optional": true,
613+ "os": [
614+ "linux"
615+ ]
616+ },
617+ "node_modules/@rollup/rollup-linux-arm64-musl": {
618+ "version": "4.63.4",
619+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
620+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
621+ "cpu": [
622+ "arm64"
623+ ],
624+ "dev": true,
625+ "libc": [
626+ "musl"
627+ ],
628+ "license": "MIT",
629+ "optional": true,
630+ "os": [
631+ "linux"
632+ ]
633+ },
634+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
635+ "version": "4.63.4",
636+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
637+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
638+ "cpu": [
639+ "loong64"
640+ ],
641+ "dev": true,
642+ "libc": [
643+ "glibc"
644+ ],
645+ "license": "MIT",
646+ "optional": true,
647+ "os": [
648+ "linux"
649+ ]
650+ },
651+ "node_modules/@rollup/rollup-linux-loong64-musl": {
652+ "version": "4.63.4",
653+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
654+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
655+ "cpu": [
656+ "loong64"
657+ ],
658+ "dev": true,
659+ "libc": [
660+ "musl"
661+ ],
662+ "license": "MIT",
663+ "optional": true,
664+ "os": [
665+ "linux"
666+ ]
667+ },
668+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
669+ "version": "4.63.4",
670+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
671+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
672+ "cpu": [
673+ "ppc64"
674+ ],
675+ "dev": true,
676+ "libc": [
677+ "glibc"
678+ ],
679+ "license": "MIT",
680+ "optional": true,
681+ "os": [
682+ "linux"
683+ ]
684+ },
685+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
686+ "version": "4.63.4",
687+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
688+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
689+ "cpu": [
690+ "ppc64"
691+ ],
692+ "dev": true,
693+ "libc": [
694+ "musl"
695+ ],
696+ "license": "MIT",
697+ "optional": true,
698+ "os": [
699+ "linux"
700+ ]
701+ },
702+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
703+ "version": "4.63.4",
704+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
705+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
706+ "cpu": [
707+ "riscv64"
708+ ],
709+ "dev": true,
710+ "libc": [
711+ "glibc"
712+ ],
713+ "license": "MIT",
714+ "optional": true,
715+ "os": [
716+ "linux"
717+ ]
718+ },
719+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
720+ "version": "4.63.4",
721+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
722+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
723+ "cpu": [
724+ "riscv64"
725+ ],
726+ "dev": true,
727+ "libc": [
728+ "musl"
729+ ],
730+ "license": "MIT",
731+ "optional": true,
732+ "os": [
733+ "linux"
734+ ]
735+ },
736+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
737+ "version": "4.63.4",
738+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
739+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
740+ "cpu": [
741+ "s390x"
742+ ],
743+ "dev": true,
744+ "libc": [
745+ "glibc"
746+ ],
747+ "license": "MIT",
748+ "optional": true,
749+ "os": [
750+ "linux"
751+ ]
752+ },
753+ "node_modules/@rollup/rollup-linux-x64-gnu": {
754+ "version": "4.63.4",
755+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
756+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
757+ "cpu": [
758+ "x64"
759+ ],
760+ "dev": true,
761+ "libc": [
762+ "glibc"
763+ ],
764+ "license": "MIT",
765+ "optional": true,
766+ "os": [
767+ "linux"
768+ ]
769+ },
770+ "node_modules/@rollup/rollup-linux-x64-musl": {
771+ "version": "4.63.4",
772+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
773+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
774+ "cpu": [
775+ "x64"
776+ ],
777+ "dev": true,
778+ "libc": [
779+ "musl"
780+ ],
781+ "license": "MIT",
782+ "optional": true,
783+ "os": [
784+ "linux"
785+ ]
786+ },
787+ "node_modules/@rollup/rollup-openbsd-x64": {
788+ "version": "4.63.4",
789+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
790+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
791+ "cpu": [
792+ "x64"
793+ ],
794+ "dev": true,
795+ "license": "MIT",
796+ "optional": true,
797+ "os": [
798+ "openbsd"
799+ ]
800+ },
801+ "node_modules/@rollup/rollup-openharmony-arm64": {
802+ "version": "4.63.4",
803+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
804+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
805+ "cpu": [
806+ "arm64"
807+ ],
808+ "dev": true,
809+ "license": "MIT",
810+ "optional": true,
811+ "os": [
812+ "openharmony"
813+ ]
814+ },
815+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
816+ "version": "4.63.4",
817+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
818+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
819+ "cpu": [
820+ "arm64"
821+ ],
822+ "dev": true,
823+ "license": "MIT",
824+ "optional": true,
825+ "os": [
826+ "win32"
827+ ]
828+ },
829+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
830+ "version": "4.63.4",
831+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
832+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
833+ "cpu": [
834+ "ia32"
835+ ],
836+ "dev": true,
837+ "license": "MIT",
838+ "optional": true,
839+ "os": [
840+ "win32"
841+ ]
842+ },
843+ "node_modules/@rollup/rollup-win32-x64-gnu": {
844+ "version": "4.63.4",
845+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
846+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
847+ "cpu": [
848+ "x64"
849+ ],
850+ "dev": true,
851+ "license": "MIT",
852+ "optional": true,
853+ "os": [
854+ "win32"
855+ ]
856+ },
857+ "node_modules/@rollup/rollup-win32-x64-msvc": {
858+ "version": "4.63.4",
859+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
860+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
861+ "cpu": [
862+ "x64"
863+ ],
864+ "dev": true,
865+ "license": "MIT",
866+ "optional": true,
867+ "os": [
868+ "win32"
869+ ]
870+ },
871+ "node_modules/@types/estree": {
872+ "version": "1.0.9",
873+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
874+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
875+ "dev": true,
876+ "license": "MIT"
877+ },
878+ "node_modules/@types/js-yaml": {
879+ "version": "4.0.9",
880+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
881+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
882+ "dev": true,
883+ "license": "MIT"
884+ },
885+ "node_modules/@types/node": {
886+ "version": "26.6.2",
887+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
888+ "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
889+ "dev": true,
890+ "license": "MIT",
891+ "dependencies": {
892+ "undici-types": "~8.9.0"
893+ }
894+ },
895+ "node_modules/argparse": {
896+ "version": "2.0.1",
897+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
898+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
899+ "license": "Python-2.0"
900+ },
901+ "node_modules/esbuild": {
902+ "version": "0.25.12",
903+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
904+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
905+ "dev": true,
906+ "hasInstallScript": true,
907+ "license": "MIT",
908+ "bin": {
909+ "esbuild": "bin/esbuild"
910+ },
911+ "engines": {
912+ "node": ">=18"
913+ },
914+ "optionalDependencies": {
915+ "@esbuild/aix-ppc64": "0.25.12",
916+ "@esbuild/android-arm": "0.25.12",
917+ "@esbuild/android-arm64": "0.25.12",
918+ "@esbuild/android-x64": "0.25.12",
919+ "@esbuild/darwin-arm64": "0.25.12",
920+ "@esbuild/darwin-x64": "0.25.12",
921+ "@esbuild/freebsd-arm64": "0.25.12",
922+ "@esbuild/freebsd-x64": "0.25.12",
923+ "@esbuild/linux-arm": "0.25.12",
924+ "@esbuild/linux-arm64": "0.25.12",
925+ "@esbuild/linux-ia32": "0.25.12",
926+ "@esbuild/linux-loong64": "0.25.12",
927+ "@esbuild/linux-mips64el": "0.25.12",
928+ "@esbuild/linux-ppc64": "0.25.12",
929+ "@esbuild/linux-riscv64": "0.25.12",
930+ "@esbuild/linux-s390x": "0.25.12",
931+ "@esbuild/linux-x64": "0.25.12",
932+ "@esbuild/netbsd-arm64": "0.25.12",
933+ "@esbuild/netbsd-x64": "0.25.12",
934+ "@esbuild/openbsd-arm64": "0.25.12",
935+ "@esbuild/openbsd-x64": "0.25.12",
936+ "@esbuild/openharmony-arm64": "0.25.12",
937+ "@esbuild/sunos-x64": "0.25.12",
938+ "@esbuild/win32-arm64": "0.25.12",
939+ "@esbuild/win32-ia32": "0.25.12",
940+ "@esbuild/win32-x64": "0.25.12"
941+ }
942+ },
943+ "node_modules/fdir": {
944+ "version": "6.5.0",
945+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
946+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
947+ "dev": true,
948+ "license": "MIT",
949+ "engines": {
950+ "node": ">=12.0.0"
951+ },
952+ "peerDependencies": {
953+ "picomatch": "^3 || ^4"
954+ },
955+ "peerDependenciesMeta": {
956+ "picomatch": {
957+ "optional": true
958+ }
959+ }
960+ },
961+ "node_modules/fsevents": {
962+ "version": "2.3.3",
963+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
964+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
965+ "dev": true,
966+ "hasInstallScript": true,
967+ "license": "MIT",
968+ "optional": true,
969+ "os": [
970+ "darwin"
971+ ],
972+ "engines": {
973+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
974+ }
975+ },
976+ "node_modules/js-yaml": {
977+ "version": "5.4.2",
978+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz",
979+ "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==",
980+ "funding": [
981+ {
982+ "type": "github",
983+ "url": "https://github.com/sponsors/puzrin"
984+ },
985+ {
986+ "type": "github",
987+ "url": "https://github.com/sponsors/nodeca"
988+ }
989+ ],
990+ "license": "MIT",
991+ "dependencies": {
992+ "argparse": "^2.0.1"
993+ },
994+ "bin": {
995+ "js-yaml": "bin/js-yaml.mjs"
996+ }
997+ },
998+ "node_modules/nanoid": {
999+ "version": "3.3.19",
1000+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1001+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1002+ "dev": true,
1003+ "funding": [
1004+ {
1005+ "type": "github",
1006+ "url": "https://github.com/sponsors/ai"
1007+ }
1008+ ],
1009+ "license": "MIT",
1010+ "bin": {
1011+ "nanoid": "bin/nanoid.cjs"
1012+ },
1013+ "engines": {
1014+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1015+ }
1016+ },
1017+ "node_modules/picocolors": {
1018+ "version": "1.1.1",
1019+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1020+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1021+ "dev": true,
1022+ "license": "ISC"
1023+ },
1024+ "node_modules/picomatch": {
1025+ "version": "4.0.7",
1026+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1027+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1028+ "dev": true,
1029+ "license": "MIT",
1030+ "engines": {
1031+ "node": ">=12"
1032+ },
1033+ "funding": {
1034+ "url": "https://github.com/sponsors/jonschlinkert"
1035+ }
1036+ },
1037+ "node_modules/postcss": {
1038+ "version": "8.5.28",
1039+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1040+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1041+ "dev": true,
1042+ "funding": [
1043+ {
1044+ "type": "opencollective",
1045+ "url": "https://opencollective.com/postcss/"
1046+ },
1047+ {
1048+ "type": "tidelift",
1049+ "url": "https://tidelift.com/funding/github/npm/postcss"
1050+ },
1051+ {
1052+ "type": "github",
1053+ "url": "https://github.com/sponsors/ai"
1054+ }
1055+ ],
1056+ "license": "MIT",
1057+ "dependencies": {
1058+ "nanoid": "^3.3.18",
1059+ "picocolors": "^1.1.1",
1060+ "source-map-js": "^1.2.1"
1061+ },
1062+ "engines": {
1063+ "node": "^10 || ^12 || >=14"
1064+ }
1065+ },
1066+ "node_modules/rollup": {
1067+ "version": "4.63.4",
1068+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
1069+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
1070+ "dev": true,
1071+ "license": "MIT",
1072+ "dependencies": {
1073+ "@types/estree": "1.0.9"
1074+ },
1075+ "bin": {
1076+ "rollup": "dist/bin/rollup"
1077+ },
1078+ "engines": {
1079+ "node": ">=18.0.0",
1080+ "npm": ">=8.0.0"
1081+ },
1082+ "optionalDependencies": {
1083+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1084+ "@rollup/rollup-android-arm-eabi": "4.63.4",
1085+ "@rollup/rollup-android-arm64": "4.63.4",
1086+ "@rollup/rollup-darwin-arm64": "4.63.4",
1087+ "@rollup/rollup-darwin-x64": "4.63.4",
1088+ "@rollup/rollup-freebsd-arm64": "4.63.4",
1089+ "@rollup/rollup-freebsd-x64": "4.63.4",
1090+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
1091+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
1092+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
1093+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
1094+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
1095+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
1096+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
1097+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
1098+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
1099+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
1100+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
1101+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
1102+ "@rollup/rollup-linux-x64-musl": "4.63.4",
1103+ "@rollup/rollup-openbsd-x64": "4.63.4",
1104+ "@rollup/rollup-openharmony-arm64": "4.63.4",
1105+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
1106+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
1107+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
1108+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
1109+ "fsevents": "~2.3.2"
1110+ }
1111+ },
1112+ "node_modules/source-map-js": {
1113+ "version": "1.2.1",
1114+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1115+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1116+ "dev": true,
1117+ "license": "BSD-3-Clause",
1118+ "engines": {
1119+ "node": ">=0.10.0"
1120+ }
1121+ },
1122+ "node_modules/tinyglobby": {
1123+ "version": "0.2.17",
1124+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1125+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1126+ "dev": true,
1127+ "license": "MIT",
1128+ "dependencies": {
1129+ "fdir": "^6.5.0",
1130+ "picomatch": "^4.0.4"
1131+ },
1132+ "engines": {
1133+ "node": ">=12.0.0"
1134+ },
1135+ "funding": {
1136+ "url": "https://github.com/sponsors/SuperchupuDev"
1137+ }
1138+ },
1139+ "node_modules/typescript": {
1140+ "version": "5.9.3",
1141+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1142+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1143+ "dev": true,
1144+ "license": "Apache-2.0",
1145+ "bin": {
1146+ "tsc": "bin/tsc",
1147+ "tsserver": "bin/tsserver"
1148+ },
1149+ "engines": {
1150+ "node": ">=14.17"
1151+ }
1152+ },
1153+ "node_modules/undici-types": {
1154+ "version": "8.9.0",
1155+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
1156+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
1157+ "dev": true,
1158+ "license": "MIT"
1159+ },
1160+ "node_modules/vite": {
1161+ "version": "6.4.3",
1162+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
1163+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
1164+ "dev": true,
1165+ "license": "MIT",
1166+ "dependencies": {
1167+ "esbuild": "^0.25.0",
1168+ "fdir": "^6.4.4",
1169+ "picomatch": "^4.0.2",
1170+ "postcss": "^8.5.3",
1171+ "rollup": "^4.34.9",
1172+ "tinyglobby": "^0.2.13"
1173+ },
1174+ "bin": {
1175+ "vite": "bin/vite.js"
1176+ },
1177+ "engines": {
1178+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1179+ },
1180+ "funding": {
1181+ "url": "https://github.com/vitejs/vite?sponsor=1"
1182+ },
1183+ "optionalDependencies": {
1184+ "fsevents": "~2.3.3"
1185+ },
1186+ "peerDependencies": {
1187+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1188+ "jiti": ">=1.21.0",
1189+ "less": "*",
1190+ "lightningcss": "^1.21.0",
1191+ "sass": "*",
1192+ "sass-embedded": "*",
1193+ "stylus": "*",
1194+ "sugarss": "*",
1195+ "terser": "^5.16.0",
1196+ "tsx": "^4.8.1",
1197+ "yaml": "^2.4.2"
1198+ },
1199+ "peerDependenciesMeta": {
1200+ "@types/node": {
1201+ "optional": true
1202+ },
1203+ "jiti": {
1204+ "optional": true
1205+ },
1206+ "less": {
1207+ "optional": true
1208+ },
1209+ "lightningcss": {
1210+ "optional": true
1211+ },
1212+ "sass": {
1213+ "optional": true
1214+ },
1215+ "sass-embedded": {
1216+ "optional": true
1217+ },
1218+ "stylus": {
1219+ "optional": true
1220+ },
1221+ "sugarss": {
1222+ "optional": true
1223+ },
1224+ "terser": {
1225+ "optional": true
1226+ },
1227+ "tsx": {
1228+ "optional": true
1229+ },
1230+ "yaml": {
1231+ "optional": true
1232+ }
1233+ }
1234+ }
1235+ }
1236+}
package.jsonadded+22−0View file
@@ -0,0 +1,22 @@
1+{
2+ "name": "cymplay",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Real-time linear modal playback of a cymsim cymbal in the browser: play hits from the keyboard, record them, export a cymsim YAML config for the full nonlinear render.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview",
11+ "test": "node --test tests/smoke.ts"
12+ },
13+ "devDependencies": {
14+ "@types/js-yaml": "^4.0.9",
15+ "@types/node": "^26.6.2",
16+ "typescript": "^5.6.0",
17+ "vite": "^6.0.0"
18+ },
19+ "dependencies": {
20+ "js-yaml": "^5.4.2"
21+ }
22+}
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+10−0View file
@@ -0,0 +1,10 @@
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: "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" },
10+];
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+119−0View file
@@ -0,0 +1,119 @@
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 * as 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); }
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/hits.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+// The hit log and its export as a cymsim configuration.
2+import type { Exciter, SimulationConfig } from "./config/schema.ts";
3+import { cloneConfig, configToYaml } from "./config/yaml.ts";
4+
5+export interface LoggedHit {
6+ id: number;
7+ time: number; // audio-context seconds at release (filled in when the worklet reports it)
8+ r: number;
9+ phi: number;
10+ velocity: number;
11+ exciter: Exciter;
12+ source: string; // key or "click"
13+ peakForce?: number;
14+ contactTime?: number;
15+ rebound?: number;
16+}
17+
18+export interface ExportOptions {
19+ leadIn: number; // seconds before the first hit
20+ tail: number; // seconds after the last hit
21+ prefix: string;
22+}
23+
24+/** The base configuration with the recorded hits, times shifted so the first hit is at leadIn. */
25+export function exportConfig(base: SimulationConfig, hits: LoggedHit[], opts: ExportOptions): SimulationConfig {
26+ const cfg = cloneConfig(base);
27+ const sorted = hits.filter((h) => h.time >= 0).slice().sort((a, b) => a.time - b.time);
28+ const t0 = sorted.length ? sorted[0].time : 0;
29+ cfg.hits = sorted.map((h) => ({
30+ time: round(h.time - t0 + opts.leadIn, 5),
31+ r: round(h.r, 5),
32+ phi: round(h.phi, 5),
33+ velocity: round(h.velocity, 3),
34+ exciter: structuredClone(h.exciter),
35+ }));
36+ const last = cfg.hits.length ? cfg.hits[cfg.hits.length - 1].time : 0;
37+ cfg.numerics.duration = round(last + opts.tail, 3);
38+ cfg.output.prefix = opts.prefix;
39+ return cfg;
40+}
41+
42+export function exportYaml(base: SimulationConfig, hits: LoggedHit[], opts: ExportOptions): string {
43+ const header =
44+ `# Recorded in cymplay (linear modal playback) on ${new Date().toISOString()}.\n` +
45+ `# ${hits.length} hit(s); render the full nonlinear model with: cymsim simulate <this file>\n`;
46+ return header + configToYaml(exportConfig(base, hits, opts));
47+}
48+
49+function round(x: number, digits: number): number {
50+ const f = Math.pow(10, digits);
51+ return Math.round(x * f) / f;
52+}
53+
54+/** 16-bit PCM mono WAV of a float signal, peak-normalised to 0.9 full scale when ``normalize``. */
55+export function encodeWav(samples: Float32Array, sampleRate: number, normalize = true): Blob {
56+ let peak = 0;
57+ for (const s of samples) peak = Math.max(peak, Math.abs(s));
58+ const scale = normalize ? (peak > 0 ? 0.9 / peak : 1) : 0.05;
59+ const buf = new ArrayBuffer(44 + 2 * samples.length);
60+ const v = new DataView(buf);
61+ const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
62+ str(0, "RIFF"); v.setUint32(4, 36 + 2 * samples.length, true); str(8, "WAVE");
63+ str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
64+ v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
65+ str(36, "data"); v.setUint32(40, 2 * samples.length, true);
66+ for (let i = 0; i < samples.length; i++) {
67+ const x = Math.max(-1, Math.min(1, samples[i] * scale));
68+ v.setInt16(44 + 2 * i, Math.round(x * 32767), true);
69+ }
70+ return new Blob([buf], { type: "audio/wav" });
71+}
src/main.tsadded+633−0View file
@@ -0,0 +1,633 @@
1+import { PRESETS } from "./config/presets.ts";
2+import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "./config/schema.ts";
3+import { cloneConfig, configToYaml, parseConfigYaml, validateConfig } from "./config/yaml.ts";
4+import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts";
5+import { derive, gainState, storedGainState, type Derived } from "./derived.ts";
6+import { DesignPanel, type Field, type Tier } from "./design.ts";
7+import { encodeWav, exportYaml, type LoggedHit } from "./hits.ts";
8+import { parseModes, type ModesData } from "./modes.ts";
9+import { buildHitPatch, patchExtent } from "./patch.ts";
10+import { structuralKey } from "./precompute/pipeline.ts";
11+import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts";
12+import { CymbalView, type Spot } from "./view.ts";
13+import workletUrl from "./worklet.ts?worker&url";
14+
15+const CACHE_VERSION = 2;
16+/** Preview lift for the mallet: in linear theory it is about 18 dB below the stick (measured),
17+ * which makes it nearly inaudible here although it is the louder of the two once the nonlinear
18+ * cascade is present. It is applied to the output gain only, never to the recorded hits. */
19+const MALLET_PREVIEW_GAIN = 8;
20+
21+// ---------------------------------------------------------------- DOM
22+const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
23+const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile");
24+const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
25+const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
26+const modesFile = $<HTMLInputElement>("modesfile"), clearCacheBtn = $<HTMLButtonElement>("clear-cache");
27+const progressBar = $<HTMLDivElement>("progress-bar"), statusEl = $<HTMLDivElement>("status");
28+const designBox = $<HTMLDivElement>("design"), designStatus = $<HTMLDivElement>("design-status");
29+const revertBtn = $<HTMLButtonElement>("revert"), designYamlBtn = $<HTMLButtonElement>("download-design-yaml");
30+const audioStartBtn = $<HTMLButtonElement>("audio-start"), volumeInput = $<HTMLInputElement>("volume");
31+const signalSel = $<HTMLSelectElement>("signal"), velocityInput = $<HTMLInputElement>("velocity");
32+const velocityValue = $<HTMLSpanElement>("velocity-value"), exciterSel = $<HTMLSelectElement>("exciter");
33+const pointsSel = $<HTMLSelectElement>("points"), previewGainBox = $<HTMLInputElement>("preview-gain");
34+const audioInfo = $<HTMLDivElement>("audio-info"), resetWeightsBtn = $<HTMLButtonElement>("reset-weights");
35+const clearHitsBtn = $<HTMLButtonElement>("clear-hits"), downloadYamlBtn = $<HTMLButtonElement>("download-yaml");
36+const captureBtn = $<HTMLButtonElement>("capture"), downloadWavBtn = $<HTMLButtonElement>("download-wav");
37+const leadinInput = $<HTMLInputElement>("leadin"), tailInput = $<HTMLInputElement>("tail"), prefixInput = $<HTMLInputElement>("prefix");
38+const hitsBody = document.querySelector<HTMLTableSectionElement>("#hits tbody")!;
39+const keyhelp = $<HTMLDivElement>("keyhelp");
40+
41+// ---------------------------------------------------------------- state
42+let presetYaml = PRESETS[0].yaml;
43+let config: SimulationConfig = parseConfigYaml(presetYaml).config!;
44+let modes: ModesData | null = null;
45+let modesBuffer: ArrayBuffer | null = null;
46+let modesStructural = "";
47+let derived: Derived | null = null;
48+let ctx: AudioContext | null = null;
49+let node: AudioWorkletNode | null = null;
50+let gainNode: GainNode | null = null;
51+let worker: Worker | null = null;
52+let workerHasModes = false;
53+let token = 0;
54+let computeToken = -1;
55+let computeStart = 0;
56+let gainToken = -1;
57+/** The material and listener the gains currently in ``modes`` were computed for. The reference
58+ * E0 and rho0 in the file header must never be touched: they are what ``freq`` is relative to. */
59+let gainsFor = "";
60+let gainsPending = "";
61+let gainTimer = 0;
62+let hits: LoggedHit[] = [];
63+let nextId = 1;
64+let capturing = false;
65+let captured: Float32Array | null = null;
66+let workletLoad = 0;
67+const view = new CymbalView($<HTMLCanvasElement>("cymbal"));
68+
69+// ---------------------------------------------------------------- key map: the rows are radii
70+const ROW_KEYS = [
71+ ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
72+ ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";"],
73+ ["Z", "X", "C", "V", "B", "N", "M", ",", ".", "/"],
74+];
75+const ROW_CODES = [
76+ ["KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP"],
77+ ["KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Semicolon"],
78+ ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Period", "Slash"],
79+];
80+const ROW_NAMES = ["inner bow (0.42 R), farthest from you", "mid bow (0.64 R)", "near the rim (0.86 R), closest to you"];
81+const ROW_FRACTIONS = [0.42, 0.64, 0.86];
82+const WEIGHT_STEP = 1.25, WEIGHT_MIN = 0.25, WEIGHT_MAX = 4;
83+const WEIGHTS_KEY = "cymplay.weights";
84+
85+/** How hard each position is struck, relative to the master level. A position keeps its weight
86+ * across sessions, so a kit of accents and ghost notes stays where you put it. */
87+const weights = new Map<string, number>(loadWeights());
88+
89+function loadWeights(): [string, number][] {
90+ try {
91+ const raw = localStorage.getItem(WEIGHTS_KEY);
92+ if (raw) return Object.entries(JSON.parse(raw) as Record<string, number>);
93+ } catch {
94+ /* a blocked or empty store just means every position starts at 1 */
95+ }
96+ return [];
97+}
98+function saveWeights() {
99+ try {
100+ localStorage.setItem(WEIGHTS_KEY, JSON.stringify(Object.fromEntries(weights)));
101+ } catch {
102+ /* not being able to remember the weights is not worth interrupting play */
103+ }
104+}
105+function weightOf(code: string): number {
106+ return weights.get(code) ?? 1;
107+}
108+function velocityFor(weight: number): number {
109+ return Math.min(16, Math.max(0.3, weight * Number(velocityInput.value)));
110+}
111+
112+/** The three key rows are three concentric arcs across the near half of the cymbal, laid out as
113+ * the keyboard is: the bottom row is the edge closest to the player and the top row the inner
114+ * bow, while a row reads left to right from the left of the cymbal, round the near side, to the
115+ * right. Pressing Q, A and Z in turn therefore walks outwards along one ray. */
116+function spots(): Spot[] {
117+ const R = config.profile.radius;
118+ const out: Spot[] = [];
119+ ROW_CODES.forEach((codes, row) => {
120+ const n = codes.length;
121+ for (let k = 0; k < n; k++) {
122+ const code = codes[k], weight = weightOf(code);
123+ out.push({ code, label: ROW_KEYS[row][k], r: ROW_FRACTIONS[row] * R, phi: -Math.PI + (Math.PI * k) / (n - 1),
124+ row, weight, velocity: velocityFor(weight) });
125+ }
126+ });
127+ const bell = weightOf("Space");
128+ out.push({ code: "Space", label: "sp", r: 0.6 * config.profile.bell_shoulder_radius, phi: -Math.PI / 2, row: 3,
129+ weight: bell, velocity: velocityFor(bell) });
130+ return out;
131+}
132+const spotByCode = new Map<string, Spot>();
133+
134+function refreshSpots() {
135+ const s = spots();
136+ spotByCode.clear();
137+ for (const sp of s) spotByCode.set(sp.code, sp);
138+ view.setProfile(config.profile, s);
139+ keyhelp.textContent =
140+ ROW_KEYS.map((keys, i) => `${keys.join(" ")} ${ROW_NAMES[i]}`).join("\n") +
141+ "\nspace the bell" +
142+ "\n1-9 set the master level, [ ] nudge it; each position shows the velocity it will be struck at" +
143+ "\nscroll the wheel over a position to make it harder or softer; click the cymbal to strike anywhere";
144+}
145+
146+function exciter(): Exciter {
147+ const kind = exciterSel.value as "stick" | "mallet";
148+ for (const h of config.hits) if (h.exciter.kind === kind) return structuredClone(h.exciter);
149+ return kind === "stick" ? defaultStick() : defaultMallet();
150+}
151+
152+// ---------------------------------------------------------------- status helpers
153+function setStatus(text: string) {
154+ statusEl.textContent = text;
155+}
156+function setProgress(frac: number) {
157+ progressBar.style.width = `${Math.round(100 * Math.min(1, Math.max(0, frac)))}%`;
158+}
159+function download(blob: Blob, name: string) {
160+ const a = document.createElement("a");
161+ a.href = URL.createObjectURL(blob);
162+ a.download = name;
163+ a.click();
164+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
165+}
166+function updateAudioInfo() {
167+ if (!ctx) {
168+ audioInfo.textContent = "audio not started";
169+ return;
170+ }
171+ const out = (ctx as AudioContext & { outputLatency?: number }).outputLatency ?? 0;
172+ audioInfo.textContent =
173+ `${ctx.sampleRate} Hz, base latency ${(ctx.baseLatency * 1e3).toFixed(1)} ms, output latency ${(out * 1e3).toFixed(1)} ms, ` +
174+ `worklet load ${(100 * workletLoad).toFixed(0)} %, state ${ctx.state}`;
175+}
176+function forgetModes() {
177+ modes = null;
178+ modesBuffer = null;
179+ derived = null;
180+ downloadModesBtn.disabled = true;
181+}
182+
183+// ---------------------------------------------------------------- modes
184+function options() {
185+ return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) };
186+}
187+function structuralString(cfg: SimulationConfig) {
188+ return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) });
189+}
190+async function currentKey() {
191+ return cacheKey(structuralString(config));
192+}
193+
194+function describeModes(label: string) {
195+ if (!modes || !derived) return;
196+ const issues = validateConfig(config);
197+ const stale = modesStructural !== structuralString(config);
198+ setStatus(
199+ `${label}: ${modes.nNodes} nodes, ${modes.nModes} modes, ` +
200+ `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[modes.nModes - 1].toFixed(0)} Hz, ` +
201+ `playback mesh ${(modes.meta.playback_element_size * 1e3).toFixed(0)} mm` +
202+ (stale ? "\nthe shape has changed since these modes were computed: press Compute modes" : ""),
203+ );
204+ const top = derived.freq[modes.nModes - 1];
205+ designStatus.textContent =
206+ `T60 ${derived.t60Low.toFixed(2)} s at ${derived.freq[0].toFixed(0)} Hz, ` +
207+ `${(1e3 * derived.t60High).toFixed(0)} ms at ${(top / 1e3).toFixed(1)} kHz` +
208+ (derived.overdamped ? `; ${derived.overdamped} modes clamped at critical damping` : "") +
209+ // a softer or denser material lowers every frequency, so the band above the new top mode is
210+ // empty until the modes are computed again with the new material
211+ (top < 0.9 * modes.meta.fmax ? `\nthe material has pulled the top mode down to ${(top / 1e3).toFixed(1)} kHz; recompute to fill the band again` : "") +
212+ (issues.length ? "\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n") : "");
213+ designStatus.className = "status" + (issues.length ? " bad" : "");
214+}
215+
216+function setModes(buffer: ArrayBuffer, label: string, structural: string) {
217+ let parsed: ModesData;
218+ try {
219+ parsed = parseModes(buffer);
220+ } catch (err) {
221+ setStatus(`could not read the modes: ${(err as Error).message}`);
222+ return;
223+ }
224+ modes = parsed;
225+ modesBuffer = buffer;
226+ modesStructural = structural;
227+ downloadModesBtn.disabled = false;
228+ derived = derive(parsed, config);
229+ gainsFor = storedGainState(parsed);
230+ sendInit();
231+ describeModes(label);
232+ if (gainsFor !== gainState(config)) requestGains(0);
233+}
234+
235+async function tryCache() {
236+ if (modes) {
237+ describeModes("modes");
238+ return;
239+ }
240+ const buf = await cacheGet(await currentKey());
241+ if (buf) setModes(buf, "cached modes", structuralString(config));
242+ else setStatus("no modes for this cymbal yet: press Compute modes");
243+}
244+
245+function ensureWorker(): Worker {
246+ if (!worker) {
247+ worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" });
248+ worker.onmessage = (ev: MessageEvent<PrecomputeReply>) => void onWorkerMessage(ev.data);
249+ worker.onerror = (e) => setStatus(`worker error: ${e.message}`);
250+ workerHasModes = false;
251+ }
252+ return worker;
253+}
254+
255+function compute() {
256+ const issues = validateConfig(config);
257+ if (issues.length) {
258+ setStatus("the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
259+ return;
260+ }
261+ computeBtn.disabled = true;
262+ computeStart = performance.now();
263+ computeToken = ++token;
264+ setProgress(0);
265+ setStatus("computing the modes...");
266+ const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
267+ ensureWorker().postMessage(req);
268+}
269+
270+function requestGains(delayMs = 250) {
271+ if (!modes || !derived || !modesBuffer) return;
272+ clearTimeout(gainTimer);
273+ gainTimer = window.setTimeout(() => {
274+ if (!modes || !derived || !modesBuffer) return;
275+ gainsPending = gainState(config);
276+ if (gainsPending === gainsFor) return;
277+ gainToken = ++token;
278+ const req: PrecomputeRequest = {
279+ type: "gains", token: gainToken, listener: structuredClone(config.listener),
280+ freq: derived.freq, phiFactor: derived.phiFactor,
281+ ...(workerHasModes ? {} : { buffer: modesBuffer }),
282+ };
283+ workerHasModes = true;
284+ ensureWorker().postMessage(req);
285+ }, delayMs);
286+}
287+
288+async function onWorkerMessage(msg: PrecomputeReply) {
289+ if (msg.type === "progress") {
290+ if (msg.token !== computeToken) return;
291+ setProgress(msg.frac);
292+ setStatus(`${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${((performance.now() - computeStart) / 1000).toFixed(0)} s]`);
293+ } else if (msg.type === "modes") {
294+ if (msg.token !== computeToken) return;
295+ computeBtn.disabled = false;
296+ setProgress(1);
297+ const structural = structuralString(config);
298+ await cachePut(await cacheKey(structural), msg.buffer);
299+ setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
300+ workerHasModes = true; // the worker kept its own copy
301+ } else if (msg.type === "gains") {
302+ if (msg.token !== gainToken || !modes) return;
303+ modes.gainFront = msg.front;
304+ modes.gainBack = msg.back;
305+ gainsFor = gainsPending;
306+ sendGain();
307+ } else {
308+ computeBtn.disabled = false;
309+ setStatus(`error: ${msg.message}`);
310+ }
311+}
312+
313+// ---------------------------------------------------------------- audio
314+function combinedGain(): Float32Array {
315+ if (!modes) return new Float32Array(0);
316+ const g = new Float32Array(2 * modes.nModes);
317+ const back = signalSel.value === "unbaffled";
318+ for (let i = 0; i < g.length; i++) g[i] = modes.gainFront[i] - (back ? modes.gainBack[i] : 0);
319+ return g;
320+}
321+function sendInit() {
322+ if (!node || !modes || !derived) return;
323+ node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: combinedGain() });
324+}
325+function sendParams() {
326+ if (!node || !derived) return;
327+ node.port.postMessage({ type: "params", freq: derived.freq, sigma: derived.sigma });
328+}
329+function sendGain() {
330+ if (!node || !modes) return;
331+ node.port.postMessage({ type: "gain", gain: combinedGain() });
332+}
333+
334+async function startAudio() {
335+ if (ctx) {
336+ await ctx.resume();
337+ updateAudioInfo();
338+ return;
339+ }
340+ ctx = new AudioContext({ latencyHint: "interactive" });
341+ await ctx.audioWorklet.addModule(workletUrl);
342+ node = new AudioWorkletNode(ctx, "cymbal-modal", { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
343+ gainNode = ctx.createGain();
344+ setVolume();
345+ node.connect(gainNode).connect(ctx.destination);
346+ node.port.onmessage = (ev) => onWorkletMessage(ev.data);
347+ node.onprocessorerror = () => setStatus("the audio processor stopped: reload the page (details in the browser console)");
348+ await ctx.resume();
349+ audioStartBtn.textContent = "Audio running";
350+ sendInit();
351+ updateAudioInfo();
352+ setInterval(updateAudioInfo, 1000);
353+}
354+
355+function setVolume() {
356+ if (!gainNode || !ctx) return;
357+ const lift = exciterSel.value === "mallet" && previewGainBox.checked ? MALLET_PREVIEW_GAIN : 1;
358+ gainNode.gain.setTargetAtTime(0.15 * Number(volumeInput.value) * lift, ctx.currentTime, 0.02);
359+}
360+
361+interface WorkletMessage {
362+ type: string;
363+ id?: number;
364+ time?: number;
365+ peakForce?: number;
366+ contactTime?: number;
367+ rebound?: number;
368+ mean?: number;
369+ chunks?: Float32Array[];
370+}
371+
372+function onWorkletMessage(msg: WorkletMessage) {
373+ if (msg.type === "hitStarted") {
374+ const h = hits.find((x) => x.id === msg.id);
375+ if (h) h.time = msg.time!;
376+ renderHits();
377+ } else if (msg.type === "hitDone") {
378+ const h = hits.find((x) => x.id === msg.id);
379+ if (h) {
380+ h.peakForce = msg.peakForce;
381+ h.contactTime = msg.contactTime;
382+ h.rebound = msg.rebound;
383+ }
384+ renderHits();
385+ } else if (msg.type === "load") {
386+ workletLoad = msg.mean ?? 0;
387+ } else if (msg.type === "captured") {
388+ const chunks = msg.chunks ?? [];
389+ const n = chunks.reduce((s, c) => s + c.length, 0);
390+ captured = new Float32Array(n);
391+ let pos = 0;
392+ for (const c of chunks) {
393+ captured.set(c, pos);
394+ pos += c.length;
395+ }
396+ downloadWavBtn.disabled = n === 0;
397+ captureBtn.textContent = "Start capture";
398+ setStatus(`captured ${(n / (ctx?.sampleRate ?? 48000)).toFixed(2)} s`);
399+ }
400+}
401+
402+// ---------------------------------------------------------------- hits
403+function strike(r: number, phi: number, velocity: number, source: string) {
404+ if (!modes) {
405+ setStatus("compute or load the modes first");
406+ return;
407+ }
408+ if (!node || !ctx) {
409+ setStatus("press Start audio first");
410+ return;
411+ }
412+ const ex = exciter();
413+ const R = config.profile.radius, es = modes.meta.playback_element_size;
414+ if (r + patchExtent(ex) >= R - es) {
415+ setStatus(`hit at r = ${r.toFixed(3)} m: the contact patch reaches the rim, ignored`);
416+ return;
417+ }
418+ const mode = pointsSel.value;
419+ const maxPoints = mode === "full" ? 7 : ex.kind === "mallet" ? 1 : mode === "cheap" ? 3 : 7;
420+ const patch = buildHitPatch(modes, ex, r, phi, velocity, maxPoints, derived?.phiFactor ?? 1);
421+ if (!patch) {
422+ setStatus("that point is outside the mesh, ignored");
423+ return;
424+ }
425+ const id = nextId++;
426+ node.port.postMessage(
427+ {
428+ type: "hit", id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha,
429+ mass: patch.mass, gap: patch.gap, velocity,
430+ },
431+ [patch.phi.buffer, patch.offsets.buffer],
432+ );
433+ hits.push({ id, time: -1, r, phi, velocity, exciter: ex, source });
434+ view.flash(r * Math.cos(phi), r * Math.sin(phi), velocity, ex.kind);
435+ renderHits();
436+}
437+
438+function renderHits() {
439+ hitsBody.innerHTML = "";
440+ const t0 = hits.find((h) => h.time >= 0)?.time ?? 0;
441+ hits.forEach((h, i) => {
442+ const tr = document.createElement("tr");
443+ for (const c of [
444+ String(i + 1), h.time >= 0 ? (h.time - t0).toFixed(3) : "...", h.source, h.exciter.kind, h.r.toFixed(3),
445+ h.phi.toFixed(3), h.velocity.toFixed(2), h.peakForce !== undefined ? h.peakForce.toFixed(0) : "",
446+ h.contactTime !== undefined ? (h.contactTime * 1e3).toFixed(2) : "",
447+ ]) {
448+ const td = document.createElement("td");
449+ td.textContent = c;
450+ tr.appendChild(td);
451+ }
452+ hitsBody.appendChild(tr);
453+ });
454+ downloadYamlBtn.disabled = hits.length === 0;
455+}
456+
457+// ---------------------------------------------------------------- design panel
458+const panel = new DesignPanel(designBox, () => config, onDesignChange);
459+
460+function onDesignChange(_field: Field, tier: Tier) {
461+ refreshSpots();
462+ if (!modes) return;
463+ if (tier === "live") {
464+ derived = derive(modes, config);
465+ sendParams();
466+ requestGains();
467+ }
468+ describeModes("modes");
469+}
470+
471+function setConfig(cfg: SimulationConfig, label: string) {
472+ config = cfg;
473+ hits = [];
474+ renderHits();
475+ panel.refresh();
476+ refreshSpots();
477+ forgetModes();
478+ setStatus(`${label}: ${Math.round(cfg.profile.radius * 2 * 39.3701)} inch, ${(cfg.profile.h_rim * 1e3).toFixed(2)} to ${(cfg.profile.h_bell * 1e3).toFixed(2)} mm thick, seed ${cfg.imperfections.seed}`);
479+ void tryCache();
480+}
481+
482+// ---------------------------------------------------------------- wiring
483+for (const p of PRESETS) {
484+ const o = document.createElement("option");
485+ o.value = p.id;
486+ o.textContent = p.name;
487+ presetSel.appendChild(o);
488+}
489+const customOpt = document.createElement("option");
490+customOpt.value = "custom";
491+customOpt.textContent = "loaded YAML";
492+customOpt.disabled = true;
493+presetSel.appendChild(customOpt);
494+
495+presetSel.addEventListener("change", () => {
496+ const p = PRESETS.find((x) => x.id === presetSel.value);
497+ if (p) {
498+ presetYaml = p.yaml;
499+ setConfig(parseConfigYaml(p.yaml).config!, p.name);
500+ }
501+});
502+yamlFile.addEventListener("change", async () => {
503+ const file = yamlFile.files?.[0];
504+ if (!file) return;
505+ const text = await file.text();
506+ const { config: cfg, issues } = parseConfigYaml(text);
507+ if (!cfg) {
508+ setStatus("YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
509+ return;
510+ }
511+ presetYaml = text;
512+ customOpt.disabled = false;
513+ presetSel.value = "custom";
514+ setConfig(cfg, file.name);
515+});
516+for (const el of [esizeSel, fmaxInput]) {
517+ el.addEventListener("change", () => {
518+ forgetModes();
519+ void tryCache();
520+ });
521+}
522+computeBtn.addEventListener("click", compute);
523+downloadModesBtn.addEventListener("click", () => {
524+ if (modesBuffer) download(new Blob([modesBuffer]), `cymplay_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`);
525+});
526+modesFile.addEventListener("change", async () => {
527+ const file = modesFile.files?.[0];
528+ if (!file) return;
529+ const buf = await file.arrayBuffer();
530+ let loaded: ModesData;
531+ try {
532+ loaded = parseModes(buf);
533+ } catch (err) {
534+ setStatus(`could not load the modes: ${(err as Error).message}`);
535+ return;
536+ }
537+ const { config: cfg } = parseConfigYaml(JSON.stringify(loaded.meta.config));
538+ if (cfg) {
539+ customOpt.disabled = false;
540+ presetSel.value = "custom";
541+ config = cfg;
542+ esizeSel.value = String(loaded.meta.playback_element_size);
543+ fmaxInput.value = String(loaded.meta.fmax);
544+ panel.refresh();
545+ refreshSpots();
546+ }
547+ workerHasModes = false;
548+ setModes(buf, file.name, structuralString(config));
549+});
550+clearCacheBtn.addEventListener("click", async () => {
551+ await cacheClear();
552+ setStatus("the cache is empty");
553+});
554+revertBtn.addEventListener("click", () => setConfig(parseConfigYaml(presetYaml).config!, "reverted"));
555+designYamlBtn.addEventListener("click", () => {
556+ const cfg = cloneConfig(config);
557+ cfg.output.prefix = prefixInput.value || "cymplay";
558+ download(new Blob([configToYaml(cfg)], { type: "text/yaml" }), `${cfg.output.prefix}_design.yaml`);
559+});
560+audioStartBtn.addEventListener("click", () => void startAudio());
561+volumeInput.addEventListener("input", setVolume);
562+previewGainBox.addEventListener("change", setVolume);
563+exciterSel.addEventListener("change", setVolume);
564+signalSel.addEventListener("change", sendGain);
565+velocityInput.addEventListener("input", () => {
566+ velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
567+ refreshSpots();
568+});
569+resetWeightsBtn.addEventListener("click", () => {
570+ weights.clear();
571+ saveWeights();
572+ refreshSpots();
573+});
574+clearHitsBtn.addEventListener("click", () => {
575+ hits = [];
576+ renderHits();
577+ node?.port.postMessage({ type: "reset" });
578+});
579+downloadYamlBtn.addEventListener("click", () => {
580+ const text = exportYaml(config, hits, { leadIn: Number(leadinInput.value), tail: Number(tailInput.value), prefix: prefixInput.value || "cymplay" });
581+ download(new Blob([text], { type: "text/yaml" }), `${prefixInput.value || "cymplay"}.yaml`);
582+});
583+captureBtn.addEventListener("click", () => {
584+ if (!node) {
585+ setStatus("press Start audio first");
586+ return;
587+ }
588+ capturing = !capturing;
589+ node.port.postMessage({ type: "capture", on: capturing });
590+ captureBtn.textContent = capturing ? "Stop capture" : "Start capture";
591+});
592+downloadWavBtn.addEventListener("click", () => {
593+ if (captured && ctx) download(encodeWav(captured, ctx.sampleRate), `${prefixInput.value || "cymplay"}_linear.wav`);
594+});
595+
596+function setLevel(v: number) {
597+ velocityInput.value = String(Math.min(16, Math.max(0.3, v)));
598+ velocityInput.dispatchEvent(new Event("input"));
599+}
600+
601+view.onStrike = (x, y) => strike(Math.hypot(x, y), Math.atan2(y, x), velocityFor(1), "click");
602+view.onWeigh = (spot, steps) => {
603+ const w = Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weightOf(spot.code) * Math.pow(WEIGHT_STEP, steps)));
604+ if (Math.abs(w - 1) < 1e-3) weights.delete(spot.code);
605+ else weights.set(spot.code, w);
606+ saveWeights();
607+ refreshSpots();
608+};
609+
610+window.addEventListener("keydown", (ev) => {
611+ const t = ev.target as HTMLElement;
612+ if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA")) return;
613+ if (ev.repeat || ev.ctrlKey || ev.metaKey) return;
614+ const digit = /^Digit([1-9])$/.exec(ev.code);
615+ if (digit) {
616+ setLevel(1.25 * Number(digit[1]));
617+ return;
618+ }
619+ if (ev.code === "BracketLeft" || ev.code === "BracketRight") {
620+ setLevel(Number(velocityInput.value) + (ev.code === "BracketRight" ? 0.5 : -0.5));
621+ return;
622+ }
623+ const s = spotByCode.get(ev.code);
624+ if (s) {
625+ ev.preventDefault();
626+ strike(s.r, s.phi, s.velocity, s.label);
627+ }
628+});
629+
630+panel.refresh();
631+refreshSpots();
632+velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
633+void tryCache();
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/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/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/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/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/patch.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+// Contact patch of one hit, resolved on the modal data: patch points (a line for a stick, a
2+// centre plus hexagon for a mallet), their containing triangles, the residual heights above the
3+// exciter's best-fit face plane, and the mode shape values at the points. Mirrors
4+// cymsim/exciter.py build_patch, with the surface height taken from the nodal z0 by linear
5+// interpolation.
6+import type { Exciter } from "./config/schema.ts";
7+import { planeResiduals } from "./model/linalg.ts";
8+import { interpolateNodal, locate, modeValuesAt, type ModesData } from "./modes.ts";
9+
10+export interface HitPatch {
11+ phi: Float32Array; // (P, Nm) point-major mode values
12+ offsets: Float32Array; // (P,)
13+ kPoint: number;
14+ alpha: number;
15+ mass: number;
16+ gap: number;
17+}
18+
19+export function contactStiffness(ex: Exciter, velocity: number): number {
20+ if (ex.kind === "stick") return ex.k;
21+ return ((ex.alpha + 1) * 0.5 * ex.mass * velocity * velocity) / Math.pow(ex.compression, ex.alpha + 1);
22+}
23+
24+/** Half-extent of the patch [m], used to keep hits inside the mesh. */
25+export function patchExtent(ex: Exciter): number {
26+ return ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
27+}
28+
29+/** ``maxPoints`` limits the number of springs used in real time (the exported configuration keeps
30+ * the exciter's own patch): a stick keeps the centre and the ends of its line, a mallet its centre. */
31+export function buildHitPatch(data: ModesData, ex: Exciter, r: number, phiAngle: number, velocity: number,
32+ maxPoints = 7, phiFactor = 1): HitPatch | null {
33+ const xs = r * Math.cos(phiAngle), ys = r * Math.sin(phiAngle);
34+ const px: number[] = [], py: number[] = [];
35+ if (ex.kind === "stick") {
36+ const ang = phiAngle + ex.patch_angle, P = Math.max(1, Math.min(Math.floor(ex.patch_points), maxPoints));
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));
40+ py.push(ys + xi * Math.sin(ang));
41+ }
42+ } else {
43+ px.push(xs);
44+ py.push(ys);
45+ for (let k = 0; k < 6 && maxPoints > 1; k++) {
46+ const th = (k * Math.PI) / 3;
47+ px.push(xs + ex.radius * Math.cos(th));
48+ py.push(ys + ex.radius * Math.sin(th));
49+ }
50+ }
51+ const P = px.length, Nm = data.nModes;
52+ const phi = new Float32Array(P * Nm), z0p = new Float64Array(P);
53+ for (let p = 0; p < P; p++) {
54+ const loc = locate(data, px[p], py[p]);
55+ if (!loc) return null;
56+ modeValuesAt(data, loc.nodes, loc.weights, phi, p * Nm, phiFactor);
57+ z0p[p] = interpolateNodal(data.z0, loc.nodes, loc.weights);
58+ }
59+ const offsets = Float32Array.from(planeResiduals(px.map((x) => x - xs), py.map((y) => y - ys), z0p));
60+ return { phi, offsets, kPoint: contactStiffness(ex, velocity) / P, alpha: ex.alpha, mass: ex.mass, gap: ex.gap };
61+}
62+
src/precompute/assemble.tsadded+117−0View file
@@ -0,0 +1,117 @@
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+}
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+50−0View file
@@ -0,0 +1,50 @@
1+// Web Worker entry: the two computations that are too slow for the main thread. "modes" is the
2+// full eigensolve (seconds to minutes); "gains" is the listener sum, redone whenever the
3+// listener or the material changes (a fraction of a second).
4+import type { Listener, SimulationConfig } from "../config/schema.ts";
5+import { parseModes, serializeModes, type ModesData } from "../modes.ts";
6+import { computeGains } from "./gains.ts";
7+import { computeModes, type PrecomputeOptions } from "./pipeline.ts";
8+
9+export type PrecomputeRequest =
10+ | { type: "modes"; token: number; config: SimulationConfig; options: PrecomputeOptions }
11+ | { type: "gains"; token: number; buffer?: ArrayBuffer; listener: Listener; freq: Float64Array; phiFactor: number };
12+
13+export type PrecomputeReply =
14+ | { type: "progress"; token: number; phase: string; frac: number }
15+ | { type: "modes"; token: number; buffer: ArrayBuffer; seconds: number }
16+ | { type: "gains"; token: number; front: Float32Array; back: Float32Array; seconds: number }
17+ | { type: "error"; token: number; message: string };
18+
19+/** The worker keeps the modes it was last given so that repeated gain requests, which happen
20+ * while a listener slider is being dragged, do not re-send a 12 MB buffer every time. */
21+let held: { buffer: ArrayBuffer; data: ModesData } | null = null;
22+
23+self.onmessage = (ev: MessageEvent<PrecomputeRequest>) => {
24+ const msg = ev.data;
25+ const post = (reply: PrecomputeReply, transfer?: Transferable[]) => (self as unknown as Worker).postMessage(reply, transfer ?? []);
26+ const t0 = performance.now();
27+ try {
28+ if (msg.type === "modes") {
29+ let lastPost = 0;
30+ const data = computeModes(msg.config, msg.options, (phase, frac) => {
31+ const t = performance.now();
32+ if (t - lastPost > 100 || frac >= 1) {
33+ lastPost = t;
34+ post({ type: "progress", token: msg.token, phase, frac });
35+ }
36+ });
37+ const buffer = serializeModes(data);
38+ held = { buffer, data };
39+ post({ type: "modes", token: msg.token, buffer: buffer.slice(0), seconds: (performance.now() - t0) / 1000 });
40+ } else {
41+ if (msg.buffer) held = { buffer: msg.buffer, data: parseModes(msg.buffer) };
42+ if (!held) throw new Error("no modes in the worker: send the buffer with the first gains request");
43+ const g = computeGains(held.data, msg.listener, msg.freq, msg.phiFactor);
44+ post({ type: "gains", token: msg.token, front: g.front, back: g.back, seconds: (performance.now() - t0) / 1000 },
45+ [g.front.buffer, g.back.buffer]);
46+ }
47+ } catch (err) {
48+ post({ type: "error", token: msg.token, message: (err as Error).message ?? String(err) });
49+ }
50+};
src/style.cssadded+51−0View file
@@ -0,0 +1,51 @@
1+:root {
2+ color-scheme: light dark;
3+ --bg: #f4f2ec;
4+ --fg: #222;
5+ --panel: #fff;
6+ --line: #d8d3c6;
7+ --accent: #2a5db0;
8+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
9+ font-size: 14px;
10+}
11+@media (prefers-color-scheme: dark) {
12+ :root { --bg: #1d1c19; --fg: #e8e4da; --panel: #27251f; --line: #3d3a32; --accent: #7fa6ea; }
13+}
14+* { box-sizing: border-box; }
15+body { margin: 0; background: var(--bg); color: var(--fg); height: 100vh; display: flex; flex-direction: column; }
16+header { display: flex; align-items: baseline; gap: 12px; padding: 8px 16px; border-bottom: 1px solid var(--line); }
17+header h1 { font-size: 18px; margin: 0; }
18+header .sub { opacity: 0.7; }
19+main { flex: 1; display: grid; grid-template-columns: minmax(300px, 1fr) minmax(360px, 520px); gap: 12px; padding: 12px 16px; min-height: 0; }
20+.left { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
21+#cymbal { flex: 1; width: 100%; min-height: 0; aspect-ratio: 1; max-height: calc(100vh - 160px); touch-action: none; cursor: crosshair; }
22+.keys { font-family: ui-monospace, monospace; font-size: 12px; opacity: 0.8; white-space: pre-wrap; }
23+.right { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
24+details { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; }
25+summary { cursor: pointer; font-weight: 600; }
26+.row { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin: 6px 0; }
27+.row.small { font-size: 12px; opacity: 0.8; }
28+label { display: inline-flex; align-items: center; gap: 6px; }
29+label.file { cursor: pointer; }
30+input[type="number"] { width: 80px; }
31+button { padding: 4px 10px; }
32+.progress { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin: 4px 0; }
33+#progress-bar { height: 100%; width: 0; background: var(--accent); transition: width 0.1s; }
34+.status { font-size: 12px; opacity: 0.85; white-space: pre-wrap; }
35+table { border-collapse: collapse; width: 100%; font-size: 12px; font-variant-numeric: tabular-nums; }
36+th, td { text-align: right; padding: 2px 6px; border-bottom: 1px solid var(--line); }
37+th:nth-child(3), td:nth-child(3), th:nth-child(4), td:nth-child(4) { text-align: left; }
38+@media (max-width: 800px) { main { grid-template-columns: 1fr; } #cymbal { max-height: 60vh; } }
39+
40+/* design panel */
41+#design { display: flex; flex-direction: column; gap: 6px; }
42+details.group { background: transparent; border: 1px solid var(--line); border-radius: 5px; padding: 4px 8px; }
43+details.group > summary { font-weight: 500; font-size: 13px; }
44+details.group .note { font-size: 11px; opacity: 0.65; margin: 2px 0 6px; }
45+.field { display: grid; grid-template-columns: minmax(140px, 1.4fr) 2fr 84px; gap: 8px; align-items: center; margin: 3px 0; }
46+.field > label { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
47+.field input[type="range"] { width: 100%; min-width: 60px; }
48+.field input[type="number"] { width: 100%; font-variant-numeric: tabular-nums; }
49+.field.live > label::after { content: " live"; font-size: 9px; letter-spacing: 0.04em; opacity: 0.55; text-transform: uppercase; }
50+.status.bad { color: #b03030; }
51+@media (prefers-color-scheme: dark) { .status.bad { color: #ff8b8b; } }
src/view.tsadded+208−0View file
@@ -0,0 +1,208 @@
1+// Top view of the cymbal on a canvas: rim, shoulder and bell circles, the preset strike spots
2+// with their key labels, and a fading marker for every hit. Coordinates follow cymsim: x to the
3+// right, y up, phi = atan2(y, x).
4+import type { Profile } from "./config/schema.ts";
5+
6+export interface Spot {
7+ code: string; // KeyboardEvent.code, so that the keyboard layout does not matter
8+ label: string;
9+ r: number;
10+ phi: number;
11+ row: number; // which key row the spot belongs to, used only for its colour
12+ weight: number; // velocity of this position relative to the master level
13+ velocity: number; // the resulting strike velocity [m/s], drawn under the key letter
14+}
15+
16+interface Flash {
17+ x: number;
18+ y: number;
19+ t0: number;
20+ velocity: number;
21+ kind: "stick" | "mallet";
22+}
23+
24+export class CymbalView {
25+ private ctx: CanvasRenderingContext2D;
26+ private flashes: Flash[] = [];
27+ private spots: Spot[] = [];
28+ private profile: Profile | null = null;
29+ private raf = 0;
30+ onStrike: ((x: number, y: number) => void) | null = null;
31+ /** Wheel over a position: ``steps`` is positive away from the user (harder). */
32+ onWeigh: ((spot: Spot, steps: number) => void) | null = null;
33+
34+ private canvas: HTMLCanvasElement;
35+
36+ constructor(canvas: HTMLCanvasElement) {
37+ this.canvas = canvas;
38+ const ctx = canvas.getContext("2d");
39+ if (!ctx) throw new Error("no 2d context");
40+ this.ctx = ctx;
41+ canvas.addEventListener("pointerdown", (ev) => {
42+ if (!this.profile || !this.onStrike) return;
43+ const [x, y] = this.toModel(ev);
44+ this.onStrike(x, y);
45+ });
46+ canvas.addEventListener("wheel", (ev) => {
47+ if (!this.profile || !this.onWeigh) return;
48+ const [x, y] = this.toModel(ev);
49+ const spot = this.spotAt(x, y);
50+ if (!spot) return; // away from a position the page scrolls as usual
51+ ev.preventDefault();
52+ this.onWeigh(spot, ev.deltaY > 0 ? -1 : 1);
53+ }, { passive: false });
54+ const ro = new ResizeObserver(() => this.resize());
55+ ro.observe(canvas);
56+ this.resize();
57+ }
58+
59+ private resize() {
60+ const rect = this.canvas.getBoundingClientRect();
61+ const dpr = window.devicePixelRatio || 1;
62+ const size = Math.max(1, Math.floor(Math.min(rect.width, rect.height) * dpr));
63+ if (this.canvas.width !== size) {
64+ this.canvas.width = size;
65+ this.canvas.height = size;
66+ }
67+ this.draw();
68+ }
69+
70+ setProfile(profile: Profile, spots: Spot[]) {
71+ this.profile = profile;
72+ this.spots = spots;
73+ this.draw();
74+ }
75+
76+ private toModel(ev: { clientX: number; clientY: number }): [number, number] {
77+ const rect = this.canvas.getBoundingClientRect();
78+ const { cx, cy, scale } = this.frame();
79+ return [
80+ ((ev.clientX - rect.left) * this.canvas.width / rect.width - cx) / scale,
81+ -((ev.clientY - rect.top) * this.canvas.height / rect.height - cy) / scale,
82+ ];
83+ }
84+
85+ /** The position under a point of the domain, if the point is within its marker. */
86+ spotAt(x: number, y: number): Spot | null {
87+ const { scale } = this.frame();
88+ const reach = Math.max(this.markerRadius(1.6), this.canvas.width / 28) / scale;
89+ let best: Spot | null = null, bestD = reach;
90+ for (const s of this.spots) {
91+ const d = Math.hypot(x - s.r * Math.cos(s.phi), y - s.r * Math.sin(s.phi));
92+ if (d < bestD) {
93+ bestD = d;
94+ best = s;
95+ }
96+ }
97+ return best;
98+ }
99+
100+ private markerRadius(weight: number): number {
101+ const fs = Math.max(10, this.canvas.width / 40);
102+ return fs * 0.8 * Math.min(1.6, Math.pow(Math.max(weight, 0.05), 0.3));
103+ }
104+
105+ private frame() {
106+ const R = this.profile?.radius ?? 0.2286;
107+ const w = this.canvas.width;
108+ return { cx: w / 2, cy: w / 2, scale: (0.46 * w) / R };
109+ }
110+
111+ flash(x: number, y: number, velocity: number, kind: "stick" | "mallet") {
112+ this.flashes.push({ x, y, t0: performance.now(), velocity, kind });
113+ this.animate();
114+ }
115+
116+ private animate() {
117+ if (this.raf) return;
118+ const tick = () => {
119+ this.raf = 0;
120+ this.draw();
121+ const now = performance.now();
122+ this.flashes = this.flashes.filter((f) => now - f.t0 < 600);
123+ if (this.flashes.length) this.raf = requestAnimationFrame(tick);
124+ };
125+ this.raf = requestAnimationFrame(tick);
126+ }
127+
128+ draw() {
129+ const { ctx, canvas } = this;
130+ const w = canvas.width;
131+ ctx.clearRect(0, 0, w, w);
132+ if (!this.profile) return;
133+ const p = this.profile;
134+ const { cx, cy, scale } = this.frame();
135+ const toX = (x: number) => cx + x * scale, toY = (y: number) => cy - y * scale;
136+ // body
137+ const grad = ctx.createRadialGradient(cx - 0.2 * w, cy - 0.2 * w, 0.05 * w, cx, cy, 0.5 * w);
138+ grad.addColorStop(0, "#e8c97a");
139+ grad.addColorStop(0.6, "#b8892f");
140+ grad.addColorStop(1, "#7a5a1c");
141+ ctx.fillStyle = grad;
142+ ctx.beginPath();
143+ ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI);
144+ ctx.fill();
145+ ctx.strokeStyle = "rgba(0,0,0,0.35)";
146+ ctx.lineWidth = Math.max(1, w / 400);
147+ ctx.stroke();
148+ for (const r of [p.bell_shoulder_radius, p.bell_top_radius]) {
149+ ctx.beginPath();
150+ ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
151+ ctx.stroke();
152+ }
153+ // lathe lines
154+ ctx.strokeStyle = "rgba(0,0,0,0.08)";
155+ for (let r = p.bell_shoulder_radius + 0.01; r < p.radius; r += 0.012) {
156+ ctx.beginPath();
157+ ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
158+ ctx.stroke();
159+ }
160+ // spots
161+ const fs = Math.max(10, w / 40);
162+ const rowFill = ["rgba(30,70,150,0.62)", "rgba(20,110,110,0.62)", "rgba(130,60,140,0.62)", "rgba(170,110,20,0.72)"];
163+ ctx.font = `${fs}px system-ui, sans-serif`;
164+ ctx.textAlign = "center";
165+ ctx.textBaseline = "middle";
166+ for (const s of this.spots) {
167+ const x = toX(s.r * Math.cos(s.phi)), y = toY(s.r * Math.sin(s.phi));
168+ const rad = this.markerRadius(s.weight);
169+ ctx.beginPath();
170+ ctx.arc(x, y, rad, 0, 2 * Math.PI);
171+ ctx.fillStyle = rowFill[s.row % rowFill.length];
172+ ctx.fill();
173+ if (Math.abs(s.weight - 1) > 1e-6) {
174+ ctx.strokeStyle = s.weight > 1 ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.45)";
175+ ctx.lineWidth = Math.max(1, w / 500);
176+ ctx.stroke();
177+ }
178+ ctx.fillStyle = "#fff";
179+ ctx.font = `${s.label.length > 1 ? fs * 0.62 : fs}px system-ui, sans-serif`;
180+ ctx.fillText(s.label, x, y);
181+ ctx.font = `${fs * 0.5}px system-ui, sans-serif`;
182+ ctx.fillStyle = "rgba(0,0,0,0.75)";
183+ ctx.fillText(s.velocity.toFixed(1), x, y + rad + fs * 0.42);
184+ }
185+ // flashes
186+ const now = performance.now();
187+ for (const f of this.flashes) {
188+ const a = Math.min(1, (now - f.t0) / 600);
189+ const rad = (0.02 + 0.06 * a) * scale * (0.5 + f.velocity / 10);
190+ ctx.beginPath();
191+ ctx.arc(toX(f.x), toY(f.y), rad, 0, 2 * Math.PI);
192+ ctx.strokeStyle = f.kind === "stick" ? `rgba(60,120,255,${1 - a})` : `rgba(255,80,100,${1 - a})`;
193+ ctx.lineWidth = Math.max(2, w / 150) * (1 - a) + 1;
194+ ctx.stroke();
195+ ctx.beginPath();
196+ ctx.arc(toX(f.x), toY(f.y), Math.max(2, w / 120), 0, 2 * Math.PI);
197+ ctx.fillStyle = `rgba(255,255,255,${1 - a})`;
198+ ctx.fill();
199+ }
200+ // axes hint
201+ ctx.fillStyle = "rgba(0,0,0,0.5)";
202+ ctx.font = `${fs * 0.8}px system-ui, sans-serif`;
203+ ctx.textAlign = "left";
204+ ctx.fillText("x", toX(p.radius) + fs * 0.3, cy);
205+ ctx.textAlign = "center";
206+ ctx.fillText("y", cx, toY(p.radius) - fs * 0.6);
207+ }
208+}
src/worklet-globals.d.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+// Globals of the AudioWorkletGlobalScope that lib.dom does not declare.
2+declare const sampleRate: number;
3+declare const currentFrame: number;
4+declare const currentTime: number;
5+
6+declare class AudioWorkletProcessor {
7+ readonly port: MessagePort;
8+ constructor();
9+ process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
10+}
11+
12+declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void;
src/worklet.tsadded+460−0View file
@@ -0,0 +1,460 @@
1+/** AudioWorklet processor: a bank of damped modal resonators driven by rigid exciters through a
2+ * power-law contact law, with the listener pressure as output.
3+ *
4+ * Mode m obeys a'' + 2 sigma a' + omega0^2 a = f_m with f_m = sum_p phi_m(p) (-F_p), the modal
5+ * force of the contact springs at the patch points p. Its state is a complex number z with
6+ * a = Im(z) and dz/dt = lambda z + f_m / omega_d, lambda = -sigma + i omega_d. One sample with
7+ * the force held constant is z <- P z + b f_m with P = exp(lambda dt) and
8+ * b = (P - 1) / (lambda omega_d), exact for piecewise-constant forcing. The real acceleration is
9+ * Im(lambda^2 z) + f_m, so the listener pressure with the complex per-mode gain G_m (Rayleigh
10+ * sum, delays folded into a phase at the mode frequency) is
11+ * p = sum_m Im(G_m lambda_m^2 z_m) + Re(G_m) f_m .
12+ *
13+ * Contact is implicit: the force of a sample is the one that satisfies the contact law at the
14+ * end of that sample, F_p = k max(w_p + o_p - z_s, 0)^alpha with w_p and z_s advanced by the
15+ * force itself. The surface under the patch responds to the step's forces through the P x P
16+ * compliance c_pq = sum_m phi_m(p) phi_m(q) Im(b_m), and the exciter through dt^2 / m, so the
17+ * end-of-step penetration is affine in F and the small nonlinear system is solved by Newton
18+ * iteration. This keeps the bank at one step per sample for any contact stiffness, where an
19+ * explicit coupling would need sub-steps. Between hits the bank runs mode-outer over the block.
20+ */
21+
22+interface InitMessage {
23+ type: "init";
24+ freq: Float64Array;
25+ sigma: Float64Array;
26+ gain: Float32Array; // (Nm, 2) complex listener gain, re/im interleaved
27+}
28+
29+/** New frequencies and decay rates for the same modes, keeping the ringing state: this is what
30+ * the design panel sends while damping or material sliders move. */
31+interface ParamsMessage {
32+ type: "params";
33+ freq: Float64Array;
34+ sigma: Float64Array;
35+}
36+
37+interface GainMessage {
38+ type: "gain";
39+ gain: Float32Array;
40+}
41+
42+interface HitMessage {
43+ type: "hit";
44+ id: number;
45+ phi: Float32Array; // (P, Nm) point-major mode values at the patch points
46+ offsets: Float32Array; // (P,) surface height minus exciter face plane
47+ kPoint: number; // contact stiffness per point [N/m^alpha]
48+ alpha: number;
49+ mass: number;
50+ gap: number;
51+ velocity: number; // approach speed, positive
52+}
53+
54+type InMessage = InitMessage | ParamsMessage | GainMessage | HitMessage | { type: "reset" } | { type: "capture"; on: boolean };
55+
56+interface Exciter {
57+ id: number;
58+ phi: Float32Array;
59+ offsets: Float32Array;
60+ nPoints: number;
61+ kPoint: number;
62+ alpha: number;
63+ mass: number;
64+ gap: number;
65+ velocity: number;
66+ released: boolean;
67+ zs: number;
68+ vs: number;
69+ startFrame: number;
70+ peakForce: number;
71+ contactSamples: number;
72+ newtonIters: number;
73+ touched: boolean;
74+ silent: boolean; // warm-up exciter: no messages
75+ wp: Float64Array; // free end-of-step surface height under the points (with offsets)
76+ Fp: Float64Array; // current forces
77+ c: Float64Array; // (P, P) one-step compliance of the surface
78+ J: Float64Array; // Newton work: (P, P)
79+ g: Float64Array;
80+ dF: Float64Array;
81+}
82+
83+const HOLD = 1e3;
84+const now: () => number = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
85+const RELEASE_CLEARANCE = 2e-3; // exciter is dropped once it has rebounded this far above the surface
86+const NEWTON_MAX = 12;
87+
88+class CymbalModalProcessor extends AudioWorkletProcessor {
89+ private nModes = 0;
90+ private zr = new Float64Array(0);
91+ private zi = new Float64Array(0);
92+ private lamR = new Float64Array(0);
93+ private lamI = new Float64Array(0);
94+ private pr = new Float64Array(0); // one-sample pole
95+ private pi = new Float64Array(0);
96+ private br = new Float64Array(0); // one-sample forcing coefficient
97+ private bi = new Float64Array(0);
98+ private cr = new Float64Array(0); // output coefficient C = G lambda^2
99+ private ci = new Float64Array(0);
100+ private dr = new Float64Array(0); // direct term Re(G)
101+ private f = new Float64Array(0);
102+ private exciters: Exciter[] = [];
103+ private capture = false;
104+ private captureBuf: Float32Array[] = [];
105+ private loadAcc = 0;
106+ private loadBlocks = 0;
107+ private lastLoadReport = 0;
108+ private lastGain: Float32Array<ArrayBufferLike> = new Float32Array(0);
109+
110+ constructor() {
111+ super();
112+ this.port.onmessage = (ev: MessageEvent<InMessage>) => this.onMessage(ev.data);
113+ }
114+
115+ private onMessage(msg: InMessage) {
116+ switch (msg.type) {
117+ case "init":
118+ this.init(msg);
119+ break;
120+ case "params":
121+ this.setParams(msg.freq, msg.sigma);
122+ break;
123+ case "gain":
124+ this.setGain(msg.gain);
125+ break;
126+ case "hit":
127+ this.addHit(msg);
128+ break;
129+ case "reset":
130+ this.zr.fill(0);
131+ this.zi.fill(0);
132+ this.exciters = [];
133+ break;
134+ case "capture":
135+ this.capture = msg.on;
136+ if (!msg.on) {
137+ this.port.postMessage({ type: "captured", chunks: this.captureBuf });
138+ this.captureBuf = [];
139+ }
140+ break;
141+ }
142+ }
143+
144+ private init(msg: InitMessage) {
145+ const Nm = msg.freq.length;
146+ this.nModes = Nm;
147+ this.zr = new Float64Array(Nm);
148+ this.zi = new Float64Array(Nm);
149+ this.lamR = new Float64Array(Nm);
150+ this.lamI = new Float64Array(Nm);
151+ this.pr = new Float64Array(Nm);
152+ this.pi = new Float64Array(Nm);
153+ this.br = new Float64Array(Nm);
154+ this.bi = new Float64Array(Nm);
155+ this.cr = new Float64Array(Nm);
156+ this.ci = new Float64Array(Nm);
157+ this.dr = new Float64Array(Nm);
158+ this.f = new Float64Array(Nm);
159+ this.lastGain = msg.gain;
160+ this.setParams(msg.freq, msg.sigma);
161+ this.exciters = [];
162+ this.warmUp();
163+ }
164+
165+ /** Poles and forcing coefficients of every mode; the ringing state is left alone. */
166+ private setParams(freq: Float64Array, sigma: Float64Array) {
167+ const Nm = this.nModes;
168+ if (freq.length !== Nm) return;
169+ const dt = 1 / sampleRate;
170+ for (let m = 0; m < Nm; m++) {
171+ const w0 = 2 * Math.PI * freq[m];
172+ const s = Math.min(sigma[m], 0.999 * w0);
173+ const wd = Math.sqrt(Math.max(w0 * w0 - s * s, 1e-6));
174+ const lr = -s, li = wd;
175+ this.lamR[m] = lr;
176+ this.lamI[m] = li;
177+ const e = Math.exp(lr * dt);
178+ const Pr = e * Math.cos(li * dt), Pi = e * Math.sin(li * dt);
179+ this.pr[m] = Pr;
180+ this.pi[m] = Pi;
181+ // b = (P - 1) / (lambda omega_d)
182+ const nr = Pr - 1, ni = Pi;
183+ const den = wd * (lr * lr + li * li);
184+ this.br[m] = (nr * lr + ni * li) / den;
185+ this.bi[m] = (ni * lr - nr * li) / den;
186+ }
187+ this.setGain(this.lastGain);
188+ }
189+
190+ /** Run realistic fake hits through the contact code path so that the JIT has compiled and
191+ * optimised it before the first real hit (the first contact blocks were otherwise several
192+ * times over the deadline). Two hits (a one-point soft one and a seven-point stiff one with
193+ * staggered offsets) contact, rebound and are dropped, then the bank rings freely. */
194+ private warmUp() {
195+ const Nm = this.nModes;
196+ const scratch = new Float32Array(128);
197+ const fake = (P: number, kPoint: number, alpha: number, mass: number, velocity: number) => {
198+ const phi = new Float32Array(P * Nm), offsets = new Float32Array(P);
199+ for (let p = 0; p < P; p++) {
200+ for (let m = 0; m < Nm; m++) phi[p * Nm + m] = 0.7 * Math.sin(0.37 * m + 1.3 * p) * Math.cos(0.11 * m);
201+ offsets[p] = 1e-4 * (p - 0.5 * (P - 1));
202+ }
203+ this.addHit({ type: "hit", id: -1, phi, offsets, kPoint, alpha, mass, gap: 1e-4, velocity });
204+ this.exciters[this.exciters.length - 1].silent = true;
205+ };
206+ fake(1, 1.8e8, 2.5, 0.3, 8);
207+ fake(7, 5e8 / 7, 1.5, 0.03, 7);
208+ for (let k = 0; k < 80; k++) {
209+ this.renderBlock(scratch);
210+ if (k === 40) fake(7, 5e8 / 7, 1.5, 0.03, 5);
211+ }
212+ this.exciters = [];
213+ this.zr.fill(0);
214+ this.zi.fill(0);
215+ }
216+
217+ private setGain(gain: Float32Array) {
218+ const Nm = this.nModes;
219+ this.lastGain = gain;
220+ if (gain.length < 2 * Nm) return;
221+ for (let m = 0; m < Nm; m++) {
222+ const gr = gain[2 * m], gi = gain[2 * m + 1];
223+ const lr = this.lamR[m], li = this.lamI[m];
224+ const l2r = lr * lr - li * li, l2i = 2 * lr * li;
225+ this.cr[m] = gr * l2r - gi * l2i;
226+ this.ci[m] = gr * l2i + gi * l2r;
227+ this.dr[m] = gr;
228+ }
229+ }
230+
231+ private addHit(msg: HitMessage) {
232+ const P = msg.offsets.length, Nm = this.nModes;
233+ if (msg.phi.length !== P * Nm) return;
234+ // one-step compliance c_pq = sum_m phi_m(p) phi_m(q) Im(b_m)
235+ const c = new Float64Array(P * P);
236+ const phi = msg.phi, bi = this.bi;
237+ for (let p = 0; p < P; p++) {
238+ for (let q = p; q < P; q++) {
239+ let s = 0;
240+ const op = p * Nm, oq = q * Nm;
241+ for (let m = 0; m < Nm; m++) s += bi[m] * phi[op + m] * phi[oq + m];
242+ c[p * P + q] = s;
243+ c[q * P + p] = s;
244+ }
245+ }
246+ this.exciters.push({
247+ id: msg.id, phi, offsets: msg.offsets, nPoints: P, kPoint: msg.kPoint, alpha: msg.alpha, mass: msg.mass,
248+ gap: msg.gap, velocity: msg.velocity, released: false, zs: HOLD, vs: 0, startFrame: 0, peakForce: 0,
249+ contactSamples: 0, newtonIters: 0, touched: false, silent: false, wp: new Float64Array(P), Fp: new Float64Array(P), c,
250+ J: new Float64Array(P * P), g: new Float64Array(P), dF: new Float64Array(P),
251+ });
252+ }
253+
254+ /** Surface height (with offsets) under the patch points from the current state; returns the max. */
255+ private surface(ex: Exciter): number {
256+ const Nm = this.nModes, zi = this.zi, phi = ex.phi, P = ex.nPoints, wp = ex.wp;
257+ let top = -Infinity;
258+ for (let p = 0; p < P; p++) {
259+ const o = p * Nm;
260+ let s = 0;
261+ for (let m = 0; m < Nm; m++) s += phi[o + m] * zi[m];
262+ s += ex.offsets[p];
263+ wp[p] = s;
264+ if (s > top) top = s;
265+ }
266+ return top;
267+ }
268+
269+ /** Implicit contact forces for this sample given the free end-of-step surface heights ex.wp. */
270+ private solveContact(ex: Exciter, dt: number): number {
271+ const P = ex.nPoints, c = ex.c, F = ex.Fp, J = ex.J, g = ex.g, dF = ex.dF;
272+ const base = ex.zs + dt * ex.vs; // exciter face at the end of the step without contact
273+ const em = dt * dt / ex.mass;
274+ const k = ex.kPoint, a = ex.alpha;
275+ // quick exit: no point can touch even with zero force
276+ let touch = false;
277+ for (let p = 0; p < P; p++) if (ex.wp[p] > base) touch = true;
278+ if (!touch) {
279+ F.fill(0);
280+ return 0;
281+ }
282+ for (let it = 0; it < NEWTON_MAX; it++) {
283+ ex.newtonIters++;
284+ let Ftot = 0;
285+ for (let q = 0; q < P; q++) Ftot += F[q];
286+ let worst = 0;
287+ for (let p = 0; p < P; p++) {
288+ let d = ex.wp[p] - base - em * Ftot;
289+ for (let q = 0; q < P; q++) d -= c[p * P + q] * F[q];
290+ if (d > 0) {
291+ const dm1 = Math.pow(d, a - 1);
292+ g[p] = F[p] - k * dm1 * d;
293+ const s = k * a * dm1;
294+ for (let q = 0; q < P; q++) J[p * P + q] = (p === q ? 1 : 0) + s * (c[p * P + q] + em);
295+ } else {
296+ g[p] = F[p];
297+ for (let q = 0; q < P; q++) J[p * P + q] = p === q ? 1 : 0;
298+ }
299+ }
300+ solveSmall(J, g, dF, P);
301+ for (let p = 0; p < P; p++) {
302+ F[p] = Math.max(0, F[p] - dF[p]);
303+ worst = Math.max(worst, Math.abs(dF[p]));
304+ }
305+ let scale = 1;
306+ for (let p = 0; p < P; p++) scale = Math.max(scale, F[p]);
307+ if (worst < 1e-7 * scale) break;
308+ }
309+ let Ftot = 0;
310+ for (let p = 0; p < P; p++) Ftot += F[p];
311+ return Ftot;
312+ }
313+
314+ process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
315+ const out = outputs[0][0];
316+ if (!out || this.nModes === 0) return true;
317+ const t0 = now();
318+ this.renderBlock(out);
319+ if (this.capture) this.captureBuf.push(Float32Array.from(out));
320+ const cost = now() - t0;
321+ this.loadAcc += cost / (1000 * out.length / sampleRate);
322+ this.loadBlocks++;
323+ if (currentFrame - this.lastLoadReport >= sampleRate / 4) {
324+ this.port.postMessage({ type: "load", mean: this.loadAcc / this.loadBlocks, frame: currentFrame });
325+ this.loadAcc = 0;
326+ this.loadBlocks = 0;
327+ this.lastLoadReport = currentFrame;
328+ }
329+ return true;
330+ }
331+
332+ private renderBlock(out: Float32Array): void {
333+ const B = out.length;
334+ const Nm = this.nModes;
335+ const zr = this.zr, zi = this.zi, cr = this.cr, ci = this.ci, pr = this.pr, pi = this.pi;
336+ const dt = 1 / sampleRate;
337+
338+ // release any exciter that is waiting: place it just above the current surface
339+ for (const ex of this.exciters) {
340+ if (!ex.released) {
341+ ex.zs = this.surface(ex) + ex.gap;
342+ ex.vs = -ex.velocity;
343+ ex.released = true;
344+ ex.startFrame = currentFrame;
345+ if (!ex.silent) this.port.postMessage({ type: "hitStarted", id: ex.id, frame: currentFrame, time: currentFrame / sampleRate });
346+ }
347+ }
348+
349+ if (this.exciters.length === 0) {
350+ // free ringing: mode-outer over the block
351+ out.fill(0);
352+ for (let m = 0; m < Nm; m++) {
353+ let r = zr[m], i = zi[m];
354+ const a = pr[m], b = pi[m], c = cr[m], d = ci[m];
355+ for (let n = 0; n < B; n++) {
356+ const r2 = a * r - b * i;
357+ i = a * i + b * r;
358+ r = r2;
359+ out[n] += c * i + d * r;
360+ }
361+ zr[m] = r;
362+ zi[m] = i;
363+ }
364+ } else {
365+ const f = this.f, dr = this.dr, br = this.br, bi = this.bi;
366+ const exs = this.exciters;
367+ for (let n = 0; n < B; n++) {
368+ // free step of the bank
369+ for (let m = 0; m < Nm; m++) {
370+ const r = zr[m], i = zi[m];
371+ zr[m] = pr[m] * r - pi[m] * i;
372+ zi[m] = pr[m] * i + pi[m] * r;
373+ }
374+ // free end-of-step surface under every patch, implicit contact forces, exciter update
375+ let any = false;
376+ for (let e = 0; e < exs.length; e++) {
377+ const ex = exs[e];
378+ this.surface(ex);
379+ const F = this.solveContact(ex, dt);
380+ if (F > 0) {
381+ any = true;
382+ ex.touched = true;
383+ ex.contactSamples++;
384+ if (F > ex.peakForce) ex.peakForce = F;
385+ }
386+ ex.vs += dt * F / ex.mass;
387+ ex.zs += dt * ex.vs;
388+ }
389+ // modal forces, forced part of the step, output
390+ let y = 0;
391+ if (any) {
392+ f.fill(0);
393+ for (let e = 0; e < exs.length; e++) {
394+ const ex = exs[e], P = ex.nPoints, phi = ex.phi, F = ex.Fp;
395+ for (let p = 0; p < P; p++) {
396+ const Fp = F[p];
397+ if (Fp === 0) continue;
398+ const o = p * Nm;
399+ for (let m = 0; m < Nm; m++) f[m] -= Fp * phi[o + m];
400+ }
401+ }
402+ for (let m = 0; m < Nm; m++) {
403+ const fm = f[m];
404+ const r = zr[m] + br[m] * fm, i = zi[m] + bi[m] * fm;
405+ zr[m] = r;
406+ zi[m] = i;
407+ y += cr[m] * i + ci[m] * r + dr[m] * fm;
408+ }
409+ } else {
410+ for (let m = 0; m < Nm; m++) y += cr[m] * zi[m] + ci[m] * zr[m];
411+ }
412+ out[n] = y;
413+ }
414+ // drop exciters that have rebounded clear of the surface
415+ this.exciters = exs.filter((ex) => {
416+ const top = this.surface(ex);
417+ const done = ex.touched && ex.vs > 0 && ex.zs - top > RELEASE_CLEARANCE;
418+ const lost = !ex.touched && currentFrame - ex.startFrame > sampleRate * 0.1;
419+ if (done || lost) {
420+ if (!ex.silent) this.port.postMessage({
421+ type: "hitDone", id: ex.id, peakForce: ex.peakForce, contactTime: ex.contactSamples * dt, rebound: ex.vs,
422+ newtonIters: ex.newtonIters, contactSamples: ex.contactSamples,
423+ });
424+ return false;
425+ }
426+ return true;
427+ });
428+ }
429+ }
430+}
431+
432+/** Solve A x = b for a small dense system (row-major A, n <= 16) by Gaussian elimination with
433+ * partial pivoting; A and b are destroyed. */
434+function solveSmall(A: Float64Array, b: Float64Array, x: Float64Array, n: number): void {
435+ for (let c = 0; c < n; c++) {
436+ let piv = c, best = Math.abs(A[c * n + c]);
437+ for (let r = c + 1; r < n; r++) {
438+ const v = Math.abs(A[r * n + c]);
439+ if (v > best) { best = v; piv = r; }
440+ }
441+ if (piv !== c) {
442+ for (let k = 0; k < n; k++) { const t = A[c * n + k]; A[c * n + k] = A[piv * n + k]; A[piv * n + k] = t; }
443+ const t = b[c]; b[c] = b[piv]; b[piv] = t;
444+ }
445+ const d = A[c * n + c] || 1e-300;
446+ for (let r = c + 1; r < n; r++) {
447+ const fct = A[r * n + c] / d;
448+ if (fct === 0) continue;
449+ for (let k = c; k < n; k++) A[r * n + k] -= fct * A[c * n + k];
450+ b[r] -= fct * b[c];
451+ }
452+ }
453+ for (let r = n - 1; r >= 0; r--) {
454+ let s = b[r];
455+ for (let k = r + 1; k < n; k++) s -= A[r * n + k] * x[k];
456+ x[r] = s / (A[r * n + r] || 1e-300);
457+ }
458+}
459+
460+registerProcessor("cymbal-modal", CymbalModalProcessor);
tests/compare.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+// Run the browser precompute pipeline in node and compare its modal frequencies with a reference
2+// file made by tools/make_modes_reference.py (same element size, --condense-rotations, --no-imperfections).
3+// node tests/compare.ts <reference.bin> [elementSize]
4+import { readFileSync } from "node:fs";
5+import { parseModes } from "../src/modes.ts";
6+import { parseConfigYaml } from "../src/config/yaml.ts";
7+import { PRESETS } from "../src/config/presets.ts";
8+import { computeModes } from "../src/precompute/pipeline.ts";
9+
10+const refPath = process.argv[2];
11+const es = Number(process.argv[3] ?? "0.010");
12+const ref = parseModes(readFileSync(refPath).buffer as ArrayBuffer);
13+const { config } = parseConfigYaml(PRESETS[0].yaml);
14+if (!config) throw new Error("preset did not parse");
15+config.imperfections.n_dents = 0; config.imperfections.rim_warp = 0;
16+config.imperfections.shoulder_ovality = 0; config.imperfections.bell_offset = 0;
17+const t0 = performance.now();
18+let lastPhase = "";
19+const data = computeModes(config, { elementSize: es, fmax: ref.meta.fmax }, (phase, frac) => {
20+ if (phase !== lastPhase) { lastPhase = phase; console.log(`[${((performance.now() - t0) / 1000).toFixed(1)} s] ${phase}`); }
21+ void frac;
22+});
23+console.log(`pipeline: ${((performance.now() - t0) / 1000).toFixed(1)} s; nodes ${data.nNodes} (ref ${ref.nNodes}), modes ${data.nModes} (ref ${ref.nModes})`);
24+const n = Math.min(data.nModes, ref.nModes);
25+let worst = 0, worstIdx = 0;
26+for (let m = 0; m < n; m++) {
27+ const rel = Math.abs(data.freq[m] - ref.freq[m]) / ref.freq[m];
28+ if (rel > worst) { worst = rel; worstIdx = m; }
29+}
30+console.log(`frequency: max relative difference ${worst.toExponential(2)} at mode ${worstIdx} (${ref.freq[worstIdx].toFixed(1)} Hz)`);
31+console.log("first 10 (browser | reference):", Array.from({ length: 10 }, (_, m) => `${data.freq[m].toFixed(2)}|${ref.freq[m].toFixed(2)}`).join(" "));
32+let sw = 0, si = 0;
33+for (let m = 0; m < n; m++) { const rel = Math.abs(data.dampQ[m] - ref.dampQ[m]) / Math.max(ref.dampQ[m], 1e-12); if (rel > sw) { sw = rel; si = m; } }
34+console.log(`sigma1 quadratic form: max relative difference ${sw.toExponential(2)} at mode ${si} (${data.dampQ[si].toFixed(3)} vs ${ref.dampQ[si].toFixed(3)})`);
35+// gains: compare magnitudes (sign of a mode shape is arbitrary)
36+let gw = 0, gi = 0;
37+for (let m = 0; m < n; m++) {
38+ const a = Math.hypot(data.gainFront[2 * m], data.gainFront[2 * m + 1]), b = Math.hypot(ref.gainFront[2 * m], ref.gainFront[2 * m + 1]);
39+ const rel = Math.abs(a - b) / Math.max(b, 1e-30);
40+ if (rel > gw) { gw = rel; gi = m; }
41+}
42+console.log(`|gain_front|: max relative difference ${gw.toExponential(2)} at mode ${gi}`);
43+// mode shape at one point (up to sign)
44+const node = Math.floor(data.nNodes / 2);
45+let pw = 0;
46+for (let m = 0; m < Math.min(n, 200); m++) {
47+ const a = data.phiQ[node * data.nModes + m] * data.phiScale[m], b = ref.phiQ[node * ref.nModes + m] * ref.phiScale[m];
48+ pw = Math.max(pw, Math.min(Math.abs(a - b), Math.abs(a + b)) / (Math.abs(b) + 1e-3 * Math.abs(a) + 1e-12));
49+}
50+console.log(`mode shape at node ${node}, first 200 modes: max |diff|/|ref| ${pw.toExponential(2)} (degenerate pairs may mix)`);
tests/smoke.tsadded+129−0View file
@@ -0,0 +1,129 @@
1+// Self-contained check that the whole chain works: precompute a coarse cymbal, round-trip the
2+// modes file, apply the live material and damping relations, and render a hit through the real
3+// AudioWorklet processor. No fixtures and no network; this is what CI runs.
4+import assert from "node:assert/strict";
5+import { test } from "node:test";
6+import { PRESETS } from "../src/config/presets.ts";
7+import { parseConfigYaml } from "../src/config/yaml.ts";
8+import { derive } from "../src/derived.ts";
9+import { ALL_FIELDS, getValue, setValue } from "../src/design.ts";
10+import { exportYaml } from "../src/hits.ts";
11+import { parseModes, serializeModes, type ModesData } from "../src/modes.ts";
12+import { buildHitPatch } from "../src/patch.ts";
13+import { computeModes } from "../src/precompute/pipeline.ts";
14+
15+const base = parseConfigYaml(PRESETS[0].yaml).config!;
16+// a coarse mesh keeps the dense eigensolve to a second or so
17+const config = structuredClone(base);
18+config.mesh.element_size = 0.02;
19+config.imperfections.n_dents = 40;
20+config.imperfections.dent_sigma = 0.02;
21+
22+let modes: ModesData;
23+
24+test("precompute produces a plausible cymbal", () => {
25+ modes = computeModes(config, { elementSize: 0.02, fmax: 20000 }, () => {});
26+ assert.ok(modes.nModes > 50, `expected many modes, got ${modes.nModes}`);
27+ assert.ok(modes.nNodes > 300 && modes.nNodes < 700, `unexpected node count ${modes.nNodes}`);
28+ // an 18 inch bronze crash has its lowest modes near 60 Hz
29+ assert.ok(modes.freq[0] > 30 && modes.freq[0] < 150, `lowest mode at ${modes.freq[0]} Hz`);
30+ for (let m = 1; m < modes.nModes; m++) assert.ok(modes.freq[m] >= modes.freq[m - 1], "frequencies must ascend");
31+ assert.ok(modes.freq.every(Number.isFinite) && modes.dampQ.every((q) => Number.isFinite(q) && q >= 0));
32+ // the plate mass: rho * mean thickness * area, within a factor of two
33+ const rough = config.material.rho * 1.5e-3 * Math.PI * config.profile.radius ** 2;
34+ assert.ok(modes.meta.mass > 0.5 * rough && modes.meta.mass < 2 * rough, `mass ${modes.meta.mass} kg`);
35+});
36+
37+test("the modes file round-trips", () => {
38+ const again = parseModes(serializeModes(modes));
39+ assert.equal(again.nModes, modes.nModes);
40+ assert.equal(again.meta.E0, modes.meta.E0);
41+ assert.deepEqual(Array.from(again.freq.slice(0, 20)), Array.from(modes.freq.slice(0, 20)));
42+ assert.deepEqual(Array.from(again.phiQ.slice(0, 100)), Array.from(modes.phiQ.slice(0, 100)));
43+});
44+
45+test("the live material and damping relations are exact", () => {
46+ const changed = structuredClone(config);
47+ changed.material.E *= 0.78;
48+ changed.material.rho *= 1.35;
49+ changed.material.sigma0 = 2.7;
50+ changed.material.sigma1 = 3.1e-3;
51+ const live = derive(modes, changed);
52+ const fresh = computeModes(changed, { elementSize: 0.02, fmax: 20000 }, () => {});
53+ const n = Math.min(live.freq.length, fresh.freq.length);
54+ assert.ok(n > 50);
55+ for (let m = 0; m < n; m++) {
56+ assert.ok(Math.abs(live.freq[m] - fresh.freq[m]) / fresh.freq[m] < 1e-8, `mode ${m} frequency`);
57+ const s = changed.material.sigma0 + changed.material.sigma1 * fresh.dampQ[m];
58+ assert.ok(Math.abs(live.sigma[m] - s) / s < 1e-6, `mode ${m} decay rate`);
59+ }
60+ assert.ok(Math.abs(live.phiFactor - 1 / Math.sqrt(1.35)) < 1e-12);
61+});
62+
63+test("every design field reads and writes its configuration slot", () => {
64+ const cfg = structuredClone(config);
65+ for (const f of ALL_FIELDS) {
66+ const before = getValue(cfg, f);
67+ assert.ok(Number.isFinite(before), `${f.section}.${f.key} is not a number`);
68+ setValue(cfg, f, before + 1);
69+ assert.equal(getValue(cfg, f), before + 1, `${f.section}.${f.key} did not take a write`);
70+ setValue(cfg, f, before);
71+ }
72+});
73+
74+test("a hit through the worklet makes finite sound and rebounds", async () => {
75+ const g = globalThis as unknown as Record<string, unknown>;
76+ const sent: Record<string, unknown>[] = [];
77+ g.sampleRate = 44100;
78+ g.currentFrame = 0;
79+ let Proc: new () => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
80+ g.AudioWorkletProcessor = class {
81+ port = { onmessage: null, postMessage: (m: Record<string, unknown>) => sent.push(m) };
82+ };
83+ g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; };
84+ await import("../src/worklet.ts");
85+
86+ const der = derive(modes, config);
87+ const gain = new Float32Array(2 * modes.nModes);
88+ for (let i = 0; i < gain.length; i++) gain[i] = modes.gainFront[i] - modes.gainBack[i];
89+ const proc = new Proc!();
90+ proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } });
91+
92+ const ex = structuredClone(base.hits[0].exciter);
93+ const patch = buildHitPatch(modes, { ...ex, kind: "stick", mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4 }, 0.17, 0.3, 7, 7, der.phiFactor);
94+ assert.ok(patch, "the patch should land inside the mesh");
95+ proc.port.onmessage!({ data: { type: "hit", id: 1, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha, mass: patch.mass, gap: patch.gap, velocity: 7 } });
96+
97+ const B = 128, blocks = Math.ceil(0.25 * 44100 / B);
98+ let peak = 0;
99+ for (let b = 0; b < blocks; b++) {
100+ const out = [[new Float32Array(B)]];
101+ g.currentFrame = b * B;
102+ proc.process([], out);
103+ for (const x of out[0][0]) {
104+ assert.ok(Number.isFinite(x), "the output must stay finite");
105+ peak = Math.max(peak, Math.abs(x));
106+ }
107+ }
108+ assert.ok(peak > 0.05, `a 0.7 J stroke should be audible, peak was ${peak} Pa`);
109+ const done = sent.find((m) => m.type === "hitDone") as { peakForce: number; contactTime: number; rebound: number } | undefined;
110+ assert.ok(done, "the exciter should rebound and report");
111+ assert.ok(done.peakForce > 100, `peak force ${done.peakForce} N`);
112+ assert.ok(done.contactTime > 1e-4 && done.contactTime < 0.02, `contact ${done.contactTime} s`);
113+ assert.ok(done.rebound > 0 && done.rebound < 7, `rebound ${done.rebound} m/s`);
114+});
115+
116+test("the exported configuration is valid cymsim input", () => {
117+ const yaml = exportYaml(config, [
118+ { id: 1, time: 10.0, r: 0.17, phi: 0.3, velocity: 7, exciter: structuredClone(base.hits[0].exciter), source: "Q" },
119+ { id: 2, time: 10.4, r: 0.12, phi: 2.0, velocity: 3, exciter: structuredClone(base.hits[0].exciter), source: "A" },
120+ ], { leadIn: 0.05, tail: 3, prefix: "smoke" });
121+ const { config: back, issues } = parseConfigYaml(yaml);
122+ assert.deepEqual(issues, []);
123+ assert.ok(back);
124+ assert.equal(back.hits.length, 2);
125+ assert.ok(Math.abs(back.hits[0].time - 0.05) < 1e-9, "the first hit sits at the lead-in");
126+ assert.ok(Math.abs(back.hits[1].time - 0.45) < 1e-6, "spacing is preserved");
127+ assert.ok(Math.abs(back.numerics.duration - 3.45) < 1e-6, "the duration covers the tail");
128+ assert.equal(back.output.prefix, "smoke");
129+});
tests/transfer.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+// Pair-invariant comparison of two modes files: the transfer function from a point force to the
2+// listener pressure, |H(w)| = |sum_m G_m phi_m(p) (-w^2) / (w0^2 - w^2 + 2 i sigma w)|, on a
3+// frequency grid, and the profile of the frequency differences along the spectrum.
4+// node tests/transfer.ts a.bin b.bin
5+import { readFileSync } from "node:fs";
6+import { locate, modeValuesAt, parseModes, type ModesData } from "../src/modes.ts";
7+
8+const sigmaOf = (d: ModesData) => {
9+ const mat = (d.meta.config as any).material;
10+ return Float64Array.from(d.dampQ, (q) => mat.sigma0 + mat.sigma1 * q);
11+};
12+
13+const A = parseModes(readFileSync(process.argv[2]).buffer as ArrayBuffer);
14+const B = parseModes(readFileSync(process.argv[3]).buffer as ArrayBuffer);
15+const n = Math.min(A.nModes, B.nModes);
16+for (const [lo, hi] of [[0, 100], [100, 300], [300, 600], [600, 1000], [1000, n]]) {
17+ let worst = 0, mean = 0, cnt = 0;
18+ for (let m = lo; m < Math.min(hi, n); m++) { const r = Math.abs(A.freq[m] - B.freq[m]) / B.freq[m]; worst = Math.max(worst, r); mean += r; cnt++; }
19+ console.log(`modes ${lo}-${Math.min(hi, n)}: freq rel diff mean ${(mean / cnt).toExponential(2)} max ${worst.toExponential(2)}`);
20+}
21+function transfer(d: ModesData, px: number, py: number, freqs: number[]): number[] {
22+ const sig = sigmaOf(d);
23+ const loc = locate(d, px, py);
24+ if (!loc) throw new Error("point outside mesh");
25+ const phi = new Float32Array(d.nModes);
26+ modeValuesAt(d, loc.nodes, loc.weights, phi, 0);
27+ return freqs.map((f) => {
28+ const w = 2 * Math.PI * f;
29+ let re = 0, im = 0;
30+ for (let m = 0; m < d.nModes; m++) {
31+ const w0 = 2 * Math.PI * d.freq[m], s = sig[m];
32+ const dr = w0 * w0 - w * w, di = 2 * s * w;
33+ const den = dr * dr + di * di;
34+ const hr = dr / den, hi = -di / den; // 1 / (dr + i di)
35+ const g = -w * w * phi[m];
36+ const gr = (d.gainFront[2 * m] - d.gainBack[2 * m]) * g, gi = (d.gainFront[2 * m + 1] - d.gainBack[2 * m + 1]) * g;
37+ re += gr * hr - gi * hi; im += gr * hi + gi * hr;
38+ }
39+ return Math.hypot(re, im);
40+ });
41+}
42+const freqs = Array.from({ length: 60 }, (_, i) => 80 * Math.pow(15000 / 80, i / 59));
43+for (const [px, py] of [[0.137 * Math.cos(0.3), 0.137 * Math.sin(0.3)], [0.19 * Math.cos(2), 0.19 * Math.sin(2)]]) {
44+ const ha = transfer(A, px, py, freqs), hb = transfer(B, px, py, freqs);
45+ let worst = 0, mean = 0;
46+ for (let i = 0; i < freqs.length; i++) { const r = Math.abs(ha[i] - hb[i]) / hb[i]; worst = Math.max(worst, r); mean += r / freqs.length; }
47+ console.log(`|H| at (${px.toFixed(3)}, ${py.toFixed(3)}): mean rel diff ${mean.toExponential(2)}, max ${worst.toExponential(2)}`);
48+ console.log(" sample |H| (A|B) at 100, 1k, 5k, 12k Hz:", [0, 22, 38, 54].map((i) => `${ha[i].toExponential(3)}|${hb[i].toExponential(3)} @${freqs[i].toFixed(0)}`).join(" "));
49+}
tests/worklet_node.tsadded+78−0View file
@@ -0,0 +1,78 @@
1+// Run the AudioWorklet processor in node with stubbed globals: one mallet hit and one stick hit,
2+// half a second of output. Writes a raw float32 signal, a WAV and a JSON of the hit reports.
3+// node tests/worklet_node.ts <modes.bin> <out prefix> [seconds]
4+import { readFileSync, writeFileSync } from "node:fs";
5+import { parseModes } from "../src/modes.ts";
6+import { derive } from "../src/derived.ts";
7+import { parseConfigYaml } from "../src/config/yaml.ts";
8+import { buildHitPatch } from "../src/patch.ts";
9+import { defaultMallet, defaultStick } from "../src/config/schema.ts";
10+import { encodeWav } from "../src/hits.ts";
11+
12+const g = globalThis as unknown as Record<string, unknown>;
13+const messages: unknown[] = [];
14+g.sampleRate = 44100;
15+g.currentFrame = 0;
16+let Proc: new () => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
17+g.AudioWorkletProcessor = class { port = { onmessage: null, postMessage: (m: unknown) => messages.push(m) }; };
18+g.registerProcessor = (_name: string, ctor: typeof Proc) => { Proc = ctor; };
19+await import("../src/worklet.ts");
20+
21+const modes = parseModes(readFileSync(process.argv[2]).buffer as ArrayBuffer);
22+const prefix = process.argv[3], seconds = Number(process.argv[4] ?? "0.5");
23+const proc = new Proc!();
24+const gain = new Float32Array(2 * modes.nModes);
25+for (let i = 0; i < gain.length; i++) gain[i] = modes.gainFront[i] - modes.gainBack[i];
26+const cfg = parseConfigYaml(JSON.stringify(modes.meta.config)).config!;
27+const der = derive(modes, cfg);
28+proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } });
29+
30+const hitsToPlay = [
31+ { t: 0.0, kind: "mallet", r: 0.13716, phi: 0.3, v: 8, substeps: 2 },
32+ { t: 0.25, kind: "stick", r: 0.194, phi: 2.0, v: 7, substeps: 6 },
33+];
34+if (seconds > 0.6) hitsToPlay.push(
35+ { t: 0.5, kind: "mallet", r: 0.13716, phi: 1.3, v: 6, substeps: 2 },
36+ { t: 0.75, kind: "stick", r: 0.194, phi: 3.0, v: 5, substeps: 6 },
37+ { t: 1.0, kind: "mallet", r: 0.10, phi: -1.0, v: 9, substeps: 2 },
38+ { t: 1.25, kind: "stick", r: 0.18, phi: 0.5, v: 8, substeps: 6 },
39+);
40+const fs = 44100, B = 128, nBlocks = Math.ceil(seconds * fs / B);
41+const out = new Float32Array(nBlocks * B);
42+let id = 1;
43+const t0 = performance.now();
44+let maxBlockMs = 0;
45+let slow: string[] = [];
46+const runs = Number(process.env.RUNS ?? "1");
47+for (let run = 0; run < runs; run++) {
48+if (run > 0) { const cfg = parseConfigYaml(JSON.stringify(modes.meta.config)).config!;
49+const der = derive(modes, cfg);
50+proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain } }); slow = []; maxBlockMs = 0; }
51+for (let b = 0; b < nBlocks; b++) {
52+ for (const h of hitsToPlay) {
53+ if (Math.round(h.t * fs / B) === b) {
54+ const ex = h.kind === "stick" ? defaultStick() : defaultMallet();
55+ const patch = buildHitPatch(modes, ex, h.r, h.phi, h.v, h.kind === "mallet" ? Number(process.env.MALLET_POINTS ?? "1") : 7)!;
56+ proc.port.onmessage!({ data: { type: "hit", id: id++, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha, mass: patch.mass, gap: patch.gap, velocity: h.v } });
57+ }
58+ }
59+ const buf = [[new Float32Array(B)]];
60+ const tb = performance.now();
61+ proc.process([], buf);
62+ const cost = performance.now() - tb;
63+ if (cost > 2.9) slow.push(`block ${b} (t=${(b * B / fs * 1e3).toFixed(1)} ms): ${cost.toFixed(1)} ms`);
64+ maxBlockMs = Math.max(maxBlockMs, cost);
65+ out.set(buf[0][0], b * B);
66+ g.currentFrame = (b + 1) * B;
67+}
68+}
69+const wall = (performance.now() - t0) / 1000;
70+let peak = 0, finite = true;
71+for (const x of out) { if (!Number.isFinite(x)) finite = false; peak = Math.max(peak, Math.abs(x)); }
72+console.log(`rendered ${seconds} s in ${wall.toFixed(2)} s (${(100 * wall / seconds).toFixed(0)} % of real time), worst block ${maxBlockMs.toFixed(2)} ms of ${(1000 * B / fs).toFixed(2)} ms; finite ${finite}; peak ${peak.toFixed(3)} Pa`);
73+console.log("slow blocks:", slow.join("; ") || "none");
74+for (const m of messages) if ((m as { type: string }).type !== "load") console.log(JSON.stringify(m));
75+writeFileSync(prefix + ".f32", Buffer.from(out.buffer));
76+const wav = encodeWav(out, fs);
77+writeFileSync(prefix + ".wav", Buffer.from(await wav.arrayBuffer()));
78+writeFileSync(prefix + "_hits.json", JSON.stringify(hitsToPlay));
tools/make_modes_reference.pyadded+243−0View file
@@ -0,0 +1,243 @@
1+"""Reference implementation (Python, cymsim) of the modal precompute that cymplay does in the browser.
2+
3+Used only to validate the browser numbers; the app itself needs no Python.
4+
5+The in-plane DOFs (u, v) of the linearised shell are eliminated by static condensation, which
6+leaves a dense stiffness on the (w, theta_x, theta_y) DOFs. Its mass-normalised eigenmodes below
7+``--fmax`` give, for each mode m: the frequency f_m, the decay rate sigma_m (sigma0 plus the
8+projected sigma1 term), the complex listener gains (front Rayleigh sum and diffracted back sum,
9+with the element delays folded into a phase at f_m), and the transverse mode shape on the nodes,
10+quantised to int16 with one scale per mode. The mesh, the nodal surface height, the full config
11+and the exciter defaults are stored alongside so that the app can locate strike points, run the
12+contact law and write a config back out.
13+
14+Output format: magic ``CYMB``, a uint32 little-endian header length, a UTF-8 JSON header, then
15+the raw little-endian arrays at the byte offsets the header lists.
16+
17+Usage (on a machine with enough memory for a dense (3N)^2 float64 matrix, 1.5 GB at 6 mm):
18+ python make_modes.py ../cym2/examples/crash_mallet.yaml public/modes/crash_mallet.bin
19+"""
20+from __future__ import annotations
21+
22+import argparse
23+import dataclasses
24+import json
25+import struct
26+import sys
27+import time
28+
29+import numpy as np
30+import scipy.linalg as sla
31+import scipy.sparse as sp
32+import scipy.sparse.linalg as spla
33+
34+try:
35+ import shim # noqa: F401 (office machine: Python 3.9 compatibility, adds cym2/src to the path)
36+except ImportError:
37+ pass
38+
39+import jax
40+import jax.numpy as jnp
41+
42+jax.config.update("jax_enable_x64", True)
43+
44+from cymsim.config import MalletExciter, SimulationConfig, StickExciter # noqa: E402
45+from cymsim.elements import element_energy # noqa: E402
46+from cymsim.geometry import CymbalProfile # noqa: E402
47+from cymsim.mesh import disk_mesh # noqa: E402
48+from cymsim.radiation import RadiationModel # noqa: E402
49+from cymsim.shell import N_DOF, ShellModel # noqa: E402
50+
51+FORMAT_VERSION = 2
52+
53+
54+def log(msg):
55+ print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
56+
57+
58+def build(cfg: SimulationConfig, fmax: float, condense_rotations: bool = False):
59+ t0 = time.time()
60+ prof = CymbalProfile(cfg.profile, cfg.imperfections)
61+ mesh = disk_mesh(cfg.profile.radius, cfg.mesh.element_size, cfg.mesh.smoothing)
62+ sh = ShellModel(mesh, prof, cfg.material, cfg.numerics)
63+ N, E, tri = mesh.n_nodes, mesh.n_elements, mesh.tri
64+ log(f"mesh: {N} nodes, {E} elements; shell built in {time.time() - t0:.0f} s")
65+
66+ # ---- global tangent stiffness at rest from the element Hessians (float64)
67+ Kh = np.asarray(jax.vmap(jax.hessian(element_energy), in_axes=(0, 0, 0, 0, 0, 0, 0, 0, None))(
68+ jnp.zeros((E, 3, N_DOF)), jnp.asarray(mesh.Gx), jnp.asarray(mesh.Gy), jnp.asarray(mesh.area),
69+ jnp.asarray(sh.z0x), jnp.asarray(sh.z0y), jnp.asarray(sh.Cm_e), jnp.asarray(sh.Ke),
70+ sh.nl)).reshape(E, 15, 15)
71+ gi = (tri[:, :, None] * 5 + np.arange(5)[None, None, :]).reshape(E, 15)
72+ rows = np.repeat(gi, 15, axis=1).ravel()
73+ cols = np.tile(gi, (1, 15)).ravel()
74+ K = sp.coo_matrix((Kh.ravel(), (rows, cols)), shape=(5 * N, 5 * N)).tocsr()
75+ M = sh.M.ravel()
76+ log(f"assembled K: {K.nnz} nonzeros")
77+
78+ # ---- static condensation of the in-plane DOFs. Three in-plane DOFs are pinned to remove
79+ # the in-plane rigid motions; the condensed operator then has exactly three zero modes
80+ # (transverse translation and the two tilts), which we drop.
81+ if condense_rotations:
82+ ip = np.sort(np.concatenate([np.arange(N) * 5 + d for d in (0, 1, 3, 4)]))
83+ else:
84+ ip = np.sort(np.concatenate([np.arange(N) * 5, np.arange(N) * 5 + 1]))
85+ wb = np.setdiff1d(np.arange(5 * N), ip)
86+ # pinned in-plane DOFs u0, v0, v1 in the local numbering of ``ip``
87+ pins = [int(np.searchsorted(ip, g)) for g in (0, 1, 5 + 1)]
88+ Kuu = K[ip][:, ip].tolil()
89+ Kuw = K[ip][:, wb].toarray()
90+ Kww = K[wb][:, wb].toarray()
91+ for p in pins: # u0, v0, v1
92+ Kuu[p, :] = 0
93+ Kuu[:, p] = 0
94+ Kuu[p, p] = 1.0
95+ Kuw[p, :] = 0
96+ t0 = time.time()
97+ lu = spla.splu(Kuu.tocsc())
98+ X = lu.solve(Kuw) # (2N, 3N)
99+ S = Kww - Kuw.T @ X
100+ S = 0.5 * (S + S.T)
101+ del Kww, Kuw, X
102+ log(f"condensed stiffness {S.shape} in {time.time() - t0:.0f} s")
103+
104+ # ---- mass-normalised eigenmodes below fmax (with some margin, trimmed afterwards)
105+ Mw = M[wb]
106+ s = 1.0 / np.sqrt(Mw)
107+ S *= s[:, None]
108+ S *= s[None, :]
109+ t0 = time.time()
110+ lam, V = sla.eigh(S, subset_by_value=[-np.inf, (2 * np.pi * fmax) ** 2], driver="evr",
111+ overwrite_a=True, check_finite=False)
112+ del S
113+ f = np.sqrt(np.maximum(lam, 0)) / (2 * np.pi)
114+ keep = f > 1.0 # drop the rigid modes
115+ f, V = f[keep], V[:, keep]
116+ Phi = V * s[:, None] # Phi^T M Phi = I on the (w, thx, thy) DOFs
117+ Nm = len(f)
118+ log(f"eigh: {Nm} modes between {f[0]:.1f} Hz and {f[-1]:.0f} Hz in {time.time() - t0:.0f} s; "
119+ f"dropped {int((~keep).sum())} rigid modes")
120+ stride = 1 if condense_rotations else 3
121+ Phi_w = np.ascontiguousarray(Phi[0::stride, :]) # (N, Nm), the w rows
122+
123+ # ---- modal damping: sigma_m = sigma0 + sigma1 phi^T L phi with L = sum_e (rho h A)_e G^T G
124+ rhoA = cfg.material.rho * sh.h_e * mesh.area
125+ Lrows, Lcols, Lvals = [], [], []
126+ for i in range(3):
127+ for j in range(3):
128+ Lrows.append(tri[:, i]); Lcols.append(tri[:, j])
129+ Lvals.append(rhoA * (mesh.Gx[:, i] * mesh.Gx[:, j] + mesh.Gy[:, i] * mesh.Gy[:, j]))
130+ L = sp.coo_matrix((np.concatenate(Lvals), (np.concatenate(Lrows), np.concatenate(Lcols))),
131+ shape=(N, N)).tocsr()
132+ damp_q = np.einsum("nm,nm->m", Phi_w, L @ Phi_w)
133+ sigma = cfg.material.sigma0 + cfg.material.sigma1 * damp_q
134+ log(f"damping: sigma from {sigma.min():.2f} to {sigma.max():.1f} 1/s "
135+ f"(T60 {6.91 / sigma.max() * 1e3:.0f} ms at the top mode)")
136+
137+ # ---- listener gains: Rayleigh front sum and diffracted back sum per mode, with the element
138+ # delays as phases at the mode frequency. pressure = Re(G_m * complex acceleration).
139+ rad = RadiationModel(mesh, prof, sh.z0_node, cfg.listener, 1e-6, 1000, jnp.float64)
140+ phi_e = Phi_w[tri].mean(1) # (E, Nm) element-mean mode shape
141+ c = cfg.listener.c_air
142+ omega = 2 * np.pi * f
143+ G_front = np.einsum("em,e,em->m", phi_e, np.asarray(rad.coef_f),
144+ np.exp(-1j * np.outer(rad.r_front, omega) / c))
145+ G_back = np.einsum("em,e,em->m", phi_e, np.asarray(rad.coef_b),
146+ np.exp(-1j * np.outer(rad.r_back, omega) / c))
147+ log("radiation gains done")
148+
149+ # ---- quantise the mode shapes: int16 with one scale per mode
150+ scale = np.abs(Phi_w).max(0) / 32767.0
151+ Phi_q = np.round(Phi_w / scale[None, :]).astype(np.int16)
152+
153+ arrays = {
154+ "xy": mesh.xy.astype(np.float32), # (N, 2)
155+ "tri": tri.astype(np.int32), # (E, 3)
156+ "z0": sh.z0_node.astype(np.float32), # (N,)
157+ "h": sh.h_node.astype(np.float32), # (N,)
158+ "freq": f.astype(np.float64), # (Nm,)
159+ "damp_q": damp_q.astype(np.float64), # (Nm,) sigma = sigma0 + sigma1 * damp_q
160+ "gain_front": np.stack([G_front.real, G_front.imag], 1).astype(np.float32), # (Nm, 2)
161+ "gain_back": np.stack([G_back.real, G_back.imag], 1).astype(np.float32), # (Nm, 2)
162+ "phi_scale": scale.astype(np.float32), # (Nm,)
163+ "phi_q": Phi_q, # (N, Nm) int16, node-major
164+ }
165+ meta = {
166+ "format_version": FORMAT_VERSION,
167+ "n_nodes": int(N), "n_elements": int(E), "n_modes": int(Nm), "fmax": float(fmax),
168+ "radius": float(cfg.profile.radius),
169+ "E0": float(cfg.material.E), "rho0": float(cfg.material.rho),
170+ "mass": float(sh.mass),
171+ "config": cfg.to_dict(),
172+ "playback_element_size": float(cfg.mesh.element_size),
173+ "condense_rotations": bool(condense_rotations),
174+ "exciters": {"stick": dataclasses.asdict(StickExciter()),
175+ "mallet": dataclasses.asdict(MalletExciter())},
176+ "notes": "phi = phi_q * phi_scale[m] is the mass-normalised transverse mode shape on the "
177+ "nodes; modal EOM: a'' + 2 sigma a' + (2 pi f)^2 a = phi(p) . F; pressure = "
178+ "Re(sum_m G_m * complex acceleration_m), unbaffled = front - back.",
179+ }
180+ return meta, arrays
181+
182+
183+def write_bin(path: str, meta: dict, arrays: dict[str, np.ndarray]) -> None:
184+ offset = 0
185+ entries = {}
186+ blobs = []
187+ for name, a in arrays.items():
188+ a = np.ascontiguousarray(a)
189+ if a.dtype.byteorder == ">":
190+ a = a.astype(a.dtype.newbyteorder("<"))
191+ b = a.tobytes()
192+ entries[name] = {"dtype": a.dtype.name, "shape": list(a.shape), "offset": offset, "nbytes": len(b)}
193+ blobs.append(b)
194+ offset += len(b)
195+ header = json.dumps({**meta, "arrays": entries}).encode("utf-8")
196+ with open(path, "wb") as fh:
197+ fh.write(b"CYMB")
198+ fh.write(struct.pack("<I", len(header)))
199+ fh.write(header)
200+ for b in blobs:
201+ fh.write(b)
202+ log(f"wrote {path}: {(8 + len(header) + offset) / 1e6:.1f} MB")
203+
204+
205+def read_bin(path: str):
206+ """Reader for the same format (used by the validation script)."""
207+ with open(path, "rb") as fh:
208+ data = fh.read()
209+ assert data[:4] == b"CYMB", "not a cymplay modes file"
210+ (hlen,) = struct.unpack("<I", data[4:8])
211+ meta = json.loads(data[8:8 + hlen].decode("utf-8"))
212+ base = 8 + hlen
213+ arrays = {}
214+ for name, e in meta.pop("arrays").items():
215+ arrays[name] = np.frombuffer(data, dtype=np.dtype(e["dtype"]).newbyteorder("<"),
216+ count=int(np.prod(e["shape"])), offset=base + e["offset"]).reshape(e["shape"])
217+ return meta, arrays
218+
219+
220+def main(argv=None):
221+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
222+ ap.add_argument("config", help="cymsim YAML config")
223+ ap.add_argument("out", help="output .bin file")
224+ ap.add_argument("--fmax", type=float, default=20000.0, help="keep modes below this frequency [Hz]")
225+ ap.add_argument("--element-size", type=float, help="override mesh.element_size [m]")
226+ ap.add_argument("--condense-rotations", action="store_true",
227+ help="also condense theta_x, theta_y (w-only modes, as the browser does)")
228+ ap.add_argument("--no-imperfections", action="store_true",
229+ help="zero every seeded imperfection (deterministic geometry, comparable across RNGs)")
230+ args = ap.parse_args(argv)
231+ cfg = SimulationConfig.from_yaml(args.config)
232+ if args.element_size:
233+ cfg.mesh.element_size = args.element_size
234+ if args.no_imperfections:
235+ im = cfg.imperfections
236+ im.n_dents, im.rim_warp, im.shoulder_ovality, im.bell_offset = 0, 0.0, 0.0, 0.0
237+ cfg.validate()
238+ meta, arrays = build(cfg, args.fmax, args.condense_rotations)
239+ write_bin(args.out, meta, arrays)
240+
241+
242+if __name__ == "__main__":
243+ sys.exit(main())
tools/modal_reference.pyadded+181−0View file
@@ -0,0 +1,181 @@
1+"""Independent numpy implementation of the player's modal engine, for checking the worklet.
2+
3+Reads a modes file, builds the contact patches exactly as the app does (linear interpolation on
4+the mesh, best-fit face plane), and integrates the modal resonators with the same scheme: complex
5+state z per mode with a = Im z, exact pole per (sub-)step, zero-order-hold forcing, symplectic
6+Euler for the exciter, implicit (Newton) contact forces. Output is the unbaffled listener pressure at fs.
7+
8+ python modal_reference.py modes.bin hits.json out.f32 [--fs 44100] [--seconds 0.5]
9+"""
10+from __future__ import annotations
11+
12+import argparse
13+import json
14+import struct
15+
16+import numpy as np
17+
18+
19+def read_bin(path):
20+ data = open(path, "rb").read()
21+ assert data[:4] == b"CYMB"
22+ (hlen,) = struct.unpack("<I", data[4:8])
23+ meta = json.loads(data[8:8 + hlen].decode())
24+ base = 8 + hlen
25+ arrays = {}
26+ for name, e in meta.pop("arrays").items():
27+ arrays[name] = np.frombuffer(data, dtype=np.dtype(e["dtype"]).newbyteorder("<"),
28+ count=int(np.prod(e["shape"])), offset=base + e["offset"]).reshape(e["shape"])
29+ return meta, arrays
30+
31+
32+def locate(xy, tri, px, py):
33+ x, y = xy[tri, 0], xy[tri, 1]
34+ det = (x[:, 1] - x[:, 0]) * (y[:, 2] - y[:, 0]) - (x[:, 2] - x[:, 0]) * (y[:, 1] - y[:, 0])
35+ l1 = ((y[:, 1] - y[:, 2]) * (px - x[:, 2]) + (x[:, 2] - x[:, 1]) * (py - y[:, 2])) / det
36+ l2 = ((y[:, 2] - y[:, 0]) * (px - x[:, 2]) + (x[:, 0] - x[:, 2]) * (py - y[:, 2])) / det
37+ l3 = 1 - l1 - l2
38+ inside = (l1 >= -1e-9) & (l2 >= -1e-9) & (l3 >= -1e-9)
39+ e = int(np.argmax(inside))
40+ assert inside[e]
41+ return tri[e], np.array([l1[e], l2[e], l3[e]])
42+
43+
44+STICK = dict(mass=0.03, k=5e8, alpha=1.5, patch_length=0.03, patch_points=7, patch_angle=0.6, gap=1e-4)
45+MALLET = dict(mass=0.3, alpha=2.5, compression=12e-3, radius=0.012, gap=1e-4)
46+
47+
48+def build_patch(arrays, kind, r, phi_angle, v, mallet_points=7):
49+ xy, tri, z0 = arrays["xy"].astype(np.float64), arrays["tri"], arrays["z0"]
50+ phi = (arrays["phi_q"].astype(np.float32) * arrays["phi_scale"][None, :].astype(np.float32))
51+ xs, ys = r * np.cos(phi_angle), r * np.sin(phi_angle)
52+ if kind == "stick":
53+ ex = STICK
54+ ang = phi_angle + ex["patch_angle"]
55+ xi = np.linspace(-0.5, 0.5, ex["patch_points"]) * ex["patch_length"]
56+ px, py = xs + xi * np.cos(ang), ys + xi * np.sin(ang)
57+ k_total, alpha = ex["k"], ex["alpha"]
58+ else:
59+ ex = MALLET
60+ alpha = ex["alpha"]
61+ k_total = (alpha + 1) * 0.5 * ex["mass"] * v ** 2 / ex["compression"] ** (alpha + 1)
62+ th = np.arange(6 if mallet_points > 1 else 0) * np.pi / 3
63+ px = np.concatenate([[xs], xs + ex["radius"] * np.cos(th)])
64+ py = np.concatenate([[ys], ys + ex["radius"] * np.sin(th)])
65+ P = len(px)
66+ phip = np.zeros((P, phi.shape[1]), np.float32)
67+ z0p = np.zeros(P)
68+ for p in range(P):
69+ nodes, w = locate(xy, tri, px[p], py[p])
70+ acc = np.zeros(phi.shape[1], np.float32)
71+ for i in range(3):
72+ acc += np.float32(w[i]) * arrays["phi_q"][nodes[i]].astype(np.float32)
73+ phip[p] = acc * arrays["phi_scale"].astype(np.float32)
74+ z0p[p] = float(w @ z0[nodes])
75+ A = np.column_stack([px - xs, py - ys, np.ones(P)])
76+ offsets = z0p - A @ np.linalg.lstsq(A, z0p, rcond=None)[0]
77+ return dict(phi=phip.astype(np.float64), offsets=offsets.astype(np.float32).astype(np.float64),
78+ k_point=k_total / P, alpha=alpha, mass=ex["mass"], gap=ex["gap"], velocity=v)
79+
80+
81+def main():
82+ ap = argparse.ArgumentParser()
83+ ap.add_argument("modes"); ap.add_argument("hits"); ap.add_argument("out")
84+ ap.add_argument("--fs", type=int, default=44100); ap.add_argument("--seconds", type=float, default=0.5)
85+ ap.add_argument("--signal", choices=["unbaffled", "front"], default="unbaffled")
86+ ap.add_argument("--no-direct", action="store_true", help="drop the direct term Re(G) f")
87+ ap.add_argument("--mallet-points", type=int, default=7)
88+ args = ap.parse_args()
89+ meta, arrays = read_bin(args.modes)
90+ fs, B = args.fs, 128
91+ freq = arrays["freq"]
92+ mat = meta["config"]["material"]
93+ sigma = mat["sigma0"] + mat["sigma1"] * arrays["damp_q"]
94+ freq = freq * np.sqrt((mat["E"] / meta["E0"]) / (mat["rho"] / meta["rho0"]))
95+ gb = arrays["gain_back"] if args.signal == "unbaffled" else np.zeros_like(arrays["gain_back"])
96+ G = (arrays["gain_front"][:, 0] - gb[:, 0]) + 1j * (arrays["gain_front"][:, 1] - gb[:, 1])
97+ G = G.astype(np.complex128)
98+ w0 = 2 * np.pi * freq
99+ wd = np.sqrt(np.maximum(w0 ** 2 - sigma ** 2, 1e-6))
100+ lam = -sigma + 1j * wd
101+ C = G * lam ** 2
102+ D = G.real * (0.0 if args.no_direct else 1.0)
103+ dt = 1.0 / fs
104+ pole1 = np.exp(lam * dt)
105+
106+ hits = json.load(open(args.hits))
107+ b1 = (pole1 - 1) / (lam * wd)
108+ n_blocks = int(np.ceil(args.seconds * fs / B))
109+ z = np.zeros(len(freq), np.complex128)
110+ out = np.zeros(n_blocks * B)
111+ active = []
112+ n = 0
113+ for b in range(n_blocks):
114+ for h in hits:
115+ if round(h["t"] * fs / B) == b:
116+ p = build_patch(arrays, h["kind"], h["r"], h["phi"], h["v"], args.mallet_points)
117+ # the worklet computes the compliance from float32 phi in float64 arithmetic
118+ p["c"] = (p["phi"] * b1.imag[None, :]) @ p["phi"].T
119+ p["F"] = np.zeros(len(p["offsets"]))
120+ p["released"] = False; p["touched"] = False; p["peak"] = 0.0; p["samples"] = 0; p["kind"] = h["kind"]
121+ active.append(p)
122+ for ex in active:
123+ if not ex["released"]:
124+ wp = ex["phi"] @ z.imag + ex["offsets"]
125+ ex["zs"] = wp.max() + ex["gap"]; ex["vs"] = -ex["velocity"]; ex["released"] = True
126+ if not active:
127+ for k in range(B):
128+ z *= pole1
129+ out[n + k] = (C * z).imag.sum()
130+ else:
131+ for k in range(B):
132+ z = pole1 * z
133+ f = np.zeros(len(freq))
134+ any_force = False
135+ for ex in active:
136+ wp = ex["phi"] @ z.imag + ex["offsets"]
137+ base = ex["zs"] + dt * ex["vs"]
138+ em = dt * dt / ex["mass"]
139+ F = ex["F"]
140+ if (wp > base).any():
141+ kp, a, c = ex["k_point"], ex["alpha"], ex["c"]
142+ P = len(F)
143+ for it in range(12):
144+ d = wp - base - em * F.sum() - c @ F
145+ pos = d > 0
146+ g = np.where(pos, F - kp * np.maximum(d, 0) ** a, F)
147+ J = np.eye(P)
148+ s = np.where(pos, kp * a * np.maximum(d, 1e-300) ** (a - 1), 0.0)
149+ J += s[:, None] * (c + em)
150+ J[~pos, :] = np.eye(P)[~pos, :]
151+ dF = np.linalg.solve(J, g)
152+ F = np.maximum(0, F - dF)
153+ if np.abs(dF).max() < 1e-7 * max(1.0, F.max()):
154+ break
155+ else:
156+ F = np.zeros_like(F)
157+ ex["F"] = F
158+ Ft = F.sum()
159+ if Ft > 0:
160+ any_force = True; ex["touched"] = True; ex["samples"] += 1; ex["peak"] = max(ex["peak"], Ft)
161+ f -= F @ ex["phi"]
162+ ex["vs"] += dt * Ft / ex["mass"]; ex["zs"] += dt * ex["vs"]
163+ if any_force:
164+ z = z + b1 * f
165+ out[n + k] = (C * z).imag.sum() + (D * f).sum()
166+ keep = []
167+ for ex in active:
168+ top = (ex["phi"] @ z.imag + ex["offsets"]).max()
169+ done = ex["touched"] and ex["vs"] > 0 and ex["zs"] - top > 2e-3
170+ if not done:
171+ keep.append(ex)
172+ else:
173+ print(f" {ex['kind']}: peak force {ex['peak']:.0f} N, contact {ex['samples'] / fs * 1e3:.2f} ms, rebound {ex['vs']:+.2f} m/s")
174+ active = keep
175+ n += B
176+ out.astype(np.float32).tofile(args.out)
177+ print(f"wrote {args.out}: peak {np.abs(out).max():.3f} Pa")
178+
179+
180+if __name__ == "__main__":
181+ main()
tsconfig.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": [
7+ "ES2022",
8+ "DOM",
9+ "DOM.Iterable"
10+ ],
11+ "strict": true,
12+ "noUnusedLocals": true,
13+ "noUnusedParameters": true,
14+ "noFallthroughCasesInSwitch": true,
15+ "isolatedModules": true,
16+ "skipLibCheck": true,
17+ "noEmit": true,
18+ "types": [
19+ "vite/client"
20+ ],
21+ "allowImportingTsExtensions": true
22+ },
23+ "include": [
24+ "src"
25+ ]
26+}
vite.config.tsadded+7−0View file
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vite";
2+
3+export default defineConfig({
4+ base: "./",
5+ worker: { format: "es" },
6+ build: { target: "es2022" },
7+});