/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU model
A MATLAB-syntax design script (numbl) describes the plate: material, outline, thickness field, optional profile, a cymbal stand with felt pads, and decay times. The linear model assembles the linearized stiffness on the grid, computes all modes below a cutoff by sliced shift-invert Lanczos in Web Workers, and synthesizes the strike from the modes with the Hertz contact and the Rayleigh integral. The full model steps the nonlinear plate on the GPU (WebGPU) with the same discretization, lift-off of the pads included. Tests cover the discretization, the assembly, the eigen solver, the synthesis, the WGSL kernel against the float64 reference, and the agreement of the two models for a light hit.
Jeremy Magland <jeremy.magland@gmail.com> committed commit 57780363f4b5 Browse files
47 changed files+11641−0
.github/workflows/deploy.ymladded+38−0View file
@@ -0,0 +1,38 @@
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+ environment:
20+ name: github-pages
21+ url: ${{ steps.deployment.outputs.page_url }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: actions/setup-node@v4
25+ with:
26+ node-version: 24
27+ cache: npm
28+ - run: npm ci
29+ # Design, discretization, assembly, modal and synthesis tests (no GPU needed).
30+ - run: npm run test:cpu
31+ # Type check and Vite build.
32+ - run: npm run build
33+ - uses: actions/configure-pages@v5
34+ - uses: actions/upload-pages-artifact@v3
35+ with:
36+ path: dist
37+ - id: deployment
38+ uses: actions/deploy-pages@v4
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+86−0View file
@@ -0,0 +1,86 @@
1+# cymbalsim
2+
3+A browser application for designing a plate or a cymbal and simulating a drumstick strike on it. The design is a short script in MATLAB syntax (material, bounding box, outline, thickness field, an optional stress-free profile, the stand, and the decay times), run in the page by [numbl](https://numbl.org), that the user edits in the page or that an AI writes for them. The sound is computed in one of two ways. The **linear model** computes the modes of the plate once per design and synthesizes the strike from them in about a second, on the CPU, in a pool of Web Workers. The **full model** solves the geometrically nonlinear (von Karman) plate equations with in-plane inertia and a spatially varying thickness, stepped explicitly on the GPU through WebGPU; it takes tens of seconds per second of sound and captures the pitch glide and the wash of a hard hit. Both use the same grid, so the linear sound is the small-amplitude limit of the full one. The model and the numerical method are described in [THEORY.md](THEORY.md).
4+
5+The focus is on free plates, sheets, discs and ellipses, flat by default, and on a realistic cymbal stand: a rod through the center hole with a felt pad of given radius below the cymbal and optionally another above it, which hold the cymbal level and let it bounce, rock, and (in the full model) lift off.
6+
7+## Running
8+
9+Requires Node 20 or later. The linear model runs in any modern browser; the full model and the 3D view need WebGPU (Chrome or Edge 113+; Firefox and Safari with WebGPU enabled).
10+
11+ npm install
12+ npm run dev # development server
13+ npm run build # static site in dist/
14+ npm test # all tests (see below)
15+
16+## Using the app
17+
18+The sidebar holds the run controls at its top, then the design script, the strike, listener and numerics parameters, and the mode browser. The main area shows the plate (a lit 3D height field with WebGPU, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
19+
20+**Run mode.** The select at the top chooses the linear or the full model. In linear mode the modes of the current design are computed as soon as the script compiles (a progress bar shows the slices), and Synthesize produces the sound; after the first synthesis, a change of the strike or the listener parameters re-synthesizes automatically. In full mode Start begins the GPU run; Pause halts it, Stop ends it and keeps the audio and movie, and Continue extends a finished run after the duration has been raised.
21+
22+**Design script.** Pick a preset from the dropdown (a flat steel rectangle, a bowed rectangle, a flat bronze disc on a stand, a tapered 16 inch crash, a 20 inch ride, the same crash with a bow and a bell, a steel ellipse, a thunder sheet) and edit it freely; the script is compiled as you type (or with Ctrl+Enter) and the view shows the resulting stress-free shape. Errors are reported under the script, and a summary lists the bounding box, the material, the thickness range, the mass, the grid, the profile's peak-to-peak height and largest slope (with a warning above the shallow-shell range), the stand with its bounce frequency, and the decay times. Revert restores the selected preset.
23+
24+The script fills in a struct named `design`. All lengths are meters. The outline, the thickness, and the profile are numbers or function handles `@(x, y, r, theta)` of the point relative to the center of the bounding box, its distance from the center, and its angle from the x axis; the handles are applied to whole grid matrices, so they use elementwise operators. A script is self-contained: nothing is in scope but numbl's own functions, so a script defines the shapes it needs as ordinary MATLAB functions at the bottom of the file, and the unit constants at the top.
25+
26+```matlab
27+inch = 0.0254; mm = 1e-3;
28+D = 16*inch; R = D/2; hole = 13*mm;
29+design.name = '16 inch flat crash';
30+design.material.E = 110e9; % Pa
31+design.material.nu = 0.34;
32+design.material.rho = 8800; % kg/m^3
33+design.bounds.width = D; % bounding box [m]
34+design.bounds.height = D;
35+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2); % omit for the whole box
36+design.thickness = @(x, y, r, theta) 1.2*mm + (0.7*mm - 1.2*mm) * ramp(40*mm, R, r);
37+design.profile = 0; % optional stress-free height [m]; a handle for a bow or a bell
38+design.stand.pad.innerRadius = 7*mm; % the felt annulus
39+design.stand.pad.outerRadius = 20*mm;
40+design.stand.pad.thickness = 12*mm;
41+design.stand.pad.E = 0.5e6; % Pa, felt in compression
42+design.stand.pad.lossFactor = 0.3;
43+design.stand.topPad = true; % false for a bottom pad only
44+design.stand.preload = 1*mm; % compression of the top pad by the wing nut
45+design.loss.T60 = [100 5; 1000 3.5; 5000 1.2; 15000 0.4]; % decay times [Hz s], or a number, or @(f)
46+
47+function v = ramp(a, b, x)
48+v = min(max((x - a) ./ (b - a), 0), 1);
49+end
50+```
51+
52+`design.stand = []` (or no stand field) gives a free plate; `design.stand.x` and `.y` move the rod. Pad fields that are omitted take the defaults shown. `design.loss.T60` is a decay time in seconds (a number), a table of `[frequency T60]` rows interpolated in log-log coordinates, or a function handle of the frequency in hertz; the default is 4 s at low frequencies falling to about 1 s at 5 kHz.
53+
54+**Modes.** In linear mode the mode browser lists every mode up to the cutoff frequency with its decay time; click a row to draw the mode's shape on the plate (scaled to 1 mm), and filter the list by frequency. On a stand the first modes are the rock and the bounce of the cymbal on the felt. The modal frequencies are also drawn as ticks on the left edge of the spectrogram, in either run mode, so that the partials of a full run can be compared with the modes.
55+
56+**Strike and listener.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance, its angle from the plate's normal, and its azimuth can be changed at any time; the microphone in the view moves with them, drawn nearer than the listener is so that both fit in the frame.
57+
58+**Numerics.** The grid spacing (8 mm by default), the cutoff frequency of the modes, the lift-off of the pads in the full model, the sample rate, and the frame rate. The Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the count and time of the modes, the time step and step count of the full model, and the decay times of the fitted two-parameter loss next to the requested ones.
59+
60+**Playback.** After a run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Design returns to the stress-free shape and, in full mode, Live to the running view. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and the two normalized modes scale each run to its loudness or its peak.
61+
62+**URL parameters.** `?preset=crash-16` selects a preset, `mode=full` the run mode, and any strike, listener, or numerics parameter can be preset in SI units, for example `?preset=ride-20&spacing=0.006&duration=3&autostart=1`; `nogpu=1` disables WebGPU.
63+
64+## Tests
65+
66+`npm test` runs six groups of tests with Node's built-in runner; `npm run test:cpu` runs the first five without a browser.
67+
68+- `tests/design.test.ts`: the design script (compilation, the checks with their messages, hoisted functions, the mask of a disc with a hole, the orientation of the sampled matrices, the removal of the mean plane, the stand's rates and preloads, the three forms of the decay times, the fit of the two-parameter loss), and that every preset evaluates on the default grid.
69+- `tests/reference.test.ts`: the float64 reference of the discretization (rigid modes of the stiffness, forces as the gradient of the energy, dissipativity of the losses and the pad dashpots, thickness scaling, the stand's stiffness against translation and tilt, the lift-off rule).
70+- `tests/assemble.test.ts`: the assembled stiffness equals the linearized reference force on a flat plate and on a bowed, tapered, holed plate on a stand.
71+- `tests/modal.test.ts`: the banded factorization and its inertia count, the tridiagonal eigensolver, all modes of a small square and a small disc against a dense solve (degenerate pairs included), the free square and disc frequencies against Gorman's and Leissa's values, and a plate on a stand.
72+- `tests/synth.test.ts`: a single mode under the stick follows the closed-form damped sinusoid, the radiation gain of a piston equals the analytic Rayleigh sum, the attack and the tail join, the parts computed by the workers sum to the whole.
73+- `tests/gpu.test.ts`: serves the app with Vite and drives a headless Chromium through Playwright: the in-page self-test of the WGSL kernel against the float64 reference on five presets (`?selftest=1` shows the same numbers in the browser), the linear model's modes and synthesis, an error in a script, short full runs on a free plate and on a stand, and a light hit simulated both ways, whose spectra must agree. It needs Playwright's Chromium (`npx playwright install chromium`) and a machine whose Chromium has WebGPU.
74+
75+## Layout
76+
77+- `src/params.ts`: simulation parameters, grid and mask, the plate as evaluated on the grid, material functions, the stand's rates, patches, listener geometry, grid dispersion.
78+- `src/design.ts`, `src/mruntime.ts`, `src/presets.ts`, `src/loss.ts`: running the script with numbl, the checks on the design struct, evaluation on the grid, the preset scripts, and the decay times.
79+- `src/model/reference.ts`: float64 reference of the discretization (forces, losses, energy, the time-step estimate).
80+- `src/model/assemble.ts`: the linearized stiffness in banded storage and the lumped mass.
81+- `src/modal/banded.ts`, `lanczos.ts`, `modes.ts`: the banded factorization with its inertia count, shift-invert Lanczos on one slice, and the sliced computation of all modes through a job runner.
82+- `src/modal/synth.ts`, `worker.ts`, `pool.ts`: the modal synthesis (contact, radiation gains, attack table, tail, movie), the worker, and the pool that runs the eigenvalue jobs and splits the synthesis between the workers.
83+- `src/sim/shaders/`, `src/sim/simulation.ts`, `src/sim/selftest.ts`: the WGSL kernels (step, listener sum, extraction), the WebGPU driver, and the in-browser comparison with the reference.
84+- `src/render/`: 2D and 3D rendering of the plate with the markers of the strike, the stand, and the listener, with a 2D canvas fallback.
85+- `src/audio/`: FFT, spectrogram, WAV encoder, playback, level mapping.
86+- `src/ui/`: the script editor, the parameter form, the mode browser; `src/main.ts` wires it all together.
THEORY.mdadded+137−0View file
@@ -0,0 +1,137 @@
1+# Model and numerical method
2+
3+This note describes the model behind cymbalsim: a thin metal plate of arbitrary outline and spatially varying thickness, flat or with a stress-free curved profile, either hanging freely or sitting on a cymbal stand, struck once with a drumstick. The sound is computed in one of two ways. The *linear model* takes the equations of the plate linearized about its rest shape, computes their modes once per design, and synthesizes the strike from the modes; this takes a few seconds and needs no GPU. The *full model* steps the geometrically nonlinear plate equations of Foppl and von Karman explicitly on the GPU; this takes tens of seconds per second of sound and captures what the linear model cannot, the pitch glide and the transfer of energy toward high frequencies that turn the initial tone of a hard hit into a wash. Both use the same spatial discretization, so the linear model is exactly the small-amplitude limit of the full one, and a light hit sounds the same either way.
4+
5+Section 1 gives the plate model, Section 2 its discretization on the grid, Section 3 the stand, Section 4 the linear model and the modal synthesis, Section 5 the full model, Section 6 the checks that were made, and Section 7 the limitations. The discretization and the GPU kernel follow those of steelsim41, the earlier simulator of a scripted cymbal, simplified where the modal approach allows it; what carries over is repeated here so that this note stands alone.
6+
7+## 1. Plate model
8+
9+The plate occupies a region $\Omega$ of the plane, given by the design script as a bounding box and an outline function (a rectangle, a disc or an ellipse with or without a center hole, or any other test on the point). It has a thickness $h(x,y)$, Young's modulus $E$, Poisson ratio $\nu$, and density $\rho$; the material constants are uniform, the thickness need not be. Its motion is described by the in-plane displacements $u(x,y,t)$ and $v(x,y,t)$ and the transverse displacement $w(x,y,t)$, all measured from a stress-free shape $w_0(x,y)$, the profile of the design, which is zero for a flat plate. The membrane strains in the Marguerre form are
10+
11+$$
12+\varepsilon_{xx} = u_x + \tfrac12\left[(w_0 + w)_x^2 - w_{0,x}^2\right], \qquad
13+\varepsilon_{yy} = v_y + \tfrac12\left[(w_0 + w)_y^2 - w_{0,y}^2\right], \qquad
14+\gamma_{xy} = u_y + v_x + (w_0 + w)_x (w_0 + w)_y - w_{0,x} w_{0,y},
15+$$
16+
17+so that $u = v = w = 0$ carries no stress. The membrane forces per unit length are $N_{xx} = C(\varepsilon_{xx} + \nu\varepsilon_{yy})$, $N_{yy} = C(\varepsilon_{yy} + \nu\varepsilon_{xx})$, and $N_{xy} = G\gamma_{xy}$, with $C = Eh/(1-\nu^2)$ and $G = Eh/(2(1+\nu))$. Writing $W = w_0 + w$ for the total shape and $D = Eh^3/(12(1-\nu^2))$ for the local bending stiffness, the equations of motion are
18+
19+$$
20+\rho h\, u_{tt} = \partial_x N_{xx} + \partial_y N_{xy}, \qquad
21+\rho h\, v_{tt} = \partial_x N_{xy} + \partial_y N_{yy},
22+$$
23+
24+$$
25+\rho h\, w_{tt} = -\mathcal{B}[w] + \partial_x(N_{xx} W_x + N_{xy} W_y) + \partial_y(N_{xy} W_x + N_{yy} W_y) + \text{(losses)} ,
26+$$
27+
28+where $\mathcal{B}[w]$ is the variable-stiffness bending operator, the Euler-Lagrange derivative of the bending energy
29+
30+$$
31+U_b = \frac12 \int_\Omega D\left[w_{xx}^2 + w_{yy}^2 + 2\nu\, w_{xx}w_{yy} + 2(1-\nu)\, w_{xy}^2\right] dA ,
32+$$
33+
34+which reduces to $D\nabla^4 w$ where $D$ is constant. Bending acts on the deviation $w$ from the stress-free shape; the membrane forces act through the total slope. For $w_0 = 0$ and constant $h$ these are the von Karman equations.
35+
36+**The linear part.** Dropping the terms of second order in the displacements, the strains become $\varepsilon_{xx} = u_x + w_{0,x} w_x$, $\varepsilon_{yy} = v_y + w_{0,y} w_y$, $\gamma_{xy} = u_y + v_x + w_{0,x} w_y + w_{0,y} w_x$, and the transverse equation keeps $\partial_x(N_{xx} w_{0,x} + N_{xy} w_{0,y}) + \partial_y(\ldots)$ with the linear $N$. For a flat plate ($w_0 = 0$) this decouples completely: the transverse motion is the Kirchhoff plate $\rho h w_{tt} = -\mathcal{B}[w]$, and the in-plane motion is neither excited by a transverse strike nor heard, so the linear model of a flat plate is a bending problem alone. A profile couples the two through its slopes: a curved plate stretches when it bends, which raises the low modes, and the coupling is what makes a shell differ from a plate.
37+
38+**The nonlinear part.** The quadratic terms in the strains couple bending to stretching at finite amplitude. A flat plate has a symmetric, hardening nonlinearity; a curved plate has an asymmetric one, may snap between curved states, and softens as it passes through the flat configuration. These terms produce the pitch glide of a strongly struck cymbal and the cascade of energy from the low modes toward high frequencies. They are kept by the full model and dropped by the linear one.
39+
40+**Thickness field.** A real cymbal is lathed to a taper, thick at the bell and thin at the edge. The thickness enters in three places: the mass per unit area $\rho h$, the membrane stiffnesses $C$ and $G$, proportional to $h$, and the bending stiffness $D$, proportional to $h^3$. A plate of twice the thickness has twice the bending frequencies and the same in-plane frequencies. The thickness must be positive over the plate and should vary slowly on the scale of the grid.
41+
42+**Profile.** The stress-free shape is given by the script as a function of position. Only its slopes enter the strains, so its mean is immaterial, but its tilt is not: the von Karman strains are not invariant under rotation, and a tilted stress-free shape would add a spurious membrane coupling linear in the tilt. The mass-weighted least-squares plane is therefore subtracted from every profile. The treatment is that of a shallow shell, valid while the slopes are small compared with one; the interface reports the largest slope and warns above 0.1. The bell of a real crash rises 25 mm over a radius of 50 mm, a slope of about 0.5, so the curved preset uses a flatter bell, and this is the main point of tension between the model and a real cymbal (Section 7).
43+
44+**Losses.** The design gives the decay time $T_{60}(f)$, the time in which a partial at frequency $f$ decays by 60 dB, as a number, a table interpolated in log-log coordinates, or a function of frequency. The decay rate of a partial is then $\sigma(f) = \ln(1000)/T_{60}(f)$, so that its amplitude decays as $e^{-\sigma t}$. The linear model applies this rate to each mode at its own frequency (Section 4). The full model needs a loss operator rather than a rate per mode, and uses the standard two-parameter model of plate synthesis, a rate $\sigma_0 + \sigma_1 k^2$ for a bending wave of wavenumber $k$, with $\sigma_0$ and $\sigma_1$ fitted to the design's curve (Section 5). None of these values are measured; the presets are chosen to sound plausible, and the fit is reported next to the requested curve so that the difference is visible.
45+
46+## 2. Spatial discretization
47+
48+The bounding box is covered by a regular grid of $n_x \times n_y$ nodes with spacings $h_x$ and $h_y$ (8 mm by default), and the outline selects the part of the grid that is the plate. All three displacements live at the nodes, and so does the thickness. The forces are the negative gradient of a discrete potential energy $U = U_b + U_m + U_s$ (bending, membrane, stand), so the semi-discrete system is Hamiltonian, its linear part has a symmetric stiffness matrix, and the free edge needs no special formulas.
49+
50+**Outline.** A cell belongs to the plate when its center lies inside the outline, and a node when it is a corner of such a cell; the edge is therefore a staircase of whole cells, and a hole is simply a region whose cells are absent. Every rule below refers to this membership: a second difference exists where the node and both of its neighbors belong to the plate, a cell contributes membrane energy and the mixed bending term when its four corners do, and the weight $\omega_k$ of a node is the fraction of its four cells that belong to the plate (1 inside, 1/2 on an edge, 1/4 at a corner of a full rectangle). Deciding membership by cell centers rather than by nodes keeps the area unbiased; a node-based mask loses about half a cell all around the edge and raises every frequency by several percent.
51+
52+**Bending.** We evaluate $w_{xx}$ and $w_{yy}$ by three-point second differences at the nodes and $w_{xy}$ by the four-point difference at cell centers, and integrate the bending energy with the node weights, the bending stiffness $D_k$ at the nodes and $D_c$ at the cells, where the cell thickness is the mean of its four corners:
53+
54+$$
55+U_b = \frac{h_x h_y}{2}\left[\sum_k \omega_k D_k \left(a_k^2 + b_k^2 + 2\nu a_k b_k\right) + 2(1-\nu)\sum_c D_c\, c_c^2\right],
56+$$
57+
58+with $a_k$, $b_k$ the second differences at node $k$ (absent where undefined) and $c_c$ the mixed difference at cell $c$. Where $D$ is constant the interior stencil is the standard 13-point biharmonic; with a varying $D$ the same gradient produces the discrete form of the variable-stiffness operator, and the edges are handled as the energy dictates. The three rigid modes $w = 1, x, y$ lie in the null space for any thickness field and any outline.
59+
60+**Membrane.** The cells are bilinear (Q4) finite elements with 2 by 2 Gauss integration, using the strains of Section 1 with $w_0$ and $w$ interpolated bilinearly and the stiffnesses of the cell's mean thickness. One-point integration would be cheaper but admits hourglass modes, which the nonlinear coupling would excite.
61+
62+**Mass.** The mass matrix is lumped: the mass of node $k$ is $m_k = \rho h_k h_x h_y \omega_k$, so the total mass is the exact integral of $\rho h$ over the staircase plate.
63+
64+**Accuracy.** Two errors limit the accuracy of the spatial scheme. The first is the treatment of the edge. For the rectangle the scheme converges at second order: the first seven frequencies of a completely free square plate are within 8 percent of Gorman's reference values at 10 cells per side and within 2 percent at 20. The staircase edge of a curved outline is only first-order accurate; the first nine frequencies of a free circular plate are within 3 percent of Leissa's values at 40 cells across the diameter, and the degenerate pairs, which the staircase splits, differ by about 1 percent. A 16 inch cymbal at 8 mm has about 50 cells across, so its low modes are placed to within a percent or two, and the splitting of the pairs is of the same order as the splitting that the hammering of a real cymbal produces. The second error is grid dispersion, which affects every partial regardless of the outline. A plane bending wave of wavenumber $k$ along a grid axis has the discrete frequency ratio $(2 - 2\cos kh)/(kh)^2$, so a partial comes out low by 3 percent at $kh \approx 0.60$ and by 10 percent at $kh \approx 1.12$. Since the bending frequency at a given $k$ is proportional to the thickness, these points move up with the thickness: for 1 mm bronze on the 8 mm grid the 3 percent point is near 1.1 kHz and the 10 percent point near 3.6 kHz. The interface reports the two frequencies at the mean thickness of the design. The high partials are therefore audibly flattened at 8 mm, in both models alike, and a finer grid is a matter of patience rather than of the method.
65+
66+## 3. The stand
67+
68+A cymbal sits on a stand: a rod through the center hole, with a felt pad below the cymbal and usually another above it under a wing nut. We model each pad as an annulus of felt of inner radius $r_1$, outer radius $r_2$ and thickness $t$, with Young's modulus $E_p$ and loss factor $\eta$. Its stiffness per unit area in compression is $E_p/t$, and its stiffness per unit area in shear is $G_p/t$ with $G_p = E_p/(2(1+\nu_p))$ and $\nu_p = 0.3$; the shear is what holds the cymbal in place sideways, and it stands in for the plastic sleeve of the rod, which is not modeled (Section 7). The stand energy is
69+
70+$$
71+U_s = \frac12 \sum_{k \in \text{pads}} \left[ k_k\, w_k^2 + g_k (u_k^2 + v_k^2) \right],
72+$$
73+
74+where the sums run over the plate nodes within the annulus, $k_k = n_p (E_p/t)\, \Delta A_k$ and $g_k = n_p (G_p/t)\, \Delta A_k$, $n_p$ is 1 or 2 for one or two pads, and $\Delta A_k$ is the node's share of the area, scaled so that the shares add up to the exact area of the annulus (a coarse grid covers the annulus with a dozen nodes). A uniform translation of the plate then feels the total stiffness $K = n_p (E_p/t) \pi (r_2^2 - r_1^2)$, and a tilt feels a restoring moment set by the second moment of the annulus, so the finite radius of the felt is what holds the cymbal level. With the defaults of the presets (a felt of 12 mm at 0.5 MPa between 7 and 20 mm, two pads) a 16 inch bronze cymbal of about 1 kg has a total stiffness of about 90 kN/m, bounces on the stand at about 45 Hz, and rocks at about 5 Hz. The felt modulus is an estimate; felt is a nonlinear material whose modulus rises with compression, and the value can be changed in the script.
75+
76+**Preload and lift-off.** The wing nut compresses the top pad by a preload $\delta_t$ at rest (1 mm by default), and the bottom pad carries that preload plus the weight of the plate: $\delta_b = \delta_t + Mg/K_1$ with $K_1$ the stiffness of one pad. Without a top pad $\delta_t = 0$ and the bottom pad carries the weight alone. Gravity enters only through this preload; the static sag of the plate under its own weight, a fraction of a millimeter, is neglected, and $w = 0$ is the rest state. In the full model each pad pushes only while compressed: the bottom pad by $\delta_b - w$, the top pad by $\delta_t + w$, and the constant part of the two forces, which balances the weight, is subtracted, so that within the preload the pads are the linear spring above and beyond it the cymbal lifts off one pad and loads the other. A hard hit near the edge lifts the cymbal off the bottom pad and starts a rock of a centimeter or two at the edge, as on a real stand. The linear model uses the linear spring throughout; it cannot represent the lift-off, which is a nonlinear effect of the stand rather than of the plate.
77+
78+**Damping of the felt.** A loss factor $\eta$ means a stiffness with a phase lag, so that a mode of angular frequency $\omega$ whose shape puts the energy $\tfrac12 \phi^T K_s \phi$ into the pads decays at the additional rate $\sigma = \eta\, \phi^T K_s \phi / (2\omega)$; the linear model applies exactly this to each mode. The full model, which has no modes, replaces the loss factor by a dashpot with the rate $c_k = \eta k_k / \omega_b$ at every pad node, where $\omega_b$ is the bounce frequency; the two agree for the bounce and the rock and differ, in the sense of a stronger dashpot loss on the high modes, above it. The high modes of the plate put next to no energy into the pads, so the difference is small.
79+
80+## 4. The linear model
81+
82+**Modes.** Collecting the linear part of Section 1 on the grid of Section 2 gives the matrix equation $M \ddot q + K q = f$, with $q$ the vector of unknowns (the transverse displacement of every plate node for a flat plate, and $u$, $v$, $w$ interleaved for a plate with a profile), $M$ the diagonal lumped mass, and $K$ the symmetric stiffness, the Hessian of $U_b + U_m + U_s$ at the rest state. The tests check that $K q$ equals the linearized force of the full model to rounding. The modes are the solutions of $K\phi = \omega^2 M \phi$, normalized so that $\phi^T M \phi = 1$; a free plate has three (flat) or six (with a profile) rigid modes at $\omega = 0$, which are computed and dropped, since a hanging plate's rigid motion is taken up by the thread and does not radiate in the model of Section 4 below. A plate on a stand has none.
83+
84+The stiffness is banded, with a half-bandwidth of about twice the number of nodes across the shorter side of the grid (times three with a profile), and its $LDL^T$ factorization at a shift, $K - \sigma M = LDL^T$, costs little. We use it in two ways. The number of negative pivots of $D$ is the number of modes with $\omega^2 < \sigma$ (Sylvester's law of inertia), which tells exactly how many modes lie in any interval. And the factorization applies $(K - \sigma M)^{-1}$, which turns the modes near $\sigma$ into the dominant eigenvalues of a symmetric operator that the Lanczos method finds in a few dozen steps. All modes up to a cutoff frequency (16 kHz by default) are computed by *spectrum slicing*: the interval is cut into slices of about 30 modes (100 with a profile) at boundaries equally spaced in frequency, refined where a slice is too full, the inertia at each boundary fixes the count in each slice, and each slice is solved by shift-invert Lanczos with full reorthogonalization from a random start at the slice's center, until the count of converged modes in the slice equals the count from the inertias; a slice that comes up short (a degenerate pair of a disc can be missed from an unlucky start) is restarted with a new random vector orthogonal to the modes found. The slices are independent and run in a pool of Web Workers. A 16 inch cymbal at 8 mm has about 2150 nodes and about 1900 bending modes below 16 kHz; they take about 2.5 s on a laptop with 12 threads. With a profile the same cymbal has 6500 unknowns and a band three times as wide, and the factorizations and solves become bound by memory traffic; the modes then take about 30 s on the same laptop.
85+
86+**Modal equations.** Expanding $q = \sum_m \phi_m q_m(t)$ turns the plate into independent oscillators,
87+
88+$$
89+\ddot q_m + 2\sigma_m \dot q_m + \omega_m^2 q_m = \phi_m^T f(t),
90+$$
91+
92+with $\sigma_m = \ln(1000)/T_{60}(f_m)$ from the design's decay times, plus the pad term of Section 3 on a stand. The listener signal and the movie are then sums over the modes.
93+
94+**Strike.** The stick tip is a free mass $M_s$ (30 g by default, the effective mass of a stick in a wrist stroke) arriving with speed $v_0$ at a point of the plate. The contact patch consists of the plate nodes within the tip radius (5 mm) plus the nearest one; its contact coordinate is the mass-weighted mean transverse displacement $w_c = \sum_k (m_k/M_p) w_k$ of these nodes, the penetration is $\delta = w_c - z$ with $z$ the tip position, and the force is the Hertz law $F = K\delta^{\alpha}$ for $\delta > 0$ and zero otherwise, with $\alpha = 1.5$ and $K = 5\times10^8\ \mathrm{N/m^{1.5}}$ for wood on metal. The force is spread over the patch in proportion to the node masses, which makes the pair conjugate: the modal force is $-F g_m$ and the contact coordinate is $\sum_m g_m q_m$ with the same gains $g_m = \sum_k (m_k/M_p)\, \phi_m(k)$. The stick obeys $M_s \ddot z = F$. The contact is stepped at 2 microseconds; over each step the force is held constant, and every oscillator is advanced by its exact solution under a constant force, so the step is accurate and unconditionally stable for the plate, and only the stick is integrated explicitly. Within the hit the stick may chatter, releasing and re-engaging as the plate runs ahead of it and comes back; once the contact has been open for 5 ms the stick is withdrawn, so that a plate rocking on its stand cannot strike it again tens of milliseconds later, as a drummer lifts the stick well before that. After that instant $t_1$ every mode decays freely, and its state is one complex amplitude, $q_m(t) = \mathrm{Re}\left[A_m e^{s_m (t - t_1)}\right]$ with $s_m = -\sigma_m + i\sqrt{\omega_m^2 - \sigma_m^2}$. A thin plate is a soft target: its driving-point impedance is $8\sqrt{D\rho h}$, about 60 N s/m for 1 mm bronze, so the contact lasts about a millisecond and the peak force is a few hundred newtons; the excitation is broadband, and the sound is a short bright attack followed by the ring. A hard hit in the linear model is simply a loud one; the model does not know that the plate would have stiffened.
95+
96+**Radiation.** The listener model is the Rayleigh integral for a baffled plate,
97+
98+$$
99+p(\mathbf r, t) = \frac{\rho_{\mathrm{air}}}{2\pi} \sum_k \frac{\Delta A_k}{r_k}\, \ddot w_k(t - \tau_k), \qquad \tau_k = r_k / c,
100+$$
101+
102+where the sum runs over the plate nodes, $r_k$ is the distance from node $k$ to the listener, and $c = 343$ m/s; the common delay is discarded and only the spread of the delays across the plate is kept, up to about a millisecond for a 16 inch cymbal. For a mode in free decay the delay is a phase: $\ddot w_k(t - \tau_k) = \mathrm{Re}[\phi_m(k)\, s_m^2 A_m e^{s_m(t - t_1)} e^{-s_m \tau_k}]$, so the whole sum over the plate collapses to one complex gain per mode,
103+
104+$$
105+G_m = \frac{\rho_{\mathrm{air}}}{2\pi} \sum_k \frac{\Delta A_k}{r_k}\, \phi_m(k)\, e^{-s_m \tau_k}, \qquad
106+p(t) = \sum_m \mathrm{Re}\left[G_m s_m^2 A_m e^{s_m (t - t_1)}\right],
107+$$
108+
109+which is exact for the free decay and includes the directivity of every mode. The signal is generated by the two-term recurrence of each damped sinusoid at the audio rate, about 1900 recurrences per sample. Before the free decay, while the modes are being driven by the stick, the delayed sum cannot be collapsed, and it is evaluated directly: the modal accelerations are tabulated at twice the audio rate during the contact phase, the nodal accelerations $\ddot w_k(t) = \sum_m \phi_m(k) \ddot q_m(t)$ are formed from them, and the Rayleigh sum is taken with linear interpolation in the table. The table path covers the output up to the moment at which every node's retarded time is past the last contact, about 2 ms after the impact, and the modal path takes over from there; the two join without a seam, and the tests check that they do. The linear interpolation in the table during the first two milliseconds is the one approximation of the linear path beyond the modal truncation itself. Moving the listener changes only the gains, so the sound follows a move in well under a second.
110+
111+**Movie.** The transverse displacement at the frame times is the modal sum $\sum_m \phi_m(k) q_m(t)$, with $q_m$ from the contact tables before $t_1$ and from the free decay after it. Its cost, 1900 modes times 2150 nodes times 480 frames, is the largest part of the synthesis and is split among the workers, after the audio, so that playback can start first.
112+
113+## 5. The full model
114+
115+The full model keeps the nonlinear strains of Section 1 and steps the semi-discrete system with the symplectic Euler scheme, $v^{n+1} = v^n + \Delta t\, a(x^n, v^n)$ and $x^{n+1} = x^n + \Delta t\, v^{n+1}$, in single precision on the GPU. With the velocities interpreted at half steps this is the leapfrog scheme, second order and symplectic for the conservative part; the losses and the pad dashpots are evaluated explicitly at the old velocity. The stability limit is $\Delta t < 2/\omega_{\max}$, where $\omega_{\max}$ is the largest frequency of the linearized discrete system, estimated at start-up by power iteration on a small uniform grid with the same spacing and the largest thickness of the design; 90 percent of the limit is used. The in-plane waves, with speed $c_p = \sqrt{E/\rho(1-\nu^2)}$, about 3800 m/s for bronze, set this limit, and $c_p$ does not depend on the thickness: at 8 mm spacing $\Delta t \approx 1.9\ \mu$s for bronze, and 4 s of sound take about 2 million steps. The step is rounded down so that an integer number of steps makes one audio sample. The in-plane inertia terms are usually dropped in favor of an Airy stress function, since in-plane waves are much faster than bending waves; we keep them because they make the whole system explicit and local, which suits the GPU, at the cost of the time step just described.
116+
117+**Losses.** The two-parameter model gives a bending mode of angular frequency $\omega$ and wavenumber $k$ the rate $\sigma_0 + \sigma_1 k^2$; for the flat plate $k^2 = \omega/\kappa$ with $\kappa = \sqrt{D/\rho h}$, so $\sigma_0$ sets the low-frequency decay and $\sigma_1$ makes the high frequencies decay faster in proportion to frequency. The two parameters are fitted by non-negative least squares to the design's rates $\ln(1000)/T_{60}(f)$ on log-spaced frequencies from 50 Hz to 10 kHz, with $\kappa$ at the mean thickness; a decay curve that is not of this form (a table with a kink, for instance) is matched only approximately, and the interface reports the fitted decay times at 100 Hz, 1 kHz and 5 kHz next to the requested ones. The $\sigma_1$ term is implemented as $2\sigma_1$ times the graph Laplacian of the transverse velocity and does not depend on the thickness; where the plate is thicker, a given frequency corresponds to a smaller $k^2$, so the frequency-dependent loss is weaker there. The in-plane motion carries an additional frequency-independent rate, taken as the design's rate at 5 kHz: the stretching modes lie at several kilohertz, couple linearly to the transverse motion once the plate is curved, and would otherwise ring for the whole low-frequency decay time.
118+
119+**Free plate.** A sheet hanging from a thread is treated as free, and the reaction to the stick is distributed over the plate as the inertial load of a rigid body, which is the inertia-relief equilibrium of a free body: the force of the previous step is applied as the rigid acceleration field $a_k = F\,(1/M + y_k y_c/I_x + x_k x_c/I_y)$ that balances the patch load and its moments about the center, so the plate gains no net momentum or angular momentum. The one-step lag has zero net impulse. A plate on a stand takes the reaction through the pads. This is the counterpart of dropping the rigid modes in the linear model.
120+
121+**Single precision.** With $w$ of a few millimeters and $\Delta t \approx 2\ \mu$s, a velocity below about $10^{-3}$ m/s produces an increment smaller than the resolution of $w$, and the rounding would act as a broadband noise source. The update of $w$ is therefore compensated: the rounding error of each addition is carried in a separate array and added back at the next step. The listener sum likewise forms the acceleration as the difference of successive delayed velocities at the audio rate, the average acceleration over one sample interval, rather than sampling the instantaneous acceleration, whose per-step rounding error of order $1\ \mathrm{m/s^2}$ per node would go straight into the audio.
122+
123+**The kernel.** The state is stored twice and each step reads one copy and writes the other. A step is one compute dispatch in which each workgroup of 256 threads advances a 12 by 12 tile of nodes: the threads load the tile with a two-node halo into shared memory, compute the membrane forces of the cells and the bending moments at the nodes once each, and then evaluate the update of the tile's nodes from shared memory. On grids of a few thousand nodes the cost of a step is the latency of one such workgroup rather than the arithmetic. A per-step uniform, addressed by a dynamic offset, carries the losses and the audio bookkeeping, so that a whole movie frame of steps is encoded in one pass; the retarded-time listener sum runs once per frame for all of the frame's samples from a ring buffer of the transverse velocities, and the following frame is encoded while the GPU works on the current one. A 16 inch cymbal at 8 mm runs at about 10 s per second of sound on the integrated GPU of a laptop.
124+
125+## 6. Validation
126+
127+The tests check, without a GPU: that the linear stiffness of every configuration (uniform and tapered rectangles, a disc with a hole) has exactly the rigid modes in its null space; that the forces of the full model are the exact gradient of the discrete energy at a strongly nonlinear state, flat and bowed, uniform and tapered, free and on a stand; that the loss terms and the pad dashpots only remove energy; that the assembled $K$ equals the linearized force of the full model to $10^{-6}$ on a bowed, tapered, holed plate on a stand; that the banded factorization solves and that its inertia counts the eigenvalues; that every mode of a small square and of a small disc, degenerate pairs included, matches a dense eigensolution to $10^{-7}$ with no mode missed; that the free square and disc frequencies converge to Gorman's and Leissa's values as stated in Section 2; that the stand resists a translation with its total stiffness and a tilt with the pads' second moment, and that the lift-off rule unloads the right pad; that a single mode struck by the stick follows the closed-form damped sinusoid, that the radiation gain of a uniform piston equals the analytic Rayleigh sum, that the attack and the tail of the synthesized signal join, and that the parts computed by the workers sum to the whole.
128+
129+With a GPU, a Playwright test runs one step of the WGSL kernel from a smooth random state of about 2.5 thicknesses amplitude, with the losses and the stand with lift-off, on the flat, bowed, tapered, curved and elliptical presets, and compares the accelerations with the float64 reference: they agree to between $10^{-7}$ and $3\times10^{-6}$ relative, which is single-precision rounding. A short strike runs to completion on a free plate and on a stand. And a light hit (0.05 m/s) on the flat disc is simulated both ways: over the interval from 50 to 250 ms the magnitude spectra of the two listener signals below 6 kHz have a correlation of 0.985 and their levels agree within 13 percent. The remaining difference comes from the fitted two-parameter loss against the per-mode decay times, the pad dashpot against the loss factor, and the modal truncation at 16 kHz.
130+
131+## 7. Limitations
132+
133+The linear model is linear: it has no pitch glide, no wash, no lift-off, and a hard hit is only a loud one. It is the right tool for the tone, the modes and the decay of a design, and for a first listen, and the full model is the right tool for the sound of a hard hit; the two agree for a light one. The modal truncation at the cutoff frequency removes the highest partials, which the grid places poorly anyway; the linear interpolation of the acceleration table during the first two milliseconds slightly blurs the attack.
134+
135+The von Karman model assumes moderate rotations, so it is not valid for displacements that are large compared with the plate size; a strike that moves a 400 mm cymbal by a few millimeters is well within its range. The Marguerre treatment of the profile is a shallow-shell approximation, adequate for a bow of a few millimeters on a sheet and for a gentle cymbal bow, but strained by the bell of a real cymbal, whose slope is several times the range in which the approximation holds; the curved preset uses a flatter bell than a real cymbal, and the interface reports the largest slope of a design. A deep bell also behaves as a stiff cup that shifts the bell modes up in ways the shallow shell does not capture. The thickness field is treated as a slowly varying coefficient in a thin-plate model, which is appropriate for a lathed taper but not for a step or a groove on the scale of the grid. The hammering of a real cymbal, which leaves residual stresses and a slightly non-axisymmetric shape, is absent unless the script adds it. The loss model is empirical and its parameters are not calibrated against a real cymbal, and the full model matches the design's decay curve only as well as the two-parameter fit allows. Grid dispersion lowers the high partials by the amounts given in Section 2, and the staircase edge places the low frequencies within a few percent and splits modes that would be degenerate on a true disc.
136+
137+The stand is a linear-elastic felt with a loss factor; real felt stiffens with compression, and the value of its modulus is an estimate. The rod and its sleeve, which stop the cymbal from sliding sideways once the felt's shear gives way, are not modeled, and neither is the friction of the felt. Gravity enters only through the preload. Air loading, which lowers the frequencies of a thin plate slightly and adds radiation damping, is not included. The plate is unbaffled in reality, so the sound from its two faces partly cancels at low frequencies, and the Rayleigh integral overestimates the low-frequency level; an accurate treatment would require solving the exterior acoustic problem, which is outside the scope of this simulator. Finally, single precision limits the dynamic range of the tail of the full model's sound to roughly 100 dB below the peak, and the linear model, in double precision, has no such limit but no more physics.
index.htmladded+55−0View file
@@ -0,0 +1,55 @@
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" />
6+ <title>cymbalsim</title>
7+ <link rel="stylesheet" href="./src/style.css" />
8+ </head>
9+ <body>
10+ <div id="app">
11+ <aside id="sidebar">
12+ <div id="sidebarHead">
13+ <h1>cymbalsim</h1>
14+ <div id="controls"></div>
15+ </div>
16+ <div id="sidebarBody">
17+ <details id="about">
18+ <summary>About</summary>
19+ <p>
20+ A plate or cymbal is described by a short MATLAB-syntax script (material, outline, thickness, an optional
21+ profile, the stand, and the decay times), and a drumstick strike on it is simulated in one of two ways.
22+ The <b>linear</b> model computes the modes of the plate once per design and synthesizes the sound from them
23+ in about a second, on the CPU. The <b>full</b> model steps the geometrically nonlinear (von Karman) plate
24+ equations on the GPU through WebGPU, which takes about a minute per second of sound but captures the pitch
25+ glide and the wash of a hard hit. Both use the same grid, so the linear sound is the small-amplitude limit
26+ of the full one. The model and the numerical method are described in THEORY.md in the repository.
27+ </p>
28+ </details>
29+ <details id="designDetails" open>
30+ <summary>Design script</summary>
31+ <div id="design"></div>
32+ <div id="designInfo" class="info"></div>
33+ </details>
34+ <div id="params"></div>
35+ <details id="modesDetails" open>
36+ <summary>Modes</summary>
37+ <div id="modes"></div>
38+ </details>
39+ </div>
40+ </aside>
41+ <div id="splitter"></div>
42+ <main id="main">
43+ <div id="viewToolbar" class="toolbar"></div>
44+ <div id="viewWrap">
45+ <canvas id="view"></canvas>
46+ <div id="viewOverlay"></div>
47+ </div>
48+ <div id="movieToolbar" class="toolbar"></div>
49+ <div id="specToolbar" class="toolbar"></div>
50+ <canvas id="spec"></canvas>
51+ </main>
52+ </div>
53+ <script type="module" src="./src/main.ts"></script>
54+ </body>
55+</html>
package-lock.jsonadded+3106−0View file
This diff is 3,111 lines long and is not shown.
package.jsonadded+31−0View file
@@ -0,0 +1,31 @@
1+{
2+ "name": "cymbalsim",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Browser simulation of a struck plate or cymbal designed by a script: modal synthesis of the linearized plate, and a geometrically nonlinear (von Karman) simulation on WebGPU.",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc --noEmit && vite build",
10+ "preview": "vite preview",
11+ "test": "node --test tests/*.test.ts",
12+ "test:cpu": "node --test tests/design.test.ts tests/reference.test.ts tests/assemble.test.ts tests/modal.test.ts tests/synth.test.ts",
13+ "test:gpu": "node --test tests/gpu.test.ts"
14+ },
15+ "devDependencies": {
16+ "@types/node": "^24.0.0",
17+ "@webgpu/types": "^0.1.60",
18+ "playwright": "^1.50.0",
19+ "typescript": "^5.6.0",
20+ "vite": "^6.0.0"
21+ },
22+ "dependencies": {
23+ "@codemirror/commands": "^6.11.0",
24+ "@codemirror/language": "^6.12.4",
25+ "@codemirror/legacy-modes": "^6.5.4",
26+ "@codemirror/state": "^6.7.4",
27+ "@codemirror/view": "^6.43.11",
28+ "@lezer/highlight": "^1.2.3",
29+ "numbl": "^0.4.21"
30+ }
31+}
src/audio/fft.tsadded+83−0View file
@@ -0,0 +1,83 @@
1+// In-place radix-2 complex FFT and a Hann window. The twiddle factors and
2+// the bit-reversal permutation are cached per transform length, so calling
3+// fft repeatedly at one length (as the spectrogram does) costs only the
4+// butterflies.
5+
6+interface Tables {
7+ cos: Float32Array;
8+ sin: Float32Array;
9+ rev: Uint32Array;
10+}
11+
12+const cache = new Map<number, Tables>();
13+
14+function tables(n: number): Tables {
15+ let t = cache.get(n);
16+ if (t) return t;
17+ const half = n >> 1;
18+ const cos = new Float32Array(half);
19+ const sin = new Float32Array(half);
20+ for (let k = 0; k < half; k++) {
21+ const a = (-2 * Math.PI * k) / n;
22+ cos[k] = Math.cos(a);
23+ sin[k] = Math.sin(a);
24+ }
25+ const rev = new Uint32Array(n);
26+ const bits = 31 - Math.clz32(n);
27+ for (let i = 0; i < n; i++) {
28+ let r = 0;
29+ for (let b = 0; b < bits; b++) r |= ((i >> b) & 1) << (bits - 1 - b);
30+ rev[i] = r;
31+ }
32+ t = { cos, sin, rev };
33+ cache.set(n, t);
34+ return t;
35+}
36+
37+/**
38+ * Forward complex FFT of (re, im) in place, X[k] = sum_n x[n] exp(-2 pi i k n / N).
39+ * The length must be a power of two and the two arrays must have equal length.
40+ */
41+export function fft(re: Float32Array, im: Float32Array): void {
42+ const n = re.length;
43+ if (n < 1 || (n & (n - 1)) !== 0) throw new Error(`fft: length ${n} is not a power of two`);
44+ if (im.length !== n) throw new Error(`fft: re (${n}) and im (${im.length}) differ in length`);
45+ if (n === 1) return;
46+ const { cos, sin, rev } = tables(n);
47+ for (let i = 0; i < n; i++) {
48+ const r = rev[i];
49+ if (r > i) {
50+ const tr = re[i];
51+ re[i] = re[r];
52+ re[r] = tr;
53+ const ti = im[i];
54+ im[i] = im[r];
55+ im[r] = ti;
56+ }
57+ }
58+ for (let size = 2; size <= n; size <<= 1) {
59+ const half = size >> 1;
60+ const step = n / size;
61+ for (let start = 0; start < n; start += size) {
62+ for (let k = 0, w = 0; k < half; k++, w += step) {
63+ const c = cos[w];
64+ const s = sin[w];
65+ const a = start + k;
66+ const b = a + half;
67+ const xr = re[b] * c - im[b] * s;
68+ const xi = re[b] * s + im[b] * c;
69+ re[b] = re[a] - xr;
70+ im[b] = im[a] - xi;
71+ re[a] += xr;
72+ im[a] += xi;
73+ }
74+ }
75+ }
76+}
77+
78+/** Periodic Hann window of length n, w[k] = 0.5 (1 - cos(2 pi k / n)). */
79+export function hann(n: number): Float32Array {
80+ const w = new Float32Array(n);
81+ for (let k = 0; k < n; k++) w[k] = 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / n);
82+ return w;
83+}
src/audio/level.tsadded+75−0View file
@@ -0,0 +1,75 @@
1+// Playback level. The synthesized signal is a sound pressure in pascals; the
2+// gain maps it to full scale either at a fixed calibration (absolute, 1 Pa
3+// at 0 dBFS plus a user gain), by its peak, or by its loudness (RMS of the
4+// sustained part). A soft clipper above 0.9 of full scale keeps the output
5+// bounded whatever the gain.
6+
7+export type LevelMode = "absolute" | "loudness" | "peak";
8+
9+const KNEE = 0.9;
10+const PEAK_TARGET = 10 ** (-1 / 20); // -1 dBFS
11+const LOUDNESS_TARGET = 10 ** (-20 / 20); // -20 dBFS RMS
12+const LOUDNESS_SKIP_S = 0.05;
13+
14+function peakAbs(samples: Float32Array): number {
15+ let max = 0;
16+ for (let k = 0; k < samples.length; k++) {
17+ const a = Math.abs(samples[k]);
18+ if (a > max) max = a;
19+ }
20+ return max;
21+}
22+
23+/**
24+ * Gain that maps the pressure signal [Pa] to full scale: absolute = 1 Pa at
25+ * 0 dBFS shifted by gainDb; peak = normalize the peak to -1 dBFS; loudness =
26+ * normalize the RMS of the signal after the first 50 ms to -20 dBFS. A silent
27+ * signal gets unit gain in the normalizing modes (there is nothing to scale).
28+ */
29+export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number): number {
30+ if (mode === "absolute") return 10 ** (gainDb / 20);
31+ if (mode === "peak") {
32+ const max = peakAbs(samples);
33+ return max > 0 ? PEAK_TARGET / max : 1;
34+ }
35+ const start = Math.min(samples.length, Math.round(LOUDNESS_SKIP_S * fs));
36+ const count = samples.length - start;
37+ if (count <= 0) return 1;
38+ let sum = 0;
39+ for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k];
40+ const rms = Math.sqrt(sum / count);
41+ return rms > 0 ? LOUDNESS_TARGET / rms : 1;
42+}
43+
44+/**
45+ * Applies the gain and a soft clipper and returns a new array. Below the
46+ * knee (0.9 of full scale) the clipper is the identity; above it the excess
47+ * is squashed with a tanh so that the output stays within (-1, 1) and the
48+ * slope is continuous at the knee.
49+ */
50+export function applyLevel(samples: Float32Array, gain: number): Float32Array {
51+ const out = new Float32Array(samples.length);
52+ const room = 1 - KNEE;
53+ for (let k = 0; k < samples.length; k++) {
54+ const y = samples[k] * gain;
55+ const a = Math.abs(y);
56+ if (a <= KNEE) out[k] = y;
57+ else {
58+ const c = KNEE + room * Math.tanh((a - KNEE) / room);
59+ out[k] = y < 0 ? -c : c;
60+ }
61+ }
62+ return out;
63+}
64+
65+/** Peak level in dBFS of a signal (-Infinity when silent). */
66+export function peakDbfs(samples: Float32Array): number {
67+ const max = peakAbs(samples);
68+ return max > 0 ? 20 * Math.log10(max) : -Infinity;
69+}
70+
71+/** Peak sound pressure level in dB SPL re 20 uPa of a pressure signal [Pa]. */
72+export function peakSpl(samples: Float32Array): number {
73+ const max = peakAbs(samples);
74+ return max > 0 ? 20 * Math.log10(max / 20e-6) : -Infinity;
75+}
src/audio/player.tsadded+84−0View file
@@ -0,0 +1,84 @@
1+// Web Audio playback of a mono Float32Array signal with position reporting.
2+// The AudioContext is created on the first play, at the signal's sample rate
3+// so that no resampling happens in the browser; if a later signal has a
4+// different rate the context is replaced.
5+
6+export class Player {
7+ private ctx: AudioContext | null = null;
8+ private source: AudioBufferSourceNode | null = null;
9+ private startedAt = 0; // context time at which the source was started
10+ private offset = 0; // signal time [s] at which playback started
11+ private length = 0; // signal duration [s]
12+ private lastPosition = 0;
13+ private isPlaying = false;
14+
15+ get playing(): boolean {
16+ return this.isPlaying;
17+ }
18+
19+ /** Current position in seconds while playing (from the AudioContext clock), else the last position. */
20+ position(): number {
21+ if (this.isPlaying && this.ctx) {
22+ const t = this.offset + (this.ctx.currentTime - this.startedAt);
23+ return Math.max(0, Math.min(this.length, t));
24+ }
25+ return this.lastPosition;
26+ }
27+
28+ /** Starts playback of the mono signal at `fs` from `startSeconds`; `onEnded` fires when it finishes on its own (not on stop). */
29+ play(samples: Float32Array, fs: number, startSeconds: number, onEnded?: () => void): void {
30+ this.stop();
31+ if (samples.length === 0 || !(fs > 0)) return;
32+ const ctx = this.context(fs);
33+ const buffer = ctx.createBuffer(1, samples.length, fs);
34+ buffer.getChannelData(0).set(samples);
35+ const src = ctx.createBufferSource();
36+ src.buffer = buffer;
37+ src.connect(ctx.destination);
38+ this.length = samples.length / fs;
39+ this.offset = Math.max(0, Math.min(this.length, startSeconds || 0));
40+ src.onended = () => {
41+ if (this.source !== src) return;
42+ this.source = null;
43+ this.isPlaying = false;
44+ this.lastPosition = this.length;
45+ onEnded?.();
46+ };
47+ this.source = src;
48+ this.isPlaying = true;
49+ if (ctx.state === "suspended") void ctx.resume();
50+ this.startedAt = ctx.currentTime;
51+ src.start(0, this.offset);
52+ }
53+
54+ stop(): void {
55+ const src = this.source;
56+ if (!src) return;
57+ this.lastPosition = this.position();
58+ this.source = null;
59+ this.isPlaying = false;
60+ src.onended = null;
61+ try {
62+ src.stop();
63+ } catch {
64+ // Already stopped.
65+ }
66+ src.disconnect();
67+ }
68+
69+ private context(fs: number): AudioContext {
70+ if (this.ctx && this.ctx.sampleRate !== fs) {
71+ void this.ctx.close();
72+ this.ctx = null;
73+ }
74+ if (!this.ctx) {
75+ try {
76+ this.ctx = new AudioContext({ sampleRate: fs });
77+ } catch {
78+ // The browser refused the rate; fall back to its default and let Web Audio resample.
79+ this.ctx = new AudioContext();
80+ }
81+ }
82+ return this.ctx;
83+ }
84+}
src/audio/spectrogram.tsadded+523−0View file
@@ -0,0 +1,523 @@
1+// Incremental STFT spectrogram drawn on a 2D canvas. The signal is kept as
2+// it arrives and turned into dB magnitude columns (one FFT per hop) as soon
3+// as a complete window is available. The plot area is an ImageData at
4+// device-pixel resolution whose pixel columns are max-pooled over the STFT
5+// columns and frequency bins they cover; on append only the pixel columns
6+// touched by the new STFT columns are recomputed, so draw() is a putImageData
7+// plus the axes and the cursor. The dB scale is relative to the largest
8+// magnitude seen so far (0 dB at the peak, down to -dbRange); when the peak
9+// rises the whole image is re-rendered from the stored columns.
10+
11+import { fft, hann } from "./fft.ts";
12+
13+export interface SpectrogramOptions {
14+ fs: number;
15+ window: number;
16+ hop: number;
17+ maxHz: number;
18+ dbRange: number;
19+ logFreq: boolean;
20+}
21+
22+const LOG_FLOOR_HZ = 20;
23+const EMPTY_RGB = [26, 26, 32];
24+const FRAME_RGB = "#121216";
25+const AXIS_COLOR = "rgba(215, 215, 228, 0.85)";
26+const TICK_COLOR = "rgba(110, 225, 255, 0.95)";
27+const CURSOR_COLOR = "rgba(255, 255, 255, 0.85)";
28+// Re-render everything when the peak has risen by more than this since the last full render.
29+const PEAK_TOLERANCE_DB = 0.5;
30+
31+export class Spectrogram {
32+ private readonly canvas: HTMLCanvasElement;
33+ private readonly ctx: CanvasRenderingContext2D;
34+ private opts: SpectrogramOptions;
35+
36+ // Signal and STFT state.
37+ private signal = new Float32Array(1 << 16);
38+ private nSamples = 0;
39+ private consumed = 0;
40+ private columns: Float32Array[] = [];
41+ private peakDb = -Infinity;
42+ private win: Float32Array;
43+ private re: Float32Array;
44+ private im: Float32Array;
45+
46+ // Display state.
47+ private duration = 1;
48+ private cursor: number | null = null;
49+ private ticks: number[] | null = null;
50+
51+ // Geometry in device pixels.
52+ private dpr = 1;
53+ private W = 1;
54+ private H = 1;
55+ private left = 0;
56+ private top = 0;
57+ private pw = 1;
58+ private ph = 1;
59+ private fmin = 0;
60+ private fmax = 1;
61+ private image: ImageData | null = null;
62+ private renderedPeak = -Infinity;
63+ /** Per pixel row, the bin range [2*py, 2*py+1) pooled into it. */
64+ private rowBins = new Int32Array(0);
65+ /** Per pixel column, the STFT column range [2*px, 2*px+1) pooled into it. */
66+ private pixCols = new Int32Array(0);
67+
68+ private raf = 0;
69+ private observer: ResizeObserver | null = null;
70+ private destroyed = false;
71+
72+ constructor(canvas: HTMLCanvasElement, opts: SpectrogramOptions) {
73+ const ctx = canvas.getContext("2d");
74+ if (!ctx) throw new Error("Spectrogram: could not get a 2D canvas context");
75+ this.canvas = canvas;
76+ this.ctx = ctx;
77+ this.opts = { ...opts };
78+ validate(this.opts);
79+ this.win = hann(this.opts.window);
80+ this.re = new Float32Array(this.opts.window);
81+ this.im = new Float32Array(this.opts.window);
82+ if (typeof ResizeObserver !== "undefined") {
83+ this.observer = new ResizeObserver(() => this.resize());
84+ this.observer.observe(canvas);
85+ }
86+ this.resize();
87+ }
88+
89+ /** Changes options; the columns are recomputed from the signal seen so far when the STFT parameters change. */
90+ setOptions(opts: Partial<SpectrogramOptions>): void {
91+ const next = { ...this.opts, ...opts };
92+ validate(next);
93+ const prev = this.opts;
94+ this.opts = next;
95+ if (next.window !== prev.window || next.hop !== prev.hop || next.fs !== prev.fs) {
96+ this.win = hann(next.window);
97+ this.re = new Float32Array(next.window);
98+ this.im = new Float32Array(next.window);
99+ this.columns = [];
100+ this.consumed = 0;
101+ this.peakDb = -Infinity;
102+ this.computeColumns();
103+ }
104+ this.rebuildGeometry();
105+ this.scheduleDraw();
106+ }
107+
108+ /** Clears the signal and the image. */
109+ reset(): void {
110+ this.nSamples = 0;
111+ this.consumed = 0;
112+ this.columns = [];
113+ this.peakDb = -Infinity;
114+ this.renderAll();
115+ this.scheduleDraw();
116+ }
117+
118+ /** Appends signal; any new complete columns are computed and rendered incrementally. */
119+ append(samples: Float32Array): void {
120+ if (samples.length === 0) return;
121+ const need = this.nSamples + samples.length;
122+ if (need > this.signal.length) {
123+ let cap = this.signal.length;
124+ while (cap < need) cap *= 2;
125+ const grown = new Float32Array(cap);
126+ grown.set(this.signal.subarray(0, this.nSamples));
127+ this.signal = grown;
128+ }
129+ this.signal.set(samples, this.nSamples);
130+ this.nSamples = need;
131+ const before = this.columns.length;
132+ this.computeColumns();
133+ if (this.columns.length > before) this.renderNew(before);
134+ this.scheduleDraw();
135+ }
136+
137+ /** Replaces the whole signal. */
138+ setSignal(samples: Float32Array): void {
139+ this.nSamples = 0;
140+ this.consumed = 0;
141+ this.columns = [];
142+ this.peakDb = -Infinity;
143+ if (samples.length === 0) {
144+ this.renderAll();
145+ this.scheduleDraw();
146+ return;
147+ }
148+ this.append(samples);
149+ }
150+
151+ /** Playback cursor [s], drawn as a vertical line; null hides it. */
152+ setCursor(seconds: number | null): void {
153+ if (seconds === this.cursor) return;
154+ this.cursor = seconds;
155+ this.scheduleDraw();
156+ }
157+
158+ /** Short horizontal ticks at these frequencies [Hz] on the left edge of the plot (e.g. modal frequencies). */
159+ setTicks(freqs: number[] | null): void {
160+ this.ticks = freqs ? freqs.slice() : null;
161+ this.scheduleDraw();
162+ }
163+
164+ /** The time axis spans this duration [s], so the image does not rescale as the signal grows. */
165+ setDuration(seconds: number): void {
166+ const d = seconds > 0 && Number.isFinite(seconds) ? seconds : 1;
167+ if (d === this.duration) return;
168+ this.duration = d;
169+ this.rebuildGeometry();
170+ this.scheduleDraw();
171+ }
172+
173+ /** Matches the canvas pixel size to its CSS size and the device pixel ratio, then re-renders. */
174+ resize(): void {
175+ if (this.destroyed) return;
176+ const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
177+ const cssW = this.canvas.clientWidth || this.canvas.width / dpr || 1;
178+ const cssH = this.canvas.clientHeight || this.canvas.height / dpr || 1;
179+ const W = Math.max(1, Math.round(cssW * dpr));
180+ const H = Math.max(1, Math.round(cssH * dpr));
181+ if (this.canvas.width !== W) this.canvas.width = W;
182+ if (this.canvas.height !== H) this.canvas.height = H;
183+ if (W === this.W && H === this.H && dpr === this.dpr && this.image) {
184+ this.scheduleDraw();
185+ return;
186+ }
187+ this.dpr = dpr;
188+ this.W = W;
189+ this.H = H;
190+ this.rebuildGeometry();
191+ this.scheduleDraw();
192+ }
193+
194+ /** Draws the current image, axes, ticks and cursor immediately. */
195+ draw(): void {
196+ if (this.raf) {
197+ cancelAnimationFrame(this.raf);
198+ this.raf = 0;
199+ }
200+ if (this.destroyed || !this.image) return;
201+ const ctx = this.ctx;
202+ const { dpr, left, top, pw, ph } = this;
203+ ctx.setTransform(1, 0, 0, 1, 0, 0);
204+ ctx.fillStyle = FRAME_RGB;
205+ ctx.fillRect(0, 0, this.W, this.H);
206+ ctx.putImageData(this.image, left, top);
207+ this.drawAxes();
208+ if (this.ticks) {
209+ ctx.fillStyle = TICK_COLOR;
210+ const len = Math.round(6 * dpr);
211+ const thick = Math.max(1, Math.round(dpr));
212+ for (const f of this.ticks) {
213+ if (!(f >= this.fmin && f <= this.fmax)) continue;
214+ const y = Math.round(top + (1 - this.freqFrac(f)) * ph - thick / 2);
215+ ctx.fillRect(left, y, len, thick);
216+ }
217+ }
218+ const c = this.cursor;
219+ if (c !== null && c >= 0 && c <= this.duration) {
220+ const x = Math.round(left + (c / this.duration) * pw);
221+ ctx.fillStyle = CURSOR_COLOR;
222+ ctx.fillRect(x, top, Math.max(1, Math.round(dpr)), ph);
223+ }
224+ }
225+
226+ destroy(): void {
227+ this.destroyed = true;
228+ if (this.raf) {
229+ cancelAnimationFrame(this.raf);
230+ this.raf = 0;
231+ }
232+ this.observer?.disconnect();
233+ this.observer = null;
234+ this.columns = [];
235+ this.signal = new Float32Array(0);
236+ this.image = null;
237+ }
238+
239+ // ---- STFT ----
240+
241+ private computeColumns(): void {
242+ const { window: n, hop } = this.opts;
243+ const nBins = (n >> 1) + 1;
244+ const re = this.re;
245+ const im = this.im;
246+ const win = this.win;
247+ while (this.consumed + n <= this.nSamples) {
248+ const off = this.consumed;
249+ for (let k = 0; k < n; k++) {
250+ re[k] = this.signal[off + k] * win[k];
251+ im[k] = 0;
252+ }
253+ fft(re, im);
254+ const col = new Float32Array(nBins);
255+ let peak = this.peakDb;
256+ for (let k = 0; k < nBins; k++) {
257+ const p = re[k] * re[k] + im[k] * im[k];
258+ const db = 10 * Math.log10(p + 1e-30);
259+ col[k] = db;
260+ if (db > peak) peak = db;
261+ }
262+ this.peakDb = peak;
263+ this.columns.push(col);
264+ this.consumed += hop;
265+ }
266+ }
267+
268+ // ---- Geometry and rendering of the plot image ----
269+
270+ private freqFrac(f: number): number {
271+ if (this.opts.logFreq) {
272+ return (Math.log(f) - Math.log(this.fmin)) / (Math.log(this.fmax) - Math.log(this.fmin));
273+ }
274+ return (f - this.fmin) / (this.fmax - this.fmin);
275+ }
276+
277+ private fracFreq(frac: number): number {
278+ if (this.opts.logFreq) {
279+ const lo = Math.log(this.fmin);
280+ const hi = Math.log(this.fmax);
281+ return Math.exp(lo + frac * (hi - lo));
282+ }
283+ return this.fmin + frac * (this.fmax - this.fmin);
284+ }
285+
286+ private rebuildGeometry(): void {
287+ const { dpr, W, H } = this;
288+ const o = this.opts;
289+ this.left = Math.round(50 * dpr);
290+ this.top = Math.round(6 * dpr);
291+ const bottom = Math.round(20 * dpr);
292+ const right = Math.round(8 * dpr);
293+ this.pw = Math.max(1, W - this.left - right);
294+ this.ph = Math.max(1, H - this.top - bottom);
295+ const nyquist = o.fs / 2;
296+ if (o.logFreq) {
297+ this.fmin = LOG_FLOOR_HZ;
298+ this.fmax = Math.max(LOG_FLOOR_HZ * 2, Math.min(o.maxHz, nyquist));
299+ } else {
300+ this.fmin = 0;
301+ this.fmax = Math.max(1, Math.min(o.maxHz, nyquist));
302+ }
303+
304+ // Bin range per pixel row (row 0 at the top).
305+ const binHz = o.fs / o.window;
306+ const nBins = (o.window >> 1) + 1;
307+ const ph = this.ph;
308+ this.rowBins = new Int32Array(2 * ph);
309+ for (let py = 0; py < ph; py++) {
310+ const fHi = this.fracFreq(1 - py / ph);
311+ const fLo = this.fracFreq(1 - (py + 1) / ph);
312+ let b0 = Math.round(fLo / binHz);
313+ let b1 = Math.round(fHi / binHz);
314+ b0 = Math.max(0, Math.min(nBins - 1, b0));
315+ b1 = Math.max(b0 + 1, Math.min(nBins, b1));
316+ this.rowBins[2 * py] = b0;
317+ this.rowBins[2 * py + 1] = b1;
318+ }
319+
320+ // STFT column range per pixel column. Column c is centered at time
321+ // (c * hop + window / 2) / fs; a pixel narrower than a hop takes the
322+ // nearest column.
323+ const pw = this.pw;
324+ const dt = this.duration / pw; // seconds per pixel
325+ const half = o.window / 2;
326+ const maxCols = Math.max(1, Math.floor((this.duration * o.fs - o.window) / o.hop) + 1);
327+ this.pixCols = new Int32Array(2 * pw);
328+ for (let px = 0; px < pw; px++) {
329+ let c0 = Math.ceil((px * dt * o.fs - half) / o.hop);
330+ let c1 = Math.ceil(((px + 1) * dt * o.fs - half) / o.hop);
331+ if (c1 <= c0) {
332+ c0 = Math.round(((px + 0.5) * dt * o.fs - half) / o.hop);
333+ c1 = c0 + 1;
334+ }
335+ c0 = Math.max(0, Math.min(maxCols - 1, c0));
336+ c1 = Math.max(c0 + 1, c1);
337+ this.pixCols[2 * px] = c0;
338+ this.pixCols[2 * px + 1] = c1;
339+ }
340+
341+ this.image = this.ctx.createImageData(pw, ph);
342+ this.renderAll();
343+ }
344+
345+ private renderAll(): void {
346+ if (!this.image) return;
347+ this.renderedPeak = this.peakDb;
348+ for (let px = 0; px < this.pw; px++) this.renderPixelColumn(px);
349+ }
350+
351+ /** Renders the pixel columns touched by STFT columns [firstNew, columns.length). */
352+ private renderNew(firstNew: number): void {
353+ if (!this.image) return;
354+ if (this.peakDb > this.renderedPeak + PEAK_TOLERANCE_DB || !Number.isFinite(this.renderedPeak)) {
355+ this.renderAll();
356+ return;
357+ }
358+ const last = this.columns.length;
359+ for (let px = 0; px < this.pw; px++) {
360+ const c0 = this.pixCols[2 * px];
361+ const c1 = this.pixCols[2 * px + 1];
362+ if (c1 > firstNew && c0 < last) this.renderPixelColumn(px);
363+ }
364+ }
365+
366+ private renderPixelColumn(px: number): void {
367+ const img = this.image;
368+ if (!img) return;
369+ const data = img.data;
370+ const { pw, ph } = this;
371+ const cols = this.columns;
372+ const c0 = this.pixCols[2 * px];
373+ const c1 = Math.min(cols.length, this.pixCols[2 * px + 1]);
374+ if (c0 >= c1) {
375+ for (let py = 0; py < ph; py++) {
376+ const o = (py * pw + px) * 4;
377+ data[o] = EMPTY_RGB[0];
378+ data[o + 1] = EMPTY_RGB[1];
379+ data[o + 2] = EMPTY_RGB[2];
380+ data[o + 3] = 255;
381+ }
382+ return;
383+ }
384+ const ref = this.renderedPeak;
385+ const range = this.opts.dbRange;
386+ const lut = colormapLut();
387+ for (let py = 0; py < ph; py++) {
388+ const b0 = this.rowBins[2 * py];
389+ const b1 = this.rowBins[2 * py + 1];
390+ let v = -Infinity;
391+ for (let c = c0; c < c1; c++) {
392+ const col = cols[c];
393+ for (let b = b0; b < b1; b++) if (col[b] > v) v = col[b];
394+ }
395+ let t = (v - ref + range) / range;
396+ if (!(t > 0)) t = 0;
397+ else if (t > 1) t = 1;
398+ const i = Math.round(t * 255) * 3;
399+ const o = (py * pw + px) * 4;
400+ data[o] = lut[i];
401+ data[o + 1] = lut[i + 1];
402+ data[o + 2] = lut[i + 2];
403+ data[o + 3] = 255;
404+ }
405+ }
406+
407+ // ---- Axes ----
408+
409+ private drawAxes(): void {
410+ const ctx = this.ctx;
411+ const { dpr, left, top, pw, ph, fmin, fmax } = this;
412+ ctx.fillStyle = AXIS_COLOR;
413+ ctx.font = `${Math.round(10 * dpr)}px system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif`;
414+ ctx.textAlign = "right";
415+ ctx.textBaseline = "middle";
416+ const fticks = this.opts.logFreq
417+ ? LOG_TICKS.filter((f) => f >= fmin && f <= fmax)
418+ : niceTicks(0, fmax, Math.max(2, Math.floor(ph / (36 * dpr))));
419+ const thin = Math.max(1, Math.round(dpr));
420+ let lastY = -Infinity;
421+ for (const f of fticks) {
422+ const y = Math.round(top + (1 - this.freqFrac(f)) * ph);
423+ if (Math.abs(y - lastY) < 12 * dpr) continue;
424+ lastY = y;
425+ ctx.fillRect(left - 4 * dpr, y, 4 * dpr, thin);
426+ ctx.fillText(formatHz(f), left - 6 * dpr, y);
427+ }
428+ ctx.textAlign = "center";
429+ ctx.textBaseline = "top";
430+ const tticks = niceTicks(0, this.duration, Math.max(2, Math.floor(pw / (64 * dpr))));
431+ const yText = top + ph + 4 * dpr;
432+ for (let k = 0; k < tticks.length; k++) {
433+ const t = tticks[k];
434+ const x = Math.round(left + (t / this.duration) * pw);
435+ ctx.fillRect(x, top + ph, thin, 4 * dpr);
436+ let label = formatNumber(t);
437+ if (k === tticks.length - 1) label += " s";
438+ ctx.fillText(label, x, yText);
439+ }
440+ }
441+
442+ private scheduleDraw(): void {
443+ if (this.destroyed || this.raf) return;
444+ if (typeof requestAnimationFrame === "undefined") {
445+ this.draw();
446+ return;
447+ }
448+ this.raf = requestAnimationFrame(() => {
449+ this.raf = 0;
450+ this.draw();
451+ });
452+ }
453+}
454+
455+const LOG_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000];
456+
457+function validate(o: SpectrogramOptions): void {
458+ if (!(o.window >= 2) || (o.window & (o.window - 1)) !== 0) throw new Error(`Spectrogram: window ${o.window} is not a power of two`);
459+ if (!(o.hop >= 1)) throw new Error(`Spectrogram: hop must be positive`);
460+ if (!(o.fs > 0)) throw new Error(`Spectrogram: fs must be positive`);
461+ if (!(o.dbRange > 0)) throw new Error(`Spectrogram: dbRange must be positive`);
462+ o.window = Math.round(o.window);
463+ o.hop = Math.round(o.hop);
464+}
465+
466+function formatHz(f: number): string {
467+ if (f >= 1000) return `${formatNumber(f / 1000)} kHz`;
468+ return `${formatNumber(f)} Hz`;
469+}
470+
471+function formatNumber(x: number): string {
472+ const s = x.toFixed(3);
473+ return s.replace(/\.?0+$/, "");
474+}
475+
476+/** Round tick positions covering [lo, hi] with about `count` intervals. */
477+function niceTicks(lo: number, hi: number, count: number): number[] {
478+ const span = hi - lo;
479+ if (!(span > 0)) return [lo];
480+ const raw = span / count;
481+ const mag = 10 ** Math.floor(Math.log10(raw));
482+ const norm = raw / mag;
483+ const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
484+ const ticks: number[] = [];
485+ const first = Math.ceil(lo / step - 1e-9);
486+ const last = Math.floor(hi / step + 1e-9);
487+ for (let k = first; k <= last; k++) ticks.push(+(k * step).toPrecision(12));
488+ return ticks;
489+}
490+
491+// Inferno-like colormap: anchor colors at t = 0, 0.1, ..., 1 interpolated
492+// linearly into a 256-entry RGB table.
493+const INFERNO_ANCHORS: [number, number, number][] = [
494+ [0, 0, 4],
495+ [22, 11, 57],
496+ [66, 10, 104],
497+ [106, 23, 110],
498+ [147, 38, 103],
499+ [188, 55, 84],
500+ [221, 81, 58],
501+ [243, 120, 25],
502+ [252, 165, 10],
503+ [246, 215, 70],
504+ [252, 255, 164],
505+];
506+
507+let lut: Uint8ClampedArray | null = null;
508+
509+function colormapLut(): Uint8ClampedArray {
510+ if (lut) return lut;
511+ const table = new Uint8ClampedArray(256 * 3);
512+ const segs = INFERNO_ANCHORS.length - 1;
513+ for (let i = 0; i < 256; i++) {
514+ const t = (i / 255) * segs;
515+ const s = Math.min(segs - 1, Math.floor(t));
516+ const u = t - s;
517+ const a = INFERNO_ANCHORS[s];
518+ const b = INFERNO_ANCHORS[s + 1];
519+ for (let k = 0; k < 3; k++) table[i * 3 + k] = Math.round(a[k] + (b[k] - a[k]) * u);
520+ }
521+ lut = table;
522+ return table;
523+}
src/audio/wav.tsadded+32−0View file
@@ -0,0 +1,32 @@
1+// Mono 16-bit PCM WAV encoder.
2+
3+/** Encodes the signal as a 16-bit PCM mono WAV file; samples are clamped to [-1, 1]. */
4+export function encodeWav(samples: Float32Array, fs: number): Blob {
5+ const n = samples.length;
6+ const dataBytes = n * 2;
7+ const buf = new ArrayBuffer(44 + dataBytes);
8+ const v = new DataView(buf);
9+ const ascii = (offset: number, s: string): void => {
10+ for (let k = 0; k < s.length; k++) v.setUint8(offset + k, s.charCodeAt(k));
11+ };
12+ const rate = Math.round(fs);
13+ ascii(0, "RIFF");
14+ v.setUint32(4, 36 + dataBytes, true);
15+ ascii(8, "WAVE");
16+ ascii(12, "fmt ");
17+ v.setUint32(16, 16, true); // fmt chunk size
18+ v.setUint16(20, 1, true); // PCM
19+ v.setUint16(22, 1, true); // channels
20+ v.setUint32(24, rate, true);
21+ v.setUint32(28, rate * 2, true); // byte rate
22+ v.setUint16(32, 2, true); // block align
23+ v.setUint16(34, 16, true); // bits per sample
24+ ascii(36, "data");
25+ v.setUint32(40, dataBytes, true);
26+ for (let k = 0; k < n; k++) {
27+ const x = samples[k];
28+ const s = x > 1 ? 1 : x < -1 ? -1 : x !== x ? 0 : x;
29+ v.setInt16(44 + 2 * k, Math.round(s * 32767), true);
30+ }
31+ return new Blob([buf], { type: "audio/wav" });
32+}
src/design.tsadded+371−0View file
@@ -0,0 +1,371 @@
1+// The design: a MATLAB-syntax script, run by numbl, that fills in a struct
2+// named `design` with the material, the bounding box, the outline, the
3+// thickness field, the stress-free profile, the stand, and the losses.
4+// The outline, the thickness, and the profile are function handles
5+// @(x, y, r, theta), vectorized over grid matrices (or plain numbers).
6+// compileDesign runs the script and checks the struct; evaluatePlate runs it
7+// again with a short trailer that samples the fields on meshgrid matrices of
8+// the requested spacing, inside numbl, and reads the matrices back.
9+//
10+// Lengths are meters throughout. A script is self-contained: nothing is in
11+// scope but numbl's own functions and whatever the script defines itself,
12+// including ordinary MATLAB functions written at its bottom (numbl hoists
13+// them, so the trailer may still follow them).
14+
15+import { executeCode, type RuntimeValue, type RuntimeTensor, type RuntimeStruct } from "numbl";
16+import { RUNTIME_FILES } from "./mruntime.ts";
17+import { LOSS_FREQS, constantLoss, defaultLoss, lossFromTable } from "./loss.ts";
18+import {
19+ type Grid,
20+ type Loss,
21+ type Material,
22+ type Pad,
23+ type Plate,
24+ type Stand,
25+ insideNode,
26+ makeGrid,
27+ nodeWeight,
28+ nodeX,
29+ nodeY,
30+ withCellMask,
31+} from "./params.ts";
32+
33+/** A compiled design: the checked contents of the `design` struct, plus the script that produced it. */
34+export interface Design {
35+ name: string;
36+ material: Material;
37+ Lx: number;
38+ Ly: number;
39+ /** Whether the script gave an outline; without one the plate is the whole bounding box. */
40+ hasInside: boolean;
41+ /** The thickness when it is a plain number; null for a function. */
42+ thicknessConst: number | null;
43+ /** The profile when it is a plain number (or absent); null for a function. */
44+ profileConst: number | null;
45+ stand: Stand | null;
46+ /** The losses when they do not need the script (a number, a table, or the default); null for a function handle. */
47+ lossConst: Loss | null;
48+ source: string;
49+}
50+
51+/** Defaults of the pad of a stand, a typical felt washer. */
52+export const DEFAULT_PAD: Pad = { innerRadius: 7e-3, outerRadius: 20e-3, thickness: 12e-3, E: 0.5e6, lossFactor: 0.3 };
53+export const DEFAULT_PRELOAD = 1e-3;
54+
55+/** Message of an error thrown by the script or by the checks. */
56+export class DesignError extends Error {}
57+
58+const FILE_NAME = "design.m";
59+
60+/**
61+ * Runs a design script and returns its workspace variables. `userLines` is
62+ * the length of the part of the source the user wrote, so that an error in
63+ * the appended trailer is not reported at a line the user cannot see.
64+ */
65+export function runDesignScript(source: string, userLines = Infinity): Record<string, RuntimeValue> {
66+ try {
67+ return executeCode(source, { displayResults: false, onOutput: () => {} }, RUNTIME_FILES, FILE_NAME).variableValues;
68+ } catch (e) {
69+ throw new DesignError(describeError(e, Math.min(source.split("\n").length, userLines)));
70+ }
71+}
72+
73+function describeError(e: unknown, userLines: number): string {
74+ if (!(e instanceof Error)) return String(e);
75+ const { line, file } = e as { line?: unknown; file?: unknown };
76+ const inScript = file === undefined || file === null || file === FILE_NAME;
77+ const where = inScript && typeof line === "number" && line >= 1 && line <= userLines ? `line ${line}: ` : "";
78+ return `${where}${e.message}`;
79+}
80+
81+// ---------- Reading runtime values ----------
82+
83+function isStruct(v: RuntimeValue | undefined): v is RuntimeStruct {
84+ return typeof v === "object" && v !== null && (v as { kind?: string }).kind === "struct";
85+}
86+function isTensor(v: RuntimeValue | undefined): v is RuntimeTensor {
87+ return typeof v === "object" && v !== null && (v as { kind?: string }).kind === "tensor";
88+}
89+function isFunction(v: RuntimeValue | undefined): boolean {
90+ return typeof v === "object" && v !== null && (v as { kind?: string }).kind === "function";
91+}
92+function isEmpty(v: RuntimeValue | undefined): boolean {
93+ return v === undefined || (isTensor(v) && v.data.length === 0);
94+}
95+
96+/** A scalar number from a runtime value, or null when it is not one. */
97+function scalar(v: RuntimeValue | undefined): number | null {
98+ if (typeof v === "number") return v;
99+ if (typeof v === "boolean") return v ? 1 : 0;
100+ if (isTensor(v) && v.data.length === 1 && (!v.imag || v.imag[0] === 0)) return v.data[0];
101+ return null;
102+}
103+
104+function num(v: RuntimeValue | undefined, what: string, min = -Infinity): number {
105+ const s = scalar(v);
106+ if (s === null || !isFinite(s)) throw new DesignError(`${what} must be a finite number`);
107+ if (s < min) throw new DesignError(`${what} must be at least ${min}`);
108+ return s;
109+}
110+
111+function bool(v: RuntimeValue | undefined, what: string, dflt: boolean): boolean {
112+ if (v === undefined || isEmpty(v)) return dflt;
113+ const s = scalar(v);
114+ if (s === null) throw new DesignError(`${what} must be true or false`);
115+ return s !== 0;
116+}
117+
118+function field(s: RuntimeStruct, name: string): RuntimeValue | undefined {
119+ return s.fields.get(name);
120+}
121+
122+function text(v: RuntimeValue | undefined): string | null {
123+ if (typeof v === "string") return v;
124+ if (typeof v === "object" && v !== null && (v as { kind?: string }).kind === "char") return (v as { value: string }).value;
125+ return null;
126+}
127+
128+// ---------- Compilation ----------
129+
130+/**
131+ * Runs the script and checks the `design` struct it leaves in the
132+ * workspace. The field functions are not called here; evaluatePlate samples
133+ * them on the grid.
134+ */
135+export function compileDesign(source: string): Design {
136+ const vars = runDesignScript(source);
137+ const d = vars.design;
138+ if (d === undefined) throw new DesignError("the script must fill in a struct named design (design.material, design.bounds, design.thickness, ...)");
139+ if (!isStruct(d)) throw new DesignError("design must be a struct");
140+
141+ const nameV = field(d, "name");
142+ const name = (nameV !== undefined ? text(nameV) : null)?.trim() || "Untitled design";
143+
144+ const m = field(d, "material");
145+ if (!isStruct(m)) throw new DesignError("design.material must be a struct with fields E, nu, rho");
146+ const material: Material = { E: num(field(m, "E"), "design.material.E", 1e6), nu: num(field(m, "nu"), "design.material.nu", 0), rho: num(field(m, "rho"), "design.material.rho", 1) };
147+ if (material.nu >= 0.5) throw new DesignError("design.material.nu must be below 0.5");
148+
149+ const b = field(d, "bounds");
150+ if (!isStruct(b)) throw new DesignError("design.bounds must be a struct with fields width, height");
151+ const Lx = num(field(b, "width"), "design.bounds.width", 1e-3), Ly = num(field(b, "height"), "design.bounds.height", 1e-3);
152+
153+ const inside = field(d, "inside");
154+ let hasInside = false;
155+ if (!isEmpty(inside)) {
156+ if (!isFunction(inside)) throw new DesignError("design.inside must be a function handle @(x, y, r, theta) returning true inside the plate, for example @(x, y, r, theta) r <= D/2");
157+ hasInside = true;
158+ }
159+
160+ const th = field(d, "thickness");
161+ if (th === undefined) throw new DesignError("design.thickness is required (a number in meters, or a function handle @(x, y, r, theta))");
162+ let thicknessConst: number | null = null;
163+ if (!isFunction(th)) {
164+ thicknessConst = scalar(th);
165+ if (thicknessConst === null || !isFinite(thicknessConst)) throw new DesignError("design.thickness must be a number or a function handle @(x, y, r, theta)");
166+ if (thicknessConst <= 0) throw new DesignError("design.thickness must be positive");
167+ }
168+
169+ const pr = field(d, "profile");
170+ let profileConst: number | null = 0;
171+ if (pr !== undefined && !isEmpty(pr)) {
172+ if (isFunction(pr)) profileConst = null;
173+ else {
174+ profileConst = scalar(pr);
175+ if (profileConst === null || !isFinite(profileConst)) throw new DesignError("design.profile must be a number or a function handle @(x, y, r, theta)");
176+ }
177+ }
178+
179+ const stand = readStand(field(d, "stand"));
180+ const lossConst = readLoss(field(d, "loss"));
181+
182+ return { name, material, Lx, Ly, hasInside, thicknessConst, profileConst, stand, lossConst, source };
183+}
184+
185+function readStand(st: RuntimeValue | undefined): Stand | null {
186+ if (st === undefined || isEmpty(st) || st === false) return null;
187+ if (!isStruct(st)) throw new DesignError("design.stand must be a struct (design.stand.pad, design.stand.topPad, design.stand.preload), or [] for a free plate");
188+ const opt = (s: RuntimeStruct, k: string, what: string, dflt: number, min = -Infinity) => (field(s, k) === undefined || isEmpty(field(s, k)) ? dflt : num(field(s, k), what, min));
189+ const padV = field(st, "pad");
190+ let pad: Pad = { ...DEFAULT_PAD };
191+ if (padV !== undefined && !isEmpty(padV)) {
192+ if (!isStruct(padV)) throw new DesignError("design.stand.pad must be a struct with fields innerRadius, outerRadius, thickness, E, lossFactor");
193+ pad = {
194+ innerRadius: opt(padV, "innerRadius", "design.stand.pad.innerRadius", DEFAULT_PAD.innerRadius, 0),
195+ outerRadius: opt(padV, "outerRadius", "design.stand.pad.outerRadius", DEFAULT_PAD.outerRadius, 0),
196+ thickness: opt(padV, "thickness", "design.stand.pad.thickness", DEFAULT_PAD.thickness, 1e-5),
197+ E: opt(padV, "E", "design.stand.pad.E", DEFAULT_PAD.E, 1),
198+ lossFactor: opt(padV, "lossFactor", "design.stand.pad.lossFactor", DEFAULT_PAD.lossFactor, 0),
199+ };
200+ if (pad.outerRadius <= pad.innerRadius) throw new DesignError("design.stand.pad.outerRadius must exceed innerRadius");
201+ }
202+ return {
203+ x: opt(st, "x", "design.stand.x", 0),
204+ y: opt(st, "y", "design.stand.y", 0),
205+ pad,
206+ topPad: bool(field(st, "topPad"), "design.stand.topPad", true),
207+ preload: opt(st, "preload", "design.stand.preload", DEFAULT_PRELOAD, 0),
208+ };
209+}
210+
211+/** The losses when they can be read without the script: null when T60 is a function handle. */
212+function readLoss(lo: RuntimeValue | undefined): Loss | null {
213+ if (lo === undefined || isEmpty(lo)) return defaultLoss();
214+ if (!isStruct(lo)) throw new DesignError("design.loss must be a struct with the field T60: a decay time in seconds, a table [f T60; ...], or a function handle @(f)");
215+ const t = field(lo, "T60");
216+ if (t === undefined || isEmpty(t)) return defaultLoss();
217+ if (isFunction(t)) return null;
218+ const s = scalar(t);
219+ if (s !== null) {
220+ if (!(s > 0) || !isFinite(s)) throw new DesignError("design.loss.T60 must be a positive decay time in seconds");
221+ return constantLoss(s);
222+ }
223+ if (isTensor(t) && t.shape.length === 2 && t.shape[1] === 2 && t.shape[0] >= 1) {
224+ const rows = t.shape[0];
225+ const fs: number[] = [], ts: number[] = [];
226+ for (let r = 0; r < rows; r++) {
227+ const f = t.data[r], t60 = t.data[rows + r]; // column-major
228+ if (!(f > 0) || !(t60 > 0) || !isFinite(f) || !isFinite(t60)) throw new DesignError("design.loss.T60 table rows must be [frequency T60] with positive values");
229+ fs.push(f); ts.push(t60);
230+ }
231+ return lossFromTable(fs, ts);
232+ }
233+ throw new DesignError("design.loss.T60 must be a number, a two-column table [f T60; ...], or a function handle @(f)");
234+}
235+
236+// ---------- Evaluation on a grid ----------
237+
238+/** Subtracts the weighted least-squares plane a + b x + c y from f over the plate nodes. */
239+export function removePlane(f: Float64Array, g: Grid): void {
240+ let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0, sf = 0, sxf = 0, syf = 0;
241+ for (let j = 0; j < g.ny; j++) {
242+ for (let i = 0; i < g.nx; i++) {
243+ const wgt = nodeWeight(g, i, j);
244+ if (wgt === 0) continue;
245+ const x = nodeX(g, i), y = nodeY(g, j), v = f[j * g.nx + i];
246+ sw += wgt; sx += wgt * x; sy += wgt * y;
247+ sxx += wgt * x * x; sxy += wgt * x * y; syy += wgt * y * y;
248+ sf += wgt * v; sxf += wgt * x * v; syf += wgt * y * v;
249+ }
250+ }
251+ if (sw === 0) return;
252+ const det = sw * (sxx * syy - sxy * sxy) - sx * (sx * syy - sxy * sy) + sy * (sx * sxy - sxx * sy);
253+ if (Math.abs(det) < 1e-300) return;
254+ const a = (sf * (sxx * syy - sxy * sxy) - sx * (sxf * syy - sxy * syf) + sy * (sxf * sxy - sxx * syf)) / det;
255+ const b = (sw * (sxf * syy - sxy * syf) - sf * (sx * syy - sxy * sy) + sy * (sx * syf - sxf * sy)) / det;
256+ const c = (sw * (sxx * syf - sxf * sxy) - sx * (sx * syf - sxf * sy) + sf * (sx * sxy - sxx * sy)) / det;
257+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] -= a + b * nodeX(g, i) + c * nodeY(g, j);
258+}
259+
260+/** The trailer appended to the script to sample the fields on the grid (all names start with cymbalsim_). */
261+function trailer(d: Design, g: Grid): string {
262+ const fmt = (v: number) => v.toPrecision(17);
263+ return [
264+ "",
265+ "% ---- appended by the simulator: sample the fields on the grid ----",
266+ `cymbalsim_nx = ${g.nx}; cymbalsim_ny = ${g.ny};`,
267+ `cymbalsim_hx = ${fmt(g.hx)}; cymbalsim_hy = ${fmt(g.hy)};`,
268+ `[cymbalsim_X, cymbalsim_Y] = meshgrid(((0:cymbalsim_nx-1) - (cymbalsim_nx-1)/2) * cymbalsim_hx, ((0:cymbalsim_ny-1) - (cymbalsim_ny-1)/2) * cymbalsim_hy);`,
269+ "cymbalsim_R = hypot(cymbalsim_X, cymbalsim_Y); cymbalsim_T = atan2(cymbalsim_Y, cymbalsim_X);",
270+ "cymbalsim_H = cymbalsim_field(design.thickness, 'thickness', cymbalsim_X, cymbalsim_Y, cymbalsim_R, cymbalsim_T);",
271+ d.profileConst === null
272+ ? "cymbalsim_Z = cymbalsim_field(design.profile, 'profile', cymbalsim_X, cymbalsim_Y, cymbalsim_R, cymbalsim_T);"
273+ : `cymbalsim_Z = ${fmt(d.profileConst)} * ones(cymbalsim_ny, cymbalsim_nx);`,
274+ d.hasInside
275+ ? [
276+ `[cymbalsim_XC, cymbalsim_YC] = meshgrid(((0:cymbalsim_nx-2) - (cymbalsim_nx-2)/2) * cymbalsim_hx, ((0:cymbalsim_ny-2) - (cymbalsim_ny-2)/2) * cymbalsim_hy);`,
277+ "cymbalsim_IN = cymbalsim_field(design.inside, 'inside', cymbalsim_XC, cymbalsim_YC, hypot(cymbalsim_XC, cymbalsim_YC), atan2(cymbalsim_YC, cymbalsim_XC));",
278+ ].join("\n")
279+ : "",
280+ d.lossConst === null
281+ ? [`cymbalsim_F = [${Array.from(LOSS_FREQS, fmt).join(" ")}];`, "cymbalsim_T60 = cymbalsim_t60(design.loss.T60, cymbalsim_F);"].join("\n")
282+ : "",
283+ "",
284+ ].join("\n");
285+}
286+
287+/** A real [rows, cols] matrix from the workspace as a Float64Array in column-major order. */
288+function matrix(vars: Record<string, RuntimeValue>, name: string, rows: number, cols: number, what: string): Float64Array {
289+ const v = vars[name];
290+ if (!isTensor(v) || v.shape.length !== 2 || v.shape[0] !== rows || v.shape[1] !== cols) throw new DesignError(`${what} did not produce a ${rows} x ${cols} matrix`);
291+ if (v.imag && v.imag.some((x) => x !== 0)) throw new DesignError(`${what} returned complex values`);
292+ return v.data;
293+}
294+
295+/**
296+ * Samples the design on a grid of about the requested spacing: the mask from
297+ * the outline at the cell centers, the thickness at the nodes (which must be
298+ * positive on the plate), the profile at the nodes with its mean plane
299+ * removed, and the decay times when they are a function. Only the slopes of
300+ * the profile enter the physics, so its mean is immaterial, but its tilt is
301+ * not: the von Karman strains are not invariant under rotation, and a tilted
302+ * stress-free shape would add a spurious membrane coupling.
303+ */
304+export function evaluatePlate(d: Design, spacing: number): Plate {
305+ const full = makeGrid(d.Lx, d.Ly, spacing);
306+ const { nx, ny } = full;
307+ const vars = runDesignScript(d.source + trailer(d, full), d.source.split("\n").length);
308+ // meshgrid matrices are [ny, nx] in column-major order: entry (i, j) sits at i * ny + j.
309+ const H = matrix(vars, "cymbalsim_H", ny, nx, "thickness");
310+ const Z = matrix(vars, "cymbalsim_Z", ny, nx, "profile");
311+ let g = full;
312+ if (d.hasInside) {
313+ const IN = matrix(vars, "cymbalsim_IN", ny - 1, nx - 1, "inside");
314+ g = withCellMask(full, (ci, cj) => IN[ci * (ny - 1) + cj] !== 0);
315+ }
316+ let loss = d.lossConst;
317+ if (loss === null) {
318+ const T = matrix(vars, "cymbalsim_T60", 1, LOSS_FREQS.length, "design.loss.T60");
319+ for (let i = 0; i < T.length; i++) if (!(T[i] > 0) || !isFinite(T[i])) throw new DesignError(`design.loss.T60 must be positive; it is ${T[i]} at ${LOSS_FREQS[i].toFixed(0)} Hz`);
320+ loss = { freqs: LOSS_FREQS, t60: Float64Array.from(T) };
321+ }
322+ const h = new Float64Array(g.n), w0 = new Float64Array(g.n);
323+ let any = false;
324+ for (let j = 0; j < ny; j++) {
325+ for (let i = 0; i < nx; i++) {
326+ if (!insideNode(g, i, j)) continue;
327+ any = true;
328+ const k = j * nx + i, m = i * ny + j;
329+ const x = nodeX(g, i), y = nodeY(g, j);
330+ const at = `(${x.toFixed(4)}, ${y.toFixed(4)})`;
331+ if (!isFinite(H[m])) throw new DesignError(`thickness is ${H[m]} at ${at}`);
332+ if (H[m] <= 0) throw new DesignError(`thickness must be positive; it is ${H[m]} at ${at}`);
333+ if (!isFinite(Z[m])) throw new DesignError(`profile is ${Z[m]} at ${at}`);
334+ h[k] = H[m];
335+ w0[k] = Z[m];
336+ }
337+ }
338+ if (!any) throw new DesignError("the outline contains no grid cells; enlarge it or reduce the grid spacing");
339+ removePlane(w0, g);
340+ for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) if (!insideNode(g, i, j)) w0[j * nx + i] = 0;
341+ return { name: d.name, Lx: d.Lx, Ly: d.Ly, grid: g, mat: d.material, h, w0, stand: d.stand, loss };
342+}
343+
344+/** Whether the plate is flat (no profile), in which case bending and in-plane motion decouple in the linear model. */
345+export function isFlat(pl: Plate): boolean {
346+ for (let k = 0; k < pl.w0.length; k++) if (pl.w0[k] !== 0) return false;
347+ return true;
348+}
349+
350+/** Peak-to-peak height and the largest slope of the profile over the plate nodes. */
351+export function profileStats(pl: Plate): { peakToPeak: number; maxSlope: number } {
352+ const g = pl.grid, w0 = pl.w0;
353+ let lo = Infinity, hi = -Infinity, maxSlope = 0;
354+ const at = (i: number, j: number) => w0[j * g.nx + i];
355+ const slope = (i: number, j: number, di: number, dj: number, hg: number) => {
356+ const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj);
357+ if (hasP && hasM) return (at(i + di, j + dj) - at(i - di, j - dj)) / (2 * hg);
358+ if (hasP) return (at(i + di, j + dj) - at(i, j)) / hg;
359+ if (hasM) return (at(i, j) - at(i - di, j - dj)) / hg;
360+ return 0;
361+ };
362+ for (let j = 0; j < g.ny; j++) {
363+ for (let i = 0; i < g.nx; i++) {
364+ if (!insideNode(g, i, j)) continue;
365+ const v = at(i, j);
366+ lo = Math.min(lo, v); hi = Math.max(hi, v);
367+ maxSlope = Math.max(maxSlope, Math.hypot(slope(i, j, 1, 0, g.hx), slope(i, j, 0, 1, g.hy)));
368+ }
369+ }
370+ return { peakToPeak: isFinite(hi - lo) ? hi - lo : 0, maxSlope };
371+}
src/loss.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+// Losses given as decay times. The design script gives T60, the time in
2+// which a partial decays by 60 dB, as a number, a table [f T60] or a
3+// function of frequency; the design layer samples it on LOSS_FREQS, and this
4+// module interpolates it, converts it to decay rates for the modes, and fits
5+// the two-parameter model of the full simulation to it.
6+
7+import { type Loss, type Material, bendingConstant } from "./params.ts";
8+
9+/** Frequencies at which T60 is sampled: 128 points from 10 Hz to 40 kHz, log-spaced. */
10+export const LOSS_FREQS: Float64Array = (() => {
11+ const n = 128, f = new Float64Array(n);
12+ for (let i = 0; i < n; i++) f[i] = 10 * Math.pow(4000, i / (n - 1));
13+ return f;
14+})();
15+
16+/** ln(1000): a rate sigma [1/s] and a decay time relate by sigma = LN1000 / T60. */
17+export const LN1000 = Math.log(1000);
18+
19+/** The default losses: 4 s at low frequencies, falling to about 1 s at 5 kHz. */
20+export function defaultT60(f: number): number {
21+ return 4 / (1 + f / 2000);
22+}
23+
24+export function lossFromFunction(fn: (f: number) => number): Loss {
25+ const t60 = new Float64Array(LOSS_FREQS.length);
26+ for (let i = 0; i < t60.length; i++) t60[i] = fn(LOSS_FREQS[i]);
27+ return { freqs: LOSS_FREQS, t60 };
28+}
29+
30+export function defaultLoss(): Loss {
31+ return lossFromFunction(defaultT60);
32+}
33+
34+export function constantLoss(t60: number): Loss {
35+ return lossFromFunction(() => t60);
36+}
37+
38+/** Log-log interpolation of a table of (frequency, T60) rows, clamped at the ends. */
39+export function lossFromTable(freqs: ArrayLike<number>, t60s: ArrayLike<number>): Loss {
40+ const n = freqs.length;
41+ const idx = Array.from({ length: n }, (_, i) => i).sort((a, b) => freqs[a] - freqs[b]);
42+ const fs = idx.map((i) => freqs[i]), ts = idx.map((i) => t60s[i]);
43+ return lossFromFunction((f) => interpLogLog(fs, ts, f));
44+}
45+
46+function interpLogLog(fs: number[], ts: number[], f: number): number {
47+ const n = fs.length;
48+ if (n === 1 || f <= fs[0]) return ts[0];
49+ if (f >= fs[n - 1]) return ts[n - 1];
50+ let i = 0;
51+ while (i < n - 2 && fs[i + 1] < f) i++;
52+ const t = (Math.log(f) - Math.log(fs[i])) / (Math.log(fs[i + 1]) - Math.log(fs[i]));
53+ return Math.exp(Math.log(ts[i]) * (1 - t) + Math.log(ts[i + 1]) * t);
54+}
55+
56+/** T60 at a frequency, by log-log interpolation of the sampled curve, clamped at its ends. */
57+export function t60At(loss: Loss, f: number): number {
58+ return interpLogLog(Array.from(loss.freqs), Array.from(loss.t60), f);
59+}
60+
61+/** Decay rate sigma [1/s] of a partial at frequency f: its amplitude decays as exp(-sigma t). */
62+export function decayRate(loss: Loss, f: number): number {
63+ return LN1000 / t60At(loss, f);
64+}
65+
66+/**
67+ * The two-parameter loss model of the full simulation. A bending mode of
68+ * angular frequency omega decays at the rate sigma0 + sigma1 k^2 with
69+ * k^2 = omega / kappa; the fit is a non-negative least-squares fit of that
70+ * line to the design's decay rates on log-spaced frequencies from 50 Hz to
71+ * 10 kHz, at the mean thickness of the plate.
72+ */
73+export interface SigmaFit {
74+ sigma0: number; // [1/s]
75+ sigma1: number; // [m^2/s]
76+ kappa: number; // [m^2/s] bending constant used for the fit
77+}
78+
79+export function fitSigma(loss: Loss, mat: Material, hMean: number, fLo = 50, fHi = 10000, n = 40): SigmaFit {
80+ const kappa = bendingConstant(mat, hMean);
81+ const x: number[] = [], y: number[] = [];
82+ for (let i = 0; i < n; i++) {
83+ const f = fLo * Math.pow(fHi / fLo, i / (n - 1));
84+ x.push((2 * Math.PI * f) / kappa); // k^2
85+ y.push(decayRate(loss, f));
86+ }
87+ // Unconstrained least squares, then clamp: with two parameters the
88+ // non-negative solution is the unconstrained one, or one parameter at
89+ // zero with the other refit.
90+ let sx = 0, sy = 0, sxx = 0, sxy = 0;
91+ for (let i = 0; i < n; i++) { sx += x[i]; sy += y[i]; sxx += x[i] * x[i]; sxy += x[i] * y[i]; }
92+ const det = n * sxx - sx * sx;
93+ let s1 = (n * sxy - sx * sy) / det, s0 = (sy - s1 * sx) / n;
94+ if (s1 < 0) { s1 = 0; s0 = sy / n; }
95+ if (s0 < 0) { s0 = 0; s1 = sxy / sxx; }
96+ return { sigma0: s0, sigma1: s1, kappa };
97+}
98+
99+/** Decay rate of the fitted model at a frequency. */
100+export function fittedRate(fit: SigmaFit, f: number): number {
101+ return fit.sigma0 + (fit.sigma1 * 2 * Math.PI * f) / fit.kappa;
102+}
src/main.tsadded+819−0View file
@@ -0,0 +1,819 @@
1+// The application: the design script, the two run modes (the linear modal
2+// model on the CPU workers and the full nonlinear simulation on the GPU),
3+// the plate view, the movie, the audio, and the spectrogram.
4+
5+import { compileDesign, evaluatePlate, isFlat, profileStats, type Design, DesignError } from "./design.ts";
6+import { PRESETS, DEFAULT_PRESET, presetById } from "./presets.ts";
7+import {
8+ type Plate,
9+ type SimParams,
10+ defaultParams,
11+ countInside,
12+ massProperties,
13+ meanThickness,
14+ thicknessRange,
15+ standRates,
16+ strikePoint,
17+ strikePatch,
18+ listenerPosition,
19+ dispersionFrequency,
20+ inPlaneSpeed,
21+ CFL,
22+} from "./params.ts";
23+import { fitSigma, fittedRate, t60At, LN1000, decayRate } from "./loss.ts";
24+import { assemble, type LinearSystem } from "./model/assemble.ts";
25+import { maxAngularFrequency, fullLoss, type FullLoss } from "./model/reference.ts";
26+import { type ModeSet } from "./modal/modes.ts";
27+import { WorkerPool } from "./modal/pool.ts";
28+import { modalModel, modeShapeOnGrid, type ModalModel, type ContactResult } from "./modal/synth.ts";
29+import { Simulation } from "./sim/simulation.ts";
30+import { runSelfTest } from "./sim/selftest.ts";
31+import { PlateRenderer, type ViewMode, type ColorMode } from "./render/renderer.ts";
32+import { Spectrogram } from "./audio/spectrogram.ts";
33+import { Player } from "./audio/player.ts";
34+import { encodeWav } from "./audio/wav.ts";
35+import { levelGain, applyLevel, peakDbfs, peakSpl, type LevelMode } from "./audio/level.ts";
36+import { DesignEditor } from "./ui/editor.ts";
37+import { ParamForm, formatNumber } from "./ui/form.ts";
38+import { ModeBrowser } from "./ui/modes.ts";
39+
40+type RunMode = "linear" | "full";
41+type ViewState = "design" | "mode" | "live" | "movie";
42+
43+const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
44+
45+/** A recording: the listener signal and the movie of one run, from either model. */
46+interface Recording {
47+ audio: Float32Array;
48+ samples: number;
49+ fs: number;
50+ frames: Float32Array[];
51+ fps: number;
52+ source: RunMode;
53+}
54+
55+class App {
56+ // GPU
57+ device: GPUDevice | null = null;
58+ // Design and plate
59+ design: Design | null = null;
60+ plate: Plate | null = null;
61+ plateSpacing = 0;
62+ designGeneration = 0;
63+ // Linear model
64+ pool: WorkerPool | null = null;
65+ sys: LinearSystem | null = null;
66+ modes: ModeSet | null = null;
67+ model: ModalModel | null = null;
68+ modesGeneration = 0;
69+ modesBusy: Promise<void> = Promise.resolve();
70+ synthBusy = false;
71+ synthPending = false;
72+ synthesized = false;
73+ lastContact: ContactResult | null = null;
74+ // Full simulation
75+ sim: Simulation | null = null;
76+ running = false;
77+ paused = false;
78+ // Recording and view
79+ rec: Recording | null = null;
80+ view: ViewState = "design";
81+ frameIndex = 0;
82+ runMode: RunMode = "linear";
83+ // UI
84+ editor!: DesignEditor;
85+ form!: ParamForm;
86+ modeBrowser!: ModeBrowser;
87+ renderer!: PlateRenderer;
88+ spec!: Spectrogram;
89+ player = new Player();
90+ levelMode: LevelMode = "absolute";
91+ gainDb = -40;
92+ showTicks = true;
93+ viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 20, range: 0 };
94+ // Controls
95+ runModeSel!: HTMLSelectElement;
96+ durationInput!: HTMLInputElement;
97+ startBtn!: HTMLButtonElement;
98+ pauseBtn!: HTMLButtonElement;
99+ stopBtn!: HTMLButtonElement;
100+ continueBtn!: HTMLButtonElement;
101+ runStatus!: HTMLDivElement;
102+ scrub!: HTMLInputElement;
103+ timeLabel!: HTMLSpanElement;
104+ playBtn!: HTMLButtonElement;
105+ liveBtn!: HTMLButtonElement;
106+ designBtn!: HTMLButtonElement;
107+ peakLabel!: HTMLSpanElement;
108+ downloadBtn!: HTMLButtonElement;
109+ overlay!: HTMLDivElement;
110+ synthTimer: number | null = null;
111+
112+ async init(): Promise<void> {
113+ const url = new URLSearchParams(location.search);
114+ if (!url.has("nogpu") && navigator.gpu) {
115+ try {
116+ const adapter = await navigator.gpu.requestAdapter();
117+ if (adapter) this.device = await adapter.requestDevice();
118+ } catch (e) {
119+ console.warn("WebGPU unavailable", e);
120+ }
121+ }
122+ if (typeof Worker !== "undefined") this.pool = new WorkerPool(WorkerPool.defaultSize());
123+
124+ this.buildControls();
125+ this.buildForm();
126+ this.buildViewToolbar();
127+ this.buildMovieToolbar();
128+ this.buildSpecToolbar();
129+ this.buildSplitter();
130+
131+ this.renderer = new PlateRenderer($("view"), this.device);
132+ if (!this.renderer.has3d) this.viewOpts.mode = "2d";
133+ this.renderer.setOptions(this.viewOpts);
134+ this.overlay = $("viewOverlay");
135+ const p = this.form.get();
136+ this.spec = new Spectrogram($("spec"), { fs: p.fs, window: 2048, hop: 256, maxHz: 12000, dbRange: 80, logFreq: false });
137+ this.spec.setDuration(p.duration);
138+ this.modeBrowser = new ModeBrowser($("modes"), (m) => this.showMode(m));
139+ this.editor = new DesignEditor($("design"), { onSource: (src) => this.onSource(src) });
140+
141+ if (url.has("mode")) this.setRunMode(url.get("mode") === "full" ? "full" : "linear");
142+ else this.setRunMode(this.device ? "linear" : "linear");
143+ const preset = url.get("preset");
144+ this.editor.selectPreset(preset && presetById(preset) ? preset : DEFAULT_PRESET);
145+ this.form.applyUrl(url);
146+ this.durationInput.value = String(this.form.get().duration);
147+
148+ const loop = () => {
149+ this.renderer.render();
150+ requestAnimationFrame(loop);
151+ };
152+ requestAnimationFrame(loop);
153+ window.addEventListener("resize", () => { this.renderer.resize(); this.spec.resize(); });
154+ this.renderer.resize();
155+ this.spec.resize();
156+
157+ // Test hooks.
158+ (window as unknown as { cymbalsim: unknown }).cymbalsim = {
159+ app: this,
160+ selftest: () => this.selfTest(),
161+ runLinear: () => this.runLinear(),
162+ runFull: () => this.runFull(),
163+ modesReady: () => this.modesBusy,
164+ status: () => this.testStatus(),
165+ };
166+ if (url.has("selftest")) {
167+ const r = await this.selfTest();
168+ this.overlay.textContent = r;
169+ }
170+ if (url.has("autostart")) {
171+ await this.modesBusy;
172+ this.start();
173+ }
174+ }
175+
176+ // ---------- Controls ----------
177+
178+ private buildControls(): void {
179+ const c = $("controls");
180+ this.runModeSel = document.createElement("select");
181+ this.runModeSel.innerHTML = `<option value="linear">Linear (modal)</option><option value="full">Full (nonlinear, GPU)</option>`;
182+ this.runModeSel.addEventListener("change", () => this.setRunMode(this.runModeSel.value as RunMode));
183+ const durLabel = document.createElement("label");
184+ durLabel.textContent = "Duration";
185+ this.durationInput = document.createElement("input");
186+ this.durationInput.type = "number"; this.durationInput.min = "0.1"; this.durationInput.step = "0.5"; this.durationInput.style.width = "60px";
187+ this.durationInput.addEventListener("change", () => {
188+ const d = Math.max(0.1, Number(this.durationInput.value) || 1);
189+ this.form.set("duration", d);
190+ this.spec.setDuration(d);
191+ if (this.sim) this.sim.setDuration(d);
192+ this.updateButtons();
193+ });
194+ const s = document.createElement("span"); s.textContent = " s";
195+ durLabel.append(this.durationInput, s);
196+ this.startBtn = this.button("Start", () => this.start(), "primary");
197+ this.pauseBtn = this.button("Pause", () => this.pause());
198+ this.stopBtn = this.button("Stop", () => this.stopRun());
199+ this.continueBtn = this.button("Continue", () => this.continueRun());
200+ c.append(this.runModeSel, durLabel, this.startBtn, this.pauseBtn, this.stopBtn, this.continueBtn);
201+ this.runStatus = document.createElement("div");
202+ this.runStatus.className = "status";
203+ $("sidebarHead").append(this.runStatus);
204+ }
205+
206+ private button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
207+ const b = document.createElement("button");
208+ b.textContent = label;
209+ if (cls) b.className = cls;
210+ b.addEventListener("click", onClick);
211+ return b;
212+ }
213+
214+ private buildForm(): void {
215+ this.form = new ParamForm(
216+ $("params"),
217+ [
218+ {
219+ title: "Strike",
220+ notes: true,
221+ fields: [
222+ { key: "strikeFracX", label: "Position x (fraction of width)", step: 0.05, min: 0, max: 1 },
223+ { key: "strikeFracY", label: "Position y (fraction of height)", step: 0.05, min: 0, max: 1 },
224+ { key: "strikeVelocity", label: "Stick speed", unit: "m/s", step: 0.5, min: 0.01 },
225+ { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, min: 1 },
226+ { key: "contactStiffness", label: "Contact stiffness K", unit: "N/m^a", step: 1e8, min: 1e5 },
227+ { key: "contactExponent", label: "Contact exponent a", step: 0.1, min: 1, max: 3 },
228+ { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, min: 0.5 },
229+ ],
230+ },
231+ {
232+ title: "Listener",
233+ fields: [
234+ { key: "listenerDist", label: "Distance", unit: "m", step: 0.1, min: 0.05, live: true },
235+ { key: "listenerAngle", label: "Angle from the normal", unit: "deg", step: 5, min: 0, max: 90, live: true },
236+ { key: "listenerAzimuth", label: "Azimuth", unit: "deg", step: 15, min: -180, max: 180, live: true },
237+ ],
238+ },
239+ {
240+ title: "Grid and numerics",
241+ notes: true,
242+ fields: [
243+ { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 1, min: 2 },
244+ { key: "modeMaxFreq", label: "Modes up to", unit: "Hz", step: 1000, min: 200 },
245+ { key: "padLiftoff", label: "Pads lift off (full model)", kind: "checkbox" },
246+ { key: "fs", label: "Sample rate", kind: "select", options: [{ value: 44100, label: "44.1 kHz" }, { value: 48000, label: "48 kHz" }, { value: 96000, label: "96 kHz" }] },
247+ { key: "fps", label: "Movie frame rate", kind: "select", options: [{ value: 60, label: "60" }, { value: 120, label: "120" }, { value: 240, label: "240" }] },
248+ ],
249+ },
250+ ],
251+ defaultParams,
252+ (key, _value, live) => this.onParam(key, live),
253+ );
254+ }
255+
256+ private onParam(key: keyof SimParams, live: boolean): void {
257+ const p = this.form.get();
258+ if (key === "spacing" || key === "modeMaxFreq" || key === "fs") {
259+ if (this.design) this.rebuildPlate(this.design);
260+ return;
261+ }
262+ if (key === "fps") { this.updateNotes(); return; }
263+ if (key === "padLiftoff") { this.updateNotes(); return; }
264+ if (live && this.sim) this.sim.setListener(p);
265+ if (key.startsWith("strike") || key.startsWith("stick") || key.startsWith("contact") || key.startsWith("listener")) {
266+ this.updateMarkers();
267+ this.updateNotes();
268+ if (this.runMode === "linear" && this.synthesized) this.scheduleSynth();
269+ }
270+ }
271+
272+ private buildViewToolbar(): void {
273+ const t = $("viewToolbar");
274+ const modeSel = document.createElement("select");
275+ modeSel.innerHTML = `<option value="3d">3D</option><option value="2d">2D</option>`;
276+ modeSel.addEventListener("change", () => { this.viewOpts.mode = modeSel.value as ViewMode; this.renderer.setOptions(this.viewOpts); });
277+ const colorSel = document.createElement("select");
278+ colorSel.innerHTML = `<option value="height">Height</option><option value="thickness">Thickness</option>`;
279+ colorSel.addEventListener("change", () => { this.viewOpts.color = colorSel.value as ColorMode; this.renderer.setOptions(this.viewOpts); });
280+ const exag = document.createElement("input");
281+ exag.type = "number"; exag.min = "1"; exag.step = "5"; exag.value = String(this.viewOpts.exaggeration); exag.style.width = "60px";
282+ exag.addEventListener("change", () => { this.viewOpts.exaggeration = Math.max(0.1, Number(exag.value) || 1); this.renderer.setOptions(this.viewOpts); });
283+ const range = document.createElement("input");
284+ range.type = "number"; range.min = "0"; range.step = "0.1"; range.value = "0"; range.style.width = "60px";
285+ range.addEventListener("change", () => { this.viewOpts.range = Math.max(0, Number(range.value) || 0) * 1e-3; this.renderer.setOptions(this.viewOpts); });
286+ const prof = document.createElement("input");
287+ prof.type = "checkbox"; prof.checked = this.viewOpts.showProfile;
288+ prof.addEventListener("change", () => { this.viewOpts.showProfile = prof.checked; this.renderer.setOptions(this.viewOpts); });
289+ const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
290+ t.append(lab("View", modeSel), lab("Color", colorSel), lab("Exaggeration", exag), lab("Range (mm, 0 = auto)", range), lab("Show profile", prof));
291+ if (!this.device) {
292+ modeSel.value = "2d"; modeSel.disabled = true;
293+ const w = document.createElement("span"); w.id = "nogpu"; w.textContent = "WebGPU is not available: the 2D view and the linear model only.";
294+ t.append(w);
295+ }
296+ }
297+
298+ private buildMovieToolbar(): void {
299+ const t = $("movieToolbar");
300+ this.playBtn = this.button("Play", () => this.togglePlay());
301+ this.liveBtn = this.button("Live", () => this.setView("live"));
302+ this.designBtn = this.button("Design", () => this.setView("design"));
303+ this.scrub = document.createElement("input");
304+ this.scrub.type = "range"; this.scrub.className = "scrub"; this.scrub.min = "0"; this.scrub.max = "0"; this.scrub.value = "0";
305+ this.scrub.addEventListener("input", () => { this.frameIndex = Number(this.scrub.value); this.setView("movie"); this.showFrame(); });
306+ this.timeLabel = document.createElement("span");
307+ this.timeLabel.textContent = "0.000 s";
308+ const levelSel = document.createElement("select");
309+ levelSel.innerHTML = `<option value="absolute">Absolute level</option><option value="loudness">Normalize loudness</option><option value="peak">Normalize peak</option>`;
310+ levelSel.addEventListener("change", () => { this.levelMode = levelSel.value as LevelMode; this.updatePeak(); });
311+ const gain = document.createElement("input");
312+ gain.type = "range"; gain.min = "-60"; gain.max = "40"; gain.step = "1"; gain.value = String(this.gainDb);
313+ const gainLabel = document.createElement("span"); gainLabel.textContent = `${this.gainDb} dB`;
314+ gain.addEventListener("input", () => { this.gainDb = Number(gain.value); gainLabel.textContent = `${this.gainDb} dB`; this.updatePeak(); });
315+ this.peakLabel = document.createElement("span");
316+ this.downloadBtn = this.button("Download .wav", () => this.download());
317+ const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
318+ t.append(this.playBtn, this.liveBtn, this.designBtn, this.scrub, this.timeLabel, levelSel, lab("Gain", gain, gainLabel), this.peakLabel, this.downloadBtn);
319+ }
320+
321+ private buildSpecToolbar(): void {
322+ const t = $("specToolbar");
323+ const sel = (opts: [string, string][], value: string, onChange: (v: string) => void) => {
324+ const s = document.createElement("select");
325+ for (const [v, l] of opts) { const o = document.createElement("option"); o.value = v; o.textContent = l; s.append(o); }
326+ s.value = value;
327+ s.addEventListener("change", () => onChange(s.value));
328+ return s;
329+ };
330+ const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
331+ const win = sel([["512", "512"], ["1024", "1024"], ["2048", "2048"], ["4096", "4096"]], "2048", (v) => this.spec.setOptions({ window: Number(v), hop: Number(v) / 8 }));
332+ const maxHz = sel([["4000", "4 kHz"], ["8000", "8 kHz"], ["12000", "12 kHz"], ["16000", "16 kHz"], ["20000", "20 kHz"]], "12000", (v) => this.spec.setOptions({ maxHz: Number(v) }));
333+ const db = sel([["60", "60 dB"], ["80", "80 dB"], ["100", "100 dB"]], "80", (v) => this.spec.setOptions({ dbRange: Number(v) }));
334+ const log = document.createElement("input"); log.type = "checkbox";
335+ log.addEventListener("change", () => this.spec.setOptions({ logFreq: log.checked }));
336+ const ticks = document.createElement("input"); ticks.type = "checkbox"; ticks.checked = this.showTicks;
337+ ticks.addEventListener("change", () => { this.showTicks = ticks.checked; this.updateTicks(); });
338+ t.append(lab("Spectrogram window", win), lab("Max", maxHz), lab("Range", db), lab("Log frequency", log), lab("Modal frequencies", ticks));
339+ }
340+
341+ private buildSplitter(): void {
342+ const sp = $("splitter"), sb = $("sidebar");
343+ const saved = localStorage.getItem("cymbalsim.sidebar");
344+ if (saved) sb.style.width = saved;
345+ let dragging = false;
346+ sp.addEventListener("mousedown", (e) => { dragging = true; e.preventDefault(); });
347+ window.addEventListener("mousemove", (e) => {
348+ if (!dragging) return;
349+ const w = Math.max(280, Math.min(window.innerWidth - 300, e.clientX));
350+ sb.style.width = `${w}px`;
351+ this.renderer.resize(); this.spec.resize();
352+ });
353+ window.addEventListener("mouseup", () => { if (dragging) { dragging = false; localStorage.setItem("cymbalsim.sidebar", sb.style.width); } });
354+ sp.addEventListener("dblclick", () => { sb.style.width = ""; localStorage.removeItem("cymbalsim.sidebar"); this.renderer.resize(); this.spec.resize(); });
355+ }
356+
357+ // ---------- Design ----------
358+
359+ private onSource(source: string): void {
360+ if (this.running) return;
361+ let d: Design;
362+ try {
363+ d = compileDesign(source);
364+ } catch (e) {
365+ this.editor.setStatus(e instanceof Error ? e.message : String(e), "error");
366+ return;
367+ }
368+ this.editor.setStatus(`Compiled: ${d.name}`, "ok");
369+ this.rebuildPlate(d);
370+ }
371+
372+ private rebuildPlate(d: Design): void {
373+ const p = this.form.get();
374+ let plate: Plate;
375+ try {
376+ plate = evaluatePlate(d, p.spacing);
377+ } catch (e) {
378+ this.editor.setStatus(e instanceof DesignError ? e.message : String(e), "error");
379+ return;
380+ }
381+ this.design = d;
382+ this.plate = plate;
383+ this.plateSpacing = p.spacing;
384+ this.designGeneration++;
385+ this.modes = null; this.model = null; this.sys = null;
386+ this.synthesized = false;
387+ this.lastContact = null;
388+ this.rec = null;
389+ this.spec.reset();
390+ this.spec.setOptions({ fs: p.fs });
391+ this.renderer.setPlate(plate);
392+ this.setView("design");
393+ this.updateMarkers();
394+ this.updateDesignInfo();
395+ this.updateNotes();
396+ this.updateButtons();
397+ if (this.runMode === "linear") this.computeModes();
398+ else this.modeBrowser.clear("Switch to the linear model to compute the modes.");
399+ }
400+
401+ private updateDesignInfo(): void {
402+ const pl = this.plate;
403+ if (!pl) return;
404+ const p = this.form.get();
405+ const g = pl.grid;
406+ const { mass } = massProperties(pl);
407+ const hr = thicknessRange(pl);
408+ const ps = profileStats(pl);
409+ const sr = standRates(pl);
410+ const lines: string[] = [];
411+ lines.push(`${pl.name}: ${(pl.Lx * 1e3).toFixed(0)} x ${(pl.Ly * 1e3).toFixed(0)} mm, E = ${(pl.mat.E / 1e9).toFixed(0)} GPa, nu = ${pl.mat.nu}, rho = ${pl.mat.rho} kg/m^3`);
412+ lines.push(`Thickness ${(hr.min * 1e3).toFixed(2)} to ${(hr.max * 1e3).toFixed(2)} mm, mass ${(mass * 1e3).toFixed(0)} g, grid ${g.nx} x ${g.ny} at ${(g.hx * 1e3).toFixed(2)} mm (${countInside(g)} nodes)`);
413+ if (ps.peakToPeak > 0) {
414+ const warn = ps.maxSlope > 0.1 ? ` <span class="warn">(above the shallow-shell range of about 0.1)</span>` : "";
415+ lines.push(`Profile ${(ps.peakToPeak * 1e3).toFixed(1)} mm peak to peak, largest slope ${ps.maxSlope.toFixed(3)}${warn}`);
416+ } else lines.push("Flat plate (no profile): the linear model is bending only");
417+ if (pl.stand && sr) {
418+ const pad = pl.stand.pad;
419+ lines.push(`Stand: ${pl.stand.topPad ? "two pads" : "bottom pad only"}, ${(pad.innerRadius * 1e3).toFixed(0)} to ${(pad.outerRadius * 1e3).toFixed(0)} mm, ${(pad.thickness * 1e3).toFixed(0)} mm felt at ${(pad.E / 1e6).toFixed(2)} MPa; ${(sr.totalKw / 1e3).toFixed(0)} kN/m, bounce ${sr.bounceHz.toFixed(0)} Hz, ${sr.nodes.length} pad nodes, preload ${(sr.preloadTop * 1e3).toFixed(2)} / ${(sr.preloadBottom * 1e3).toFixed(2)} mm`);
420+ } else lines.push("Free plate (no stand)");
421+ lines.push(`Decay T60: ${t60At(pl.loss, 100).toFixed(2)} s at 100 Hz, ${t60At(pl.loss, 1000).toFixed(2)} s at 1 kHz, ${t60At(pl.loss, 5000).toFixed(2)} s at 5 kHz`);
422+ void p;
423+ $("designInfo").innerHTML = lines.join("\n");
424+ }
425+
426+ private updateNotes(): void {
427+ const pl = this.plate;
428+ if (!pl) return;
429+ const p = this.form.get();
430+ const g = pl.grid;
431+ const hm = meanThickness(pl);
432+ const f3 = dispersionFrequency(pl.mat, hm, g.hx, 0.03), f10 = dispersionFrequency(pl.mat, hm, g.hx, 0.1);
433+ const wmax = maxAngularFrequency(g, pl.mat, thicknessRange(pl).max);
434+ const dtMax = (CFL * 2) / wmax;
435+ const sps = Math.ceil(1 / (p.fs * dtMax));
436+ const dt = 1 / (p.fs * sps);
437+ const fit = fitSigma(pl.loss, pl.mat, hm);
438+ const t60f = (f: number) => (LN1000 / fittedRate(fit, f)).toFixed(2);
439+ const lines = [
440+ `Grid ${g.nx} x ${g.ny}, ${countInside(g)} plate nodes. Grid dispersion lowers the bending partials by 3% near ${(f3 / 1e3).toFixed(1)} kHz and by 10% near ${(f10 / 1e3).toFixed(1)} kHz (at the mean thickness).`,
441+ `Linear model: modes up to ${(p.modeMaxFreq / 1e3).toFixed(1)} kHz${this.modes ? `, ${this.modes.count} found in ${this.modes.seconds.toFixed(1)} s` : ""}${this.modes && this.modes.missing ? ` <span class="warn">(${this.modes.missing} missing)</span>` : ""}.`,
442+ `Full model: time step ${(dt * 1e6).toFixed(2)} us (${sps} per sample, in-plane wave speed ${inPlaneSpeed(pl.mat).toFixed(0)} m/s), ${((p.duration * p.fs * sps) / 1e6).toFixed(1)} million steps for ${p.duration} s. Losses fitted as sigma0 = ${fit.sigma0.toFixed(2)} 1/s, sigma1 = ${fit.sigma1.toExponential(2)} m^2/s: T60 ${t60f(100)} / ${t60f(1000)} / ${t60f(5000)} s at 100 Hz / 1 kHz / 5 kHz (requested ${t60At(pl.loss, 100).toFixed(2)} / ${t60At(pl.loss, 1000).toFixed(2)} / ${t60At(pl.loss, 5000).toFixed(2)}).`,
443+ ];
444+ this.form.setNotes("Grid and numerics", lines.join("\n"));
445+ const patch = strikePatch(pl, p);
446+ const sp = strikePoint(pl, p);
447+ const sl = [`Strike at (${(sp.x * 1e3).toFixed(0)}, ${(sp.y * 1e3).toFixed(0)}) mm on ${patch.nodes.length} node${patch.nodes.length === 1 ? "" : "s"} (${(patch.mass * 1e3).toFixed(2)} g).`];
448+ if (this.lastContact) sl.push(`Last linear strike: peak force ${this.lastContact.peakForce.toFixed(0)} N, contact ${(this.lastContact.contactTime * 1e3).toFixed(2)} ms.`);
449+ if (this.sim) { const st = this.sim.status().stick; sl.push(`Full simulation: peak force ${st.maxForce.toFixed(0)} N${st.withdrawn ? ", stick withdrawn" : ""}.`); }
450+ this.form.setNotes("Strike", sl.join("\n"));
451+ }
452+
453+ private updateMarkers(): void {
454+ const pl = this.plate;
455+ if (!pl) return;
456+ const p = this.form.get();
457+ const sp = strikePoint(pl, p);
458+ const L = listenerPosition(p);
459+ this.renderer.setMarkers({
460+ strike: { x: sp.x, y: sp.y, radius: p.contactRadius },
461+ stand: pl.stand ? { x: pl.stand.x, y: pl.stand.y, innerRadius: pl.stand.pad.innerRadius, outerRadius: pl.stand.pad.outerRadius } : null,
462+ listener: L,
463+ });
464+ }
465+
466+ // ---------- Linear model ----------
467+
468+ private computeModes(): void {
469+ const pl = this.plate, gen = this.designGeneration;
470+ if (!pl) return;
471+ const p = this.form.get();
472+ const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
473+ this.modeBrowser.clear("");
474+ this.modeBrowser.setProgress(0, "Computing the modes...");
475+ this.updateButtons();
476+ this.modesBusy = this.modesBusy.then(async () => {
477+ if (gen !== this.designGeneration) return;
478+ const t0 = performance.now();
479+ let sys: LinearSystem;
480+ try {
481+ sys = assemble(pl);
482+ } catch (e) {
483+ this.modeBrowser.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`);
484+ return;
485+ }
486+ let modes: ModeSet;
487+ try {
488+ if (this.pool) modes = await this.pool.modes(sys, fMax, (d, t) => { if (gen === this.designGeneration) this.modeBrowser.setProgress(t ? d / t : 0, `Computing the modes: slice ${d} of ${t}`); });
489+ else {
490+ const { computeModes, localRunner } = await import("./modal/modes.ts");
491+ modes = await computeModes(sys, fMax, localRunner(sys));
492+ }
493+ } catch (e) {
494+ this.modeBrowser.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`);
495+ return;
496+ }
497+ if (gen !== this.designGeneration) return;
498+ this.sys = sys;
499+ this.modes = modes;
500+ this.model = modalModel(pl, sys, modes);
501+ if (this.pool) await this.pool.setModel(this.model);
502+ const secs = (performance.now() - t0) / 1000;
503+ const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: LN1000 / Math.min(decayRate(pl.loss, f) + this.model!.padSigma[m], 0.99 * 2 * Math.PI * f) }));
504+ const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)";
505+ const info = `${modes.count} ${kind} up to ${(fMax / 1e3).toFixed(1)} kHz in ${secs.toFixed(1)} s (${sys.n} unknowns${modes.missing ? `, ${modes.missing} missing` : ""}). Click a mode to see its shape.`;
506+ this.modeBrowser.setProgress(null);
507+ this.modeBrowser.setModes(rows, info);
508+ this.updateNotes();
509+ this.updateTicks();
510+ this.updateButtons();
511+ });
512+ }
513+
514+ private showMode(m: number | null): void {
515+ if (m === null) { this.setView(this.rec ? "movie" : "design"); return; }
516+ if (!this.model) return;
517+ this.view = "mode";
518+ const shape = modeShapeOnGrid(this.model, m);
519+ // Scale the mass-normalized shape to a visible amplitude: 1 mm peak.
520+ let peak = 0;
521+ for (const v of shape) peak = Math.max(peak, Math.abs(v));
522+ const w = new Float32Array(shape.length);
523+ for (let k = 0; k < w.length; k++) w[k] = (shape[k] / (peak || 1)) * 1e-3;
524+ this.renderer.setField(w);
525+ this.overlay.textContent = `Mode ${m + 1}: ${this.model.freqs[m].toFixed(1)} Hz (shape scaled to 1 mm)`;
526+ }
527+
528+ private scheduleSynth(): void {
529+ if (this.synthTimer !== null) clearTimeout(this.synthTimer);
530+ this.synthTimer = window.setTimeout(() => { this.synthTimer = null; void this.runLinear(); }, 250);
531+ }
532+
533+ /** Synthesizes the strike with the current parameters (linear model). */
534+ async runLinear(): Promise<void> {
535+ const pl = this.plate, model = this.model;
536+ if (!pl || !model) return;
537+ if (this.synthBusy) { this.synthPending = true; return; }
538+ this.synthBusy = true;
539+ const p = this.form.get();
540+ const gen = this.designGeneration;
541+ const t0 = performance.now();
542+ this.runStatus.textContent = "Synthesizing...";
543+ try {
544+ let audio: Float32Array, frames: Float32Array, nFrames: number, contact: ContactResult;
545+ if (this.pool) {
546+ const r = await this.pool.synthesize(model, pl, pl.loss, p, (a, c) => {
547+ if (gen !== this.designGeneration) return;
548+ this.installRecording(a, [], p, "linear");
549+ this.lastContact = c;
550+ this.runStatus.textContent = `Audio ready in ${((performance.now() - t0) / 1000).toFixed(2)} s; movie frames...`;
551+ });
552+ audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
553+ } else {
554+ const { synthesize } = await import("./modal/synth.ts");
555+ const r = synthesize(model, pl, pl.loss, p, true);
556+ audio = r.audio; frames = r.frames; nFrames = r.nFrames; contact = r.contact;
557+ }
558+ if (gen !== this.designGeneration) return;
559+ const n = pl.grid.n;
560+ const frameList: Float32Array[] = [];
561+ for (let f = 0; f < nFrames; f++) frameList.push(frames.subarray(f * n, (f + 1) * n));
562+ this.installRecording(audio, frameList, p, "linear");
563+ this.lastContact = contact;
564+ this.synthesized = true;
565+ const secs = (performance.now() - t0) / 1000;
566+ this.runStatus.textContent = `Linear model: ${model.count} modes, ${p.duration} s synthesized in ${secs.toFixed(2)} s. Peak ${peakSpl(audio).toFixed(0)} dB SPL, contact ${(contact.contactTime * 1e3).toFixed(2)} ms, peak force ${contact.peakForce.toFixed(0)} N.`;
567+ this.updateNotes();
568+ } catch (e) {
569+ this.runStatus.textContent = `Synthesis failed: ${e instanceof Error ? e.message : String(e)}`;
570+ } finally {
571+ this.synthBusy = false;
572+ if (this.synthPending) { this.synthPending = false; void this.runLinear(); }
573+ }
574+ }
575+
576+ // ---------- Full simulation ----------
577+
578+ async runFull(): Promise<void> {
579+ const pl = this.plate;
580+ if (!pl || !this.device || this.running) return;
581+ const p = this.form.get();
582+ this.sim?.destroy();
583+ const hm = meanThickness(pl);
584+ const fit = fitSigma(pl.loss, pl.mat, hm);
585+ const loss: FullLoss = fullLoss(fit, standRates(pl), decayRate(pl.loss, 5000));
586+ this.sim = new Simulation(this.device, pl, p, loss);
587+ this.rec = { audio: new Float32Array(0), samples: 0, fs: p.fs, frames: [new Float32Array(pl.grid.n)], fps: p.fps, source: "full" };
588+ this.spec.reset();
589+ this.spec.setOptions({ fs: p.fs });
590+ this.spec.setDuration(p.duration);
591+ this.running = true;
592+ this.paused = false;
593+ this.setLocked(true);
594+ this.setView("live");
595+ this.updateButtons();
596+ await this.fullLoop();
597+ }
598+
599+ private async fullLoop(): Promise<void> {
600+ const sim = this.sim;
601+ if (!sim) return;
602+ const t0 = performance.now();
603+ let lastUi = 0;
604+ while (this.running && sim.phase === "recording") {
605+ if (this.paused) { await new Promise((r) => setTimeout(r, 50)); continue; }
606+ await sim.runBatch();
607+ if (!this.rec || this.sim !== sim) break;
608+ // New audio and frames.
609+ const rec = this.rec;
610+ const newSamples = sim.samples - rec.samples;
611+ if (newSamples > 0) {
612+ const chunk = sim.audio.subarray(rec.samples, sim.samples);
613+ this.spec.append(chunk);
614+ rec.audio = sim.audio; rec.samples = sim.samples;
615+ }
616+ rec.frames = sim.frames;
617+ if (this.view === "live") this.renderer.setField(sim.frames[sim.frames.length - 1]);
618+ const now = performance.now();
619+ if (now - lastUi > 100) {
620+ lastUi = now;
621+ const st = sim.status();
622+ const elapsed = (now - t0) / 1000;
623+ this.runStatus.textContent = `Full model: t = ${st.t.toFixed(3)} s, ${(st.totalSteps / elapsed / 1000).toFixed(0)}k steps/s (${(elapsed / Math.max(st.t, 1e-3)).toFixed(0)} s per second of sound), peak ${peakSpl(sim.recordedAudio()).toFixed(0)} dB SPL, stick force ${st.stick.force.toFixed(0)} N${st.delayClamped ? ", delay spread clamped" : ""}`;
624+ this.scrub.max = String(Math.max(0, rec.frames.length - 1));
625+ this.updatePeak();
626+ }
627+ }
628+ if (this.sim === sim) {
629+ this.running = false;
630+ this.setLocked(false);
631+ const st = sim.status();
632+ this.runStatus.textContent = `Full model: ${st.t.toFixed(2)} s recorded, peak ${peakSpl(sim.recordedAudio()).toFixed(0)} dB SPL, peak force ${st.stick.maxForce.toFixed(0)} N, largest displacement ${(st.wmax * 1e3).toFixed(2)} mm.`;
633+ if (this.rec) { this.rec.audio = sim.recordedAudio(); this.rec.samples = sim.samples; this.rec.frames = sim.frames; }
634+ this.updateNotes();
635+ this.updateButtons();
636+ this.updatePeak();
637+ }
638+ }
639+
640+ // ---------- Run control ----------
641+
642+ private setRunMode(mode: RunMode): void {
643+ if (mode === "full" && !this.device) mode = "linear";
644+ this.runMode = mode;
645+ this.runModeSel.value = mode;
646+ if (mode === "linear" && this.plate && !this.modes) this.computeModes();
647+ if (mode === "full") this.modeBrowser.clear("Switch to the linear model to compute the modes.");
648+ this.updateButtons();
649+ }
650+
651+ private start(): void {
652+ if (this.runMode === "linear") void this.runLinear();
653+ else void this.runFull();
654+ }
655+ private pause(): void {
656+ if (!this.running) return;
657+ this.paused = !this.paused;
658+ this.pauseBtn.textContent = this.paused ? "Resume" : "Pause";
659+ }
660+ private stopRun(): void {
661+ if (this.sim && this.running) { this.sim.stop(); this.running = false; }
662+ this.paused = false;
663+ this.pauseBtn.textContent = "Pause";
664+ this.setLocked(false);
665+ this.updateButtons();
666+ }
667+ private continueRun(): void {
668+ const sim = this.sim;
669+ if (!sim || this.running || !sim.canContinue()) return;
670+ sim.setDuration(this.form.get().duration);
671+ this.running = true;
672+ this.setLocked(true);
673+ this.setView("live");
674+ this.updateButtons();
675+ void this.fullLoop();
676+ }
677+
678+ private setLocked(locked: boolean): void {
679+ this.editor.setLocked(locked);
680+ this.form.setLocked(locked);
681+ this.runModeSel.disabled = locked;
682+ }
683+
684+ private updateButtons(): void {
685+ const linear = this.runMode === "linear";
686+ const canLinear = linear && !!this.model && !this.synthBusy;
687+ const canFull = !linear && !!this.plate && !!this.device && !this.running;
688+ this.startBtn.disabled = !(canLinear || canFull);
689+ this.startBtn.textContent = linear ? "Synthesize" : "Start";
690+ this.pauseBtn.disabled = linear || !this.running;
691+ this.stopBtn.disabled = linear || !this.running;
692+ this.continueBtn.disabled = linear || this.running || !this.sim || !this.sim.canContinue() || this.sim.samples >= Math.round(this.form.get().duration * this.form.get().fs);
693+ this.pauseBtn.hidden = linear; this.stopBtn.hidden = linear; this.continueBtn.hidden = linear;
694+ this.liveBtn.hidden = linear;
695+ }
696+
697+ // ---------- Recording, view, playback ----------
698+
699+ private installRecording(audio: Float32Array, frames: Float32Array[], p: SimParams, source: RunMode): void {
700+ this.rec = { audio, samples: audio.length, fs: p.fs, frames, fps: p.fps, source };
701+ this.spec.setOptions({ fs: p.fs });
702+ this.spec.setDuration(p.duration);
703+ this.spec.setSignal(audio);
704+ this.scrub.max = String(Math.max(0, frames.length - 1));
705+ if (frames.length > 0) {
706+ this.frameIndex = Math.min(this.frameIndex, frames.length - 1);
707+ if (this.view !== "mode") { this.setView("movie"); this.showFrame(); }
708+ }
709+ this.updatePeak();
710+ this.updateButtons();
711+ }
712+
713+ private setView(v: ViewState): void {
714+ this.view = v;
715+ if (v === "design") { this.renderer.setField(null); this.overlay.textContent = this.plate ? "Stress-free shape" : ""; }
716+ else if (v === "live") { this.overlay.textContent = "Live"; if (this.sim) this.renderer.setField(this.sim.frames[this.sim.frames.length - 1]); }
717+ else if (v === "movie") this.showFrame();
718+ }
719+
720+ private showFrame(): void {
721+ const rec = this.rec;
722+ if (!rec || rec.frames.length === 0) return;
723+ const f = Math.max(0, Math.min(rec.frames.length - 1, this.frameIndex));
724+ this.frameIndex = f;
725+ this.scrub.value = String(f);
726+ this.timeLabel.textContent = `${(f / rec.fps).toFixed(3)} s`;
727+ this.renderer.setField(rec.frames[f]);
728+ this.overlay.textContent = `${rec.source === "linear" ? "Linear" : "Full"} model, t = ${(f / rec.fps).toFixed(3)} s`;
729+ }
730+
731+ private playable(): Float32Array | null {
732+ const rec = this.rec;
733+ if (!rec || rec.samples === 0) return null;
734+ const sig = rec.audio.subarray(0, rec.samples);
735+ return applyLevel(sig, levelGain(sig, rec.fs, this.levelMode, this.gainDb));
736+ }
737+
738+ private updatePeak(): void {
739+ const s = this.playable();
740+ if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; }
741+ const db = peakDbfs(s);
742+ this.peakLabel.textContent = `Peak ${db.toFixed(1)} dBFS${db > -0.5 ? " (clipping)" : ""}`;
743+ this.peakLabel.className = db > -0.5 ? "badge warn" : "badge";
744+ this.downloadBtn.disabled = false;
745+ this.playBtn.disabled = false;
746+ }
747+
748+ private togglePlay(): void {
749+ if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
750+ const s = this.playable(), rec = this.rec;
751+ if (!s || !rec) return;
752+ const start = rec.frames.length > 1 ? this.frameIndex / rec.fps : 0;
753+ this.playBtn.textContent = "Stop";
754+ const prevView = this.view;
755+ this.player.play(s, rec.fs, start, () => { this.playBtn.textContent = "Play"; this.spec.setCursor(null); if (prevView === "live") this.setView("live"); });
756+ const tick = () => {
757+ if (!this.player.playing) return;
758+ const t = this.player.position();
759+ this.spec.setCursor(t);
760+ if (rec.frames.length > 1) { this.frameIndex = Math.round(t * rec.fps); this.view = "movie"; this.showFrame(); }
761+ requestAnimationFrame(tick);
762+ };
763+ requestAnimationFrame(tick);
764+ }
765+
766+ private download(): void {
767+ const s = this.playable(), rec = this.rec;
768+ if (!s || !rec) return;
769+ const blob = encodeWav(s, rec.fs);
770+ const a = document.createElement("a");
771+ a.href = URL.createObjectURL(blob);
772+ a.download = `${(this.plate?.name ?? "cymbalsim").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${rec.source}.wav`;
773+ a.click();
774+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
775+ }
776+
777+ private updateTicks(): void {
778+ this.spec.setTicks(this.showTicks && this.modes ? Array.from(this.modes.freqs) : null);
779+ }
780+
781+ // ---------- Tests ----------
782+
783+ async selfTest(): Promise<string> {
784+ if (!this.device) return "no WebGPU";
785+ const lines: string[] = [];
786+ for (const id of ["flat-rectangle", "bowed-rectangle", "crash-16", "crash-bell-16", "ellipse"]) {
787+ const pr = presetById(id)!;
788+ const d = compileDesign(pr.source);
789+ const pl = evaluatePlate(d, 0.012);
790+ const r = await runSelfTest(this.device, pl, this.form.get());
791+ lines.push(`${id}: max relative error ${r.maxRel.toExponential(2)} (${r.n} nodes)`);
792+ }
793+ return lines.join("\n");
794+ }
795+
796+ private testStatus(): unknown {
797+ return {
798+ design: this.design?.name ?? null,
799+ modes: this.modes ? { count: this.modes.count, missing: this.modes.missing, first: Array.from(this.modes.freqs.slice(0, 5)) } : null,
800+ synthesized: this.synthesized,
801+ running: this.running,
802+ samples: this.rec?.samples ?? 0,
803+ frames: this.rec?.frames.length ?? 0,
804+ source: this.rec?.source ?? null,
805+ peakPressure: this.rec ? Array.from(this.rec.audio.subarray(0, this.rec.samples)).reduce((m, v) => Math.max(m, Math.abs(v)), 0) : 0,
806+ sim: this.sim ? this.sim.status() : null,
807+ contact: this.lastContact ? { peakForce: this.lastContact.peakForce, contactTime: this.lastContact.contactTime } : null,
808+ editorStatus: ($("design").querySelector(".editor-status") as HTMLElement | null)?.textContent ?? "",
809+ };
810+ }
811+}
812+
813+const app = new App();
814+app.init().catch((e) => {
815+ console.error(e);
816+ $("viewOverlay").textContent = `Failed to start: ${e instanceof Error ? e.message : String(e)}`;
817+});
818+
819+export { app, PRESETS, isFlat, formatNumber };
src/modal/banded.tsadded+80−0View file
@@ -0,0 +1,80 @@
1+// Symmetric banded LDL^T factorization of K - sigma M, its solve, and its
2+// inertia (the number of negative pivots, which by Sylvester's law equals
3+// the number of eigenvalues of the pencil (K, M) below sigma). The band
4+// layout is that of assemble.ts: row i holds K[i, i - d] at offset d.
5+
6+export interface Factorization {
7+ n: number;
8+ bw: number;
9+ /** Unit lower band factor L, same layout as the input (the diagonal offset is unused). */
10+ L: Float64Array;
11+ /** Pivots D. */
12+ D: Float64Array;
13+ /** Number of negative pivots: eigenvalues of (K, M) below the shift. */
14+ negative: number;
15+ shift: number;
16+}
17+
18+/** LDL^T of K - shift * diag(mass). Throws on an exactly zero pivot (a shift equal to an eigenvalue). */
19+export function factorize(band: Float64Array, mass: Float64Array, n: number, bw: number, shift: number): Factorization {
20+ const s = bw + 1;
21+ const L = new Float64Array(band);
22+ for (let i = 0; i < n; i++) L[i * s] -= shift * mass[i];
23+ const D = new Float64Array(n);
24+ // W[j][k] = D[k] L[j][k], kept alongside L so that the inner loop of the
25+ // factorization multiplies two loads rather than three.
26+ const W = new Float64Array(n * s);
27+ let negative = 0;
28+ for (let i = 0; i < n; i++) {
29+ const rowI = i * s;
30+ const kmin = Math.max(0, i - bw);
31+ for (let j = kmin; j < i; j++) {
32+ const rowJ = j * s;
33+ let acc = L[rowI + (i - j)];
34+ const k0 = Math.max(kmin, j - bw);
35+ // acc -= sum_k L[i][k] W[j][k]; L[i][k] sits at rowI + i - k, W[j][k] at rowJ + j - k.
36+ const offI = rowI + i, offJ = rowJ + j;
37+ for (let k = k0; k < j; k++) acc -= L[offI - k] * W[offJ - k];
38+ L[rowI + (i - j)] = acc / D[j];
39+ }
40+ let d = L[rowI];
41+ for (let k = kmin; k < i; k++) {
42+ const l = L[rowI + (i - k)];
43+ const w = l * D[k];
44+ W[rowI + (i - k)] = w;
45+ d -= l * w;
46+ }
47+ if (d === 0 || !isFinite(d)) throw new Error(`zero pivot at row ${i} for shift ${shift}`);
48+ D[i] = d;
49+ if (d < 0) negative++;
50+ }
51+ return { n, bw, L, D, negative, shift };
52+}
53+
54+/** Solves (K - shift M) x = b in place (x may be b). */
55+export function solve(f: Factorization, b: Float64Array, x: Float64Array): void {
56+ const { n, bw, L, D } = f;
57+ const s = bw + 1;
58+ if (x !== b) x.set(b);
59+ // Forward: L y = b
60+ for (let i = 0; i < n; i++) {
61+ const rowI = i * s;
62+ const kmin = Math.max(0, i - bw);
63+ let acc = x[i];
64+ for (let k = kmin; k < i; k++) acc -= L[rowI + (i - k)] * x[k];
65+ x[i] = acc;
66+ }
67+ for (let i = 0; i < n; i++) x[i] /= D[i];
68+ // Backward: L^T x = y
69+ for (let i = n - 1; i >= 0; i--) {
70+ const xi = x[i];
71+ const rowI = i * s;
72+ const kmin = Math.max(0, i - bw);
73+ for (let k = kmin; k < i; k++) x[k] -= L[rowI + (i - k)] * xi;
74+ }
75+}
76+
77+/** Number of eigenvalues of (K, M) below the shift. */
78+export function inertia(band: Float64Array, mass: Float64Array, n: number, bw: number, shift: number): number {
79+ return factorize(band, mass, n, bw, shift).negative;
80+}
src/modal/lanczos.tsadded+265−0View file
@@ -0,0 +1,265 @@
1+// Shift-invert Lanczos for the eigenvalues of the pencil (K, M) in one
2+// slice [lo, hi] of the spectrum. With M diagonal the pencil is turned into
3+// the standard symmetric problem A = M^{-1/2} K M^{-1/2}, and Lanczos is
4+// run on (A - sigma I)^{-1} = M^{1/2} (K - sigma M)^{-1} M^{1/2} with the
5+// banded factorization of banded.ts, full reorthogonalization, and a check
6+// of the count of converged eigenvalues in the slice against the inertia
7+// counts at its ends. The mode shapes returned are M^{-1/2} times the
8+// eigenvectors of A, that is, mass-normalized.
9+
10+import { factorize, solve, type Factorization } from "./banded.ts";
11+
12+export interface SliceResult {
13+ /** Eigenvalues omega^2 found in (lo, hi], ascending. */
14+ lambda: number[];
15+ /** Mass-normalized mode shapes, one Float64Array of length n per eigenvalue. */
16+ shapes: Float64Array[];
17+ /** Number of eigenvalues the inertia counts say lie in the slice. */
18+ expected: number;
19+ /** Lanczos steps used. */
20+ steps: number;
21+}
22+
23+export interface SliceOptions {
24+ /** Relative tolerance on the shift-inverted Ritz values; 1e-9 by default. */
25+ tol?: number;
26+ /** Largest number of Lanczos steps per start; 3 count + 40 by default. */
27+ maxSteps?: number;
28+ /** Restarts with a fresh random vector when modes are missing (degenerate pairs); 3 by default. */
29+ restarts?: number;
30+ seed?: number;
31+}
32+
33+/**
34+ * Eigenvalues of (K, M) in (lo, hi] and their shapes. `countBelowLo` and
35+ * `countBelowHi` are the inertia counts at the two ends, so that the
36+ * expected number of eigenvalues is known.
37+ */
38+export function sliceModes(
39+ band: Float64Array,
40+ mass: Float64Array,
41+ n: number,
42+ bw: number,
43+ lo: number,
44+ hi: number,
45+ countBelowLo: number,
46+ countBelowHi: number,
47+ opts: SliceOptions = {},
48+): SliceResult {
49+ const expected = countBelowHi - countBelowLo;
50+ if (expected <= 0) return { lambda: [], shapes: [], expected, steps: 0 };
51+ const tol = opts.tol ?? 1e-9;
52+ const restarts = opts.restarts ?? 3;
53+ const sigma = 0.5 * (lo + hi);
54+ const fact = factorize(band, mass, n, bw, sigma);
55+ const sqm = new Float64Array(n);
56+ for (let i = 0; i < n; i++) sqm[i] = Math.sqrt(mass[i]);
57+ let seed = (opts.seed ?? 4321) >>> 0;
58+ const rnd = () => {
59+ seed = (seed * 1664525 + 1013904223) >>> 0;
60+ return seed / 4294967296 - 0.5;
61+ };
62+
63+ const locked: { lambda: number; x: Float64Array }[] = [];
64+ let steps = 0;
65+ const maxSteps = Math.min(n, opts.maxSteps ?? 3 * expected + 40);
66+ for (let attempt = 0; attempt <= restarts && locked.length < expected; attempt++) {
67+ const found = lanczosRun(fact, sqm, n, sigma, lo, hi, expected - locked.length, locked.map((l) => l.x), maxSteps, tol, rnd);
68+ steps += found.steps;
69+ for (const f of found.pairs) locked.push(f);
70+ }
71+ locked.sort((a, b) => a.lambda - b.lambda);
72+ const shapes = locked.map((l) => {
73+ const phi = new Float64Array(n);
74+ for (let i = 0; i < n; i++) phi[i] = l.x[i] / sqm[i];
75+ return phi;
76+ });
77+ return { lambda: locked.map((l) => l.lambda), shapes, expected, steps };
78+}
79+
80+function dot(a: Float64Array, b: Float64Array): number {
81+ let s = 0;
82+ for (let i = 0; i < a.length; i++) s += a[i] * b[i];
83+ return s;
84+}
85+
86+/** One Lanczos run from a random start, orthogonal to the locked vectors; returns the converged pairs in (lo, hi]. */
87+function lanczosRun(
88+ fact: Factorization,
89+ sqm: Float64Array,
90+ n: number,
91+ sigma: number,
92+ lo: number,
93+ hi: number,
94+ wanted: number,
95+ locked: Float64Array[],
96+ maxSteps: number,
97+ tol: number,
98+ rnd: () => number,
99+): { pairs: { lambda: number; x: Float64Array }[]; steps: number } {
100+ const Q: Float64Array[] = [];
101+ const alpha: number[] = [], beta: number[] = [];
102+ const tmp = new Float64Array(n);
103+ // Classical Gram-Schmidt against the basis, repeated once when the first
104+ // pass removed a large part of the vector ("twice is enough"); the second
105+ // pass is rarely needed and the basis is the main memory traffic.
106+ const orthogonalize = (r: Float64Array, against: Float64Array[]): void => {
107+ if (against.length === 0) return;
108+ const before = dot(r, r);
109+ for (let pass = 0; pass < 2; pass++) {
110+ for (const q of against) {
111+ const c = dot(r, q);
112+ if (c !== 0) for (let i = 0; i < n; i++) r[i] -= c * q[i];
113+ }
114+ if (dot(r, r) > 0.5 * before) break;
115+ }
116+ };
117+ let q = new Float64Array(n);
118+ for (let i = 0; i < n; i++) q[i] = rnd();
119+ orthogonalize(q, locked);
120+ let nrm = Math.sqrt(dot(q, q));
121+ for (let i = 0; i < n; i++) q[i] /= nrm;
122+ Q.push(q);
123+
124+ let pairs: { lambda: number; x: Float64Array }[] = [];
125+ let steps = 0;
126+ const checkEvery = Math.max(6, Math.round(wanted / 10));
127+ const check = (): boolean => {
128+ const m = Q.length;
129+ const { values, vectors } = tridiagonalEigen(alpha, beta.slice(0, m - 1));
130+ const betaM = beta[m - 1] ?? 0;
131+ const conv: { lambda: number; s: Float64Array }[] = [];
132+ for (let k = 0; k < m; k++) {
133+ const theta = values[k];
134+ if (theta === 0) continue;
135+ const lambda = sigma + 1 / theta;
136+ if (!(lambda > lo && lambda <= hi)) continue;
137+ const err = Math.abs(betaM * vectors[k][m - 1]);
138+ if (err <= tol * Math.abs(theta)) conv.push({ lambda, s: vectors[k] });
139+ }
140+ if (conv.length < wanted && m < maxSteps) return false;
141+ pairs = conv.map(({ lambda, s }) => {
142+ const x = new Float64Array(n);
143+ for (let j = 0; j < m; j++) {
144+ const sj = s[j], qj = Q[j];
145+ if (sj === 0) continue;
146+ for (let i = 0; i < n; i++) x[i] += sj * qj[i];
147+ }
148+ return { lambda, x };
149+ });
150+ return true;
151+ };
152+
153+ for (let j = 0; j < maxSteps; j++) {
154+ steps++;
155+ // r = Op q_j with Op = M^{1/2} (K - sigma M)^{-1} M^{1/2}
156+ for (let i = 0; i < n; i++) tmp[i] = sqm[i] * Q[j][i];
157+ solve(fact, tmp, tmp);
158+ const r = new Float64Array(n);
159+ for (let i = 0; i < n; i++) r[i] = sqm[i] * tmp[i];
160+ if (j > 0) for (let i = 0; i < n; i++) r[i] -= beta[j - 1] * Q[j - 1][i];
161+ const a = dot(r, Q[j]);
162+ alpha.push(a);
163+ for (let i = 0; i < n; i++) r[i] -= a * Q[j][i];
164+ orthogonalize(r, Q);
165+ orthogonalize(r, locked);
166+ const b = Math.sqrt(dot(r, r));
167+ beta.push(b);
168+ const last = j === maxSteps - 1 || Q.length >= n;
169+ if (b < 1e-14 * Math.max(1, Math.abs(a))) {
170+ // Invariant subspace: everything reachable has converged.
171+ check();
172+ break;
173+ }
174+ // Fewer than `wanted` steps cannot hold `wanted` converged Ritz values,
175+ // and the tridiagonal eigenproblem costs m^3, so check only from there
176+ // on and at a spacing that grows with the slice.
177+ const m = j + 1;
178+ if (last || (m >= wanted && (m - wanted) % checkEvery === 0)) {
179+ if (check()) break;
180+ }
181+ if (Q.length >= n) break;
182+ for (let i = 0; i < n; i++) r[i] /= b;
183+ Q.push(r);
184+ }
185+ if (pairs.length === 0 && steps > 0) check();
186+ return { pairs, steps };
187+}
188+
189+/**
190+ * Eigenvalues and eigenvectors of the symmetric tridiagonal matrix with
191+ * diagonal `d` and off-diagonal `e` (implicit QL, after EISPACK tql2).
192+ * vectors[k] is the eigenvector of values[k].
193+ */
194+export function tridiagonalEigen(dIn: number[], eIn: number[]): { values: number[]; vectors: Float64Array[] } {
195+ const n = dIn.length;
196+ const d = dIn.slice();
197+ const e = eIn.slice();
198+ e.push(0);
199+ const z: Float64Array[] = [];
200+ for (let i = 0; i < n; i++) {
201+ const row = new Float64Array(n);
202+ row[i] = 1;
203+ z.push(row);
204+ }
205+ let f = 0, tst1 = 0;
206+ const eps = 2 ** -52;
207+ for (let l = 0; l < n; l++) {
208+ tst1 = Math.max(tst1, Math.abs(d[l]) + Math.abs(e[l]));
209+ let m = l;
210+ while (m < n) {
211+ if (Math.abs(e[m]) <= eps * tst1) break;
212+ m++;
213+ }
214+ if (m > l) {
215+ let iter = 0;
216+ do {
217+ iter++;
218+ if (iter > 200) throw new Error("tridiagonal QL did not converge");
219+ let g = d[l];
220+ let p = (d[l + 1] - g) / (2 * e[l]);
221+ let r = Math.hypot(p, 1);
222+ if (p < 0) r = -r;
223+ d[l] = e[l] / (p + r);
224+ d[l + 1] = e[l] * (p + r);
225+ const dl1 = d[l + 1];
226+ let h = g - d[l];
227+ for (let i = l + 2; i < n; i++) d[i] -= h;
228+ f += h;
229+ p = d[m];
230+ let c = 1, c2 = c, c3 = c;
231+ const el1 = e[l + 1];
232+ let s = 0, s2 = 0;
233+ for (let i = m - 1; i >= l; i--) {
234+ c3 = c2; c2 = c; s2 = s;
235+ g = c * e[i];
236+ h = c * p;
237+ r = Math.hypot(p, e[i]);
238+ e[i + 1] = s * r;
239+ s = e[i] / r;
240+ c = p / r;
241+ p = c * d[i] - s * g;
242+ d[i + 1] = h + s * (c * g + s * d[i]);
243+ for (let k = 0; k < n; k++) {
244+ h = z[k][i + 1];
245+ z[k][i + 1] = s * z[k][i] + c * h;
246+ z[k][i] = c * z[k][i] - s * h;
247+ }
248+ }
249+ p = (-s * s2 * c3 * el1 * e[l]) / dl1;
250+ e[l] = s * p;
251+ d[l] = c * p;
252+ } while (Math.abs(e[l]) > eps * tst1);
253+ }
254+ d[l] += f;
255+ e[l] = 0;
256+ }
257+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => d[a] - d[b]);
258+ const values = order.map((i) => d[i]);
259+ const vectors = order.map((i) => {
260+ const v = new Float64Array(n);
261+ for (let k = 0; k < n; k++) v[k] = z[k][i];
262+ return v;
263+ });
264+ return { values, vectors };
265+}
src/modal/modes.tsadded+154−0View file
@@ -0,0 +1,154 @@
1+// The modes of a plate up to a cutoff frequency, by spectrum slicing: the
2+// eigenvalue count below a shift is the inertia of the banded factorization
3+// (banded.ts), which fixes slices of about `sliceSize` modes between zero
4+// and the cutoff, and each slice is solved by shift-invert Lanczos
5+// (lanczos.ts). Inertia counts and slices are independent jobs, run here
6+// through a JobRunner: in-process for the tests, or a pool of Web Workers
7+// in the browser (pool.ts).
8+
9+import { type LinearSystem } from "../model/assemble.ts";
10+import { inertia } from "./banded.ts";
11+import { sliceModes } from "./lanczos.ts";
12+
13+export type Job =
14+ | { kind: "inertia"; shift: number }
15+ | { kind: "slice"; lo: number; hi: number; countLo: number; countHi: number; seed: number };
16+
17+export type JobResult =
18+ | { kind: "inertia"; count: number }
19+ | { kind: "slice"; lambda: number[]; shapes: Float64Array[]; expected: number; steps: number };
20+
21+export interface JobRunner {
22+ run(job: Job): Promise<JobResult>;
23+ close(): void;
24+}
25+
26+/** Runs the jobs on the calling thread. */
27+export function localRunner(sys: LinearSystem): JobRunner {
28+ return {
29+ run: async (job) => runJob(sys, job),
30+ close: () => {},
31+ };
32+}
33+
34+export function runJob(sys: LinearSystem, job: Job): JobResult {
35+ if (job.kind === "inertia") return { kind: "inertia", count: inertia(sys.band, sys.mass, sys.n, sys.bw, job.shift) };
36+ const r = sliceModes(sys.band, sys.mass, sys.n, sys.bw, job.lo, job.hi, job.countLo, job.countHi, { seed: job.seed });
37+ return { kind: "slice", lambda: r.lambda, shapes: r.shapes, expected: r.expected, steps: r.steps };
38+}
39+
40+export interface ModeSet {
41+ /** Number of unknowns of the system the shapes live on. */
42+ n: number;
43+ count: number;
44+ /** Frequencies [Hz], ascending. */
45+ freqs: Float64Array;
46+ /** Mass-normalized shapes, mode-major: shapes[m * n + i]. */
47+ shapes: Float32Array;
48+ /** Modes the inertia counts announced but Lanczos did not deliver (zero when all is well). */
49+ missing: number;
50+ /** Modes below cutoff according to the inertia count (including any missing ones). */
51+ expected: number;
52+ /** Wall time of the computation [s]. */
53+ seconds: number;
54+}
55+
56+export interface ModeOptions {
57+ /** Target number of modes per slice; 80 by default. */
58+ sliceSize?: number;
59+ /** Progress callback with the number of finished and total slices. */
60+ onProgress?: (done: number, total: number) => void;
61+ /** Rigid-body modes of a free plate lie at zero; modes below this frequency are discarded [Hz]. Default 1. */
62+ minFreq?: number;
63+}
64+
65+interface Slice {
66+ lo: number;
67+ hi: number;
68+ countLo: number;
69+ countHi: number;
70+}
71+
72+/**
73+ * All modes of the system with frequencies up to fMax. The zero-frequency
74+ * rigid modes of a free plate are computed and then discarded.
75+ */
76+export async function computeModes(sys: LinearSystem, fMax: number, runner: JobRunner, opts: ModeOptions = {}): Promise<ModeSet> {
77+ const t0 = performance.now();
78+ const sliceSize = opts.sliceSize ?? 80;
79+ const minFreq = opts.minFreq ?? 1;
80+ const w2 = (f: number) => (2 * Math.PI * f) ** 2;
81+ const lamMin = -w2(1); // slightly below zero so that the rigid modes are counted in the first slice
82+ const lamMax = w2(fMax);
83+ const count = async (shift: number) => {
84+ const r = await runner.run({ kind: "inertia", shift });
85+ return (r as { count: number }).count;
86+ };
87+ const [nMin, nMax] = await Promise.all([count(lamMin), count(lamMax)]);
88+ const expected = nMax - nMin;
89+ const empty = (): ModeSet => ({ n: sys.n, count: 0, freqs: new Float64Array(0), shapes: new Float32Array(0), missing: 0, expected, seconds: (performance.now() - t0) / 1000 });
90+ if (expected <= 0) return empty();
91+
92+ // Boundaries equally spaced in omega (the mode count of a plate grows about
93+ // linearly with frequency), refined where a slice is too full.
94+ let slices: Slice[] = [];
95+ {
96+ const S = Math.max(1, Math.ceil(expected / sliceSize));
97+ const wMax = Math.sqrt(lamMax);
98+ const bounds: number[] = [lamMin];
99+ for (let i = 1; i < S; i++) bounds.push(((wMax * i) / S) ** 2);
100+ bounds.push(lamMax);
101+ const counts = [nMin, ...(await Promise.all(bounds.slice(1, -1).map(count))), nMax];
102+ for (let i = 0; i < bounds.length - 1; i++) slices.push({ lo: bounds[i], hi: bounds[i + 1], countLo: counts[i], countHi: counts[i + 1] });
103+ }
104+ for (let round = 0; round < 6; round++) {
105+ const full = slices.filter((s) => s.countHi - s.countLo > 1.5 * sliceSize && s.hi - s.lo > w2(0.5));
106+ if (full.length === 0) break;
107+ const mids = await Promise.all(full.map((s) => count(((Math.sqrt(Math.max(s.lo, 0)) + Math.sqrt(s.hi)) / 2) ** 2)));
108+ const next: Slice[] = [];
109+ for (const s of slices) {
110+ const idx = full.indexOf(s);
111+ if (idx < 0) { next.push(s); continue; }
112+ const mid = ((Math.sqrt(Math.max(s.lo, 0)) + Math.sqrt(s.hi)) / 2) ** 2;
113+ next.push({ lo: s.lo, hi: mid, countLo: s.countLo, countHi: mids[idx] }, { lo: mid, hi: s.hi, countLo: mids[idx], countHi: s.countHi });
114+ }
115+ slices = next;
116+ }
117+ // Merge neighbors while the union stays within the target, and drop empty slices.
118+ {
119+ const merged: Slice[] = [];
120+ for (const s of slices) {
121+ const last = merged[merged.length - 1];
122+ if (last && last.countHi === s.countLo && s.countHi - last.countLo <= sliceSize) { last.hi = s.hi; last.countHi = s.countHi; }
123+ else merged.push({ ...s });
124+ }
125+ slices = merged.filter((s) => s.countHi > s.countLo);
126+ }
127+
128+ let done = 0;
129+ opts.onProgress?.(0, slices.length);
130+ const results = await Promise.all(
131+ slices.map(async (s, i) => {
132+ const r = (await runner.run({ kind: "slice", lo: s.lo, hi: s.hi, countLo: s.countLo, countHi: s.countHi, seed: 1000 + 7919 * i })) as Extract<JobResult, { kind: "slice" }>;
133+ done++;
134+ opts.onProgress?.(done, slices.length);
135+ return r;
136+ }),
137+ );
138+ const pairs: { lambda: number; shape: Float64Array }[] = [];
139+ let missing = 0;
140+ for (const r of results) {
141+ missing += r.expected - r.lambda.length;
142+ for (let i = 0; i < r.lambda.length; i++) pairs.push({ lambda: r.lambda[i], shape: r.shapes[i] });
143+ }
144+ pairs.sort((a, b) => a.lambda - b.lambda);
145+ const kept = pairs.filter((p) => p.lambda > w2(minFreq));
146+ const n = sys.n;
147+ const shapes = new Float32Array(kept.length * n);
148+ const freqs = new Float64Array(kept.length);
149+ kept.forEach((p, m) => {
150+ freqs[m] = Math.sqrt(p.lambda) / (2 * Math.PI);
151+ shapes.set(p.shape, m * n);
152+ });
153+ return { n, count: kept.length, freqs, shapes, missing, expected, seconds: (performance.now() - t0) / 1000 };
154+}
src/modal/pool.tsadded+188−0View file
@@ -0,0 +1,188 @@
1+// A pool of the workers of worker.ts. It runs the eigenvalue jobs of
2+// modes.ts through a JobRunner, and splits the modes of a model between the
3+// workers for the synthesis: the main thread steps the contact (which
4+// couples all modes and is cheap), each worker forms the parts of its
5+// modes, and the main thread sums them.
6+
7+import { type LinearSystem } from "../model/assemble.ts";
8+import { type Loss, type Plate, type SimParams } from "../params.ts";
9+import { type Job, type JobResult, type JobRunner, type ModeSet, computeModes } from "./modes.ts";
10+import {
11+ type ContactResult,
12+ type ModalModel,
13+ type Synthesis,
14+ attackPressure,
15+ listenerWeights,
16+ modalRates,
17+ simulateContact,
18+ sliceContact,
19+ sliceModel,
20+ strikeGains,
21+ tailStart,
22+} from "./synth.ts";
23+import { type WorkerRequest, type WorkerResponse } from "./worker.ts";
24+
25+interface Pending {
26+ resolve: (r: WorkerResponse) => void;
27+ reject: (e: Error) => void;
28+}
29+
30+class PoolWorker {
31+ worker: Worker;
32+ busy = false;
33+ pending = new Map<number, Pending>();
34+ ready: Pending | null = null;
35+ constructor() {
36+ this.worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
37+ this.worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
38+ const r = ev.data;
39+ if (r.type === "ready") { this.ready?.resolve(r); this.ready = null; return; }
40+ const p = this.pending.get(r.id);
41+ if (!p) return;
42+ this.pending.delete(r.id);
43+ if (r.type === "error") p.reject(new Error(r.message)); else p.resolve(r);
44+ };
45+ this.worker.onerror = (e) => {
46+ for (const p of this.pending.values()) p.reject(new Error(e.message));
47+ this.pending.clear();
48+ };
49+ }
50+ send(msg: WorkerRequest, transfer: ArrayBuffer[] = []): Promise<WorkerResponse> {
51+ return new Promise((resolve, reject) => {
52+ if (msg.type === "system" || msg.type === "model") this.ready = { resolve, reject };
53+ else this.pending.set(msg.id, { resolve, reject });
54+ this.worker.postMessage(msg, transfer);
55+ });
56+ }
57+}
58+
59+export class WorkerPool {
60+ private workers: PoolWorker[] = [];
61+ private queue: (() => void)[] = [];
62+ private modelSplit: { from: number; to: number }[] = [];
63+ private nextId = 1;
64+
65+ constructor(public readonly size: number) {
66+ for (let i = 0; i < size; i++) this.workers.push(new PoolWorker());
67+ }
68+
69+ static defaultSize(): number {
70+ const hw = typeof navigator !== "undefined" ? navigator.hardwareConcurrency || 4 : 4;
71+ return Math.max(2, Math.min(12, hw));
72+ }
73+
74+ /** Waits for an idle worker. */
75+ private acquire(): Promise<PoolWorker> {
76+ return new Promise((resolve) => {
77+ const tryTake = (): boolean => {
78+ const w = this.workers.find((w) => !w.busy);
79+ if (!w) return false;
80+ w.busy = true;
81+ resolve(w);
82+ return true;
83+ };
84+ if (!tryTake()) this.queue.push(() => tryTake());
85+ });
86+ }
87+ private release(w: PoolWorker): void {
88+ w.busy = false;
89+ const next = this.queue.shift();
90+ if (next) next();
91+ }
92+
93+ /** Sends the linear system to every worker. */
94+ async setSystem(sys: LinearSystem): Promise<void> {
95+ await Promise.all(this.workers.map((w) => w.send({ type: "system", n: sys.n, bw: sys.bw, band: sys.band, mass: sys.mass })));
96+ }
97+
98+ runner(): JobRunner {
99+ return {
100+ run: async (job: Job): Promise<JobResult> => {
101+ const w = await this.acquire();
102+ try {
103+ const r = await w.send({ type: "job", id: this.nextId++, job });
104+ return (r as { result: JobResult }).result;
105+ } finally {
106+ this.release(w);
107+ }
108+ },
109+ close: () => {},
110+ };
111+ }
112+
113+ /** The modes of the system, computed by the workers. */
114+ async modes(sys: LinearSystem, fMax: number, onProgress?: (done: number, total: number) => void): Promise<ModeSet> {
115+ await this.setSystem(sys);
116+ return computeModes(sys, fMax, this.runner(), { sliceSize: sys.fields === 1 ? 30 : 100, onProgress });
117+ }
118+
119+ /** Splits the modes of the model between the workers. */
120+ async setModel(model: ModalModel): Promise<void> {
121+ const n = this.workers.length;
122+ this.modelSplit = [];
123+ const per = Math.ceil(model.count / n);
124+ await Promise.all(
125+ this.workers.map((w, i) => {
126+ const from = Math.min(model.count, i * per), to = Math.min(model.count, (i + 1) * per);
127+ this.modelSplit.push({ from, to });
128+ const slice = sliceModel(model, from, to);
129+ return w.send({ type: "model", model: slice });
130+ }),
131+ );
132+ }
133+
134+ /**
135+ * Synthesizes a strike with the model last set. The audio comes first
136+ * (through `onAudio`), then the movie frames; the returned promise
137+ * resolves when both are done.
138+ */
139+ async synthesize(model: ModalModel, pl: Plate, loss: Loss, p: SimParams, onAudio?: (audio: Float32Array, contact: ContactResult) => void, withMovie = true): Promise<Synthesis> {
140+ const sigma = modalRates(model, loss);
141+ const g = strikeGains(model, pl, p);
142+ const contact = simulateContact(model, sigma, g, p, p.fps);
143+ const { weight, delay, maxDelay } = listenerWeights(pl, p);
144+ const tStart = tailStart(contact, maxDelay);
145+ const len = Math.min(contact.tableLen, Math.ceil(tStart * contact.tableRate) + 2);
146+ const nSamples = Math.round(p.duration * p.fs);
147+ const nFrames = withMovie ? Math.round(p.duration * p.fps) : 0;
148+ const parts = await Promise.all(
149+ this.workers.map((w, i) => {
150+ const { from, to } = this.modelSplit[i];
151+ if (to <= from) return Promise.resolve(null);
152+ return w.send({
153+ type: "synth", id: this.nextId++, sigma: sigma.slice(from, to), contact: sliceContact(contact, from, to),
154+ weight, delay, tStart, len, fs: p.fs, nSamples, audio: true, movie: null,
155+ });
156+ }),
157+ );
158+ const audio = new Float32Array(nSamples);
159+ const table = new Float32Array(model.nRows * len);
160+ for (const r of parts) {
161+ if (!r || r.type !== "synth") continue;
162+ if (r.audio) for (let i = 0; i < nSamples; i++) audio[i] += r.audio[i];
163+ if (r.table) for (let i = 0; i < table.length; i++) table[i] += r.table[i];
164+ }
165+ attackPressure(model, table, len, contact.tableRate, weight, delay, p.fs, tStart, audio);
166+ onAudio?.(audio, contact);
167+ const frames = new Float32Array(nFrames * model.nGrid);
168+ if (withMovie) {
169+ const mparts = await Promise.all(
170+ this.workers.map((w, i) => {
171+ const { from, to } = this.modelSplit[i];
172+ if (to <= from) return Promise.resolve(null);
173+ return w.send({
174+ type: "synth", id: this.nextId++, sigma: sigma.slice(from, to), contact: sliceContact(contact, from, to),
175+ weight, delay, tStart, len, fs: p.fs, nSamples, audio: false, movie: { fps: p.fps, nFrames },
176+ });
177+ }),
178+ );
179+ for (const r of mparts) if (r && r.type === "synth" && r.frames) for (let i = 0; i < frames.length; i++) frames[i] += r.frames[i];
180+ }
181+ return { audio, frames, nFrames, contact };
182+ }
183+
184+ terminate(): void {
185+ for (const w of this.workers) w.worker.terminate();
186+ this.workers = [];
187+ }
188+}
src/modal/synth.tsadded+425−0View file
@@ -0,0 +1,425 @@
1+// Modal synthesis of a strike. Each mode m of the linearized plate is a
2+// damped oscillator q'' + 2 sigma q' + omega^2 q = f, with sigma from the
3+// design's decay times (and the pads' loss factor on a stand). The stick is
4+// the Hertz contact of the full simulation, stepped against the modal
5+// system with an exact update of every oscillator under a piecewise
6+// constant force. Once the stick has left, every mode is a damped sinusoid
7+// with a known complex amplitude, and the delayed Rayleigh sum over the
8+// plate collapses to one complex gain per mode. During the attack, while the
9+// modes are being driven, the delayed sum is evaluated directly from a
10+// table of the nodal accelerations. The parts that are sums over the modes
11+// (gains, the acceleration table, the audio tail, the movie) accept a model
12+// restricted to a subset of the modes, so that a pool of workers can split
13+// the modes between them (synthPool.ts).
14+
15+import { type LinearSystem } from "../model/assemble.ts";
16+import { type ModeSet } from "./modes.ts";
17+import { type Loss, type Plate, type SimParams, STICK_RELEASE, listenerTerms, standRates, strikePatch, nodeMass } from "../params.ts";
18+import { decayRate } from "../loss.ts";
19+
20+/** What the synthesis needs of a plate and its modes; plain arrays so that it can be posted to a worker. */
21+export interface ModalModel {
22+ nGrid: number;
23+ /** Number of plate nodes (rows of wShapes). */
24+ nRows: number;
25+ count: number;
26+ freqs: Float64Array;
27+ /** Transverse component of the mass-normalized mode shapes, node-major: wShapes[row * count + m]. */
28+ wShapes: Float32Array;
29+ /** Row of each grid node in wShapes, or -1 outside the plate. */
30+ wRow: Int32Array;
31+ /** Grid node of each row. */
32+ rowNode: Int32Array;
33+ /** Decay rate added by the pads' loss factor to each mode [1/s]; zero on a free plate. */
34+ padSigma: Float64Array;
35+ /** Node masses on the grid [kg]. */
36+ masses: Float64Array;
37+}
38+
39+export function modalModel(pl: Plate, sys: LinearSystem, modes: ModeSet): ModalModel {
40+ const nGrid = pl.grid.n;
41+ const wRow = new Int32Array(nGrid).fill(-1);
42+ const rows: number[] = [];
43+ for (let k = 0; k < nGrid; k++) if (sys.nodeDof[k] >= 0) { wRow[k] = rows.length; rows.push(k); }
44+ const nRows = rows.length, M = modes.count;
45+ const wShapes = new Float32Array(nRows * M);
46+ for (let r = 0; r < nRows; r++) {
47+ const d = sys.nodeDof[rows[r]] + (sys.fields - 1);
48+ for (let m = 0; m < M; m++) wShapes[r * M + m] = modes.shapes[m * sys.n + d];
49+ }
50+ const masses = new Float64Array(nGrid);
51+ for (let j = 0; j < pl.grid.ny; j++) for (let i = 0; i < pl.grid.nx; i++) masses[j * pl.grid.nx + i] = nodeMass(pl, i, j);
52+ // Hysteretic damping of the pads: a loss factor eta gives each mode the
53+ // rate sigma = eta (phi' K_pad phi) / (2 omega).
54+ const padSigma = new Float64Array(M);
55+ const sr = standRates(pl);
56+ if (sr) {
57+ for (let m = 0; m < M; m++) {
58+ let e = 0;
59+ for (const k of sr.nodes) {
60+ const d = sys.nodeDof[k];
61+ if (d < 0) continue;
62+ const w = modes.shapes[m * sys.n + d + (sys.fields - 1)];
63+ e += sr.kw[k] * w * w;
64+ if (sys.fields === 3) {
65+ const u = modes.shapes[m * sys.n + d], v = modes.shapes[m * sys.n + d + 1];
66+ e += sr.ku[k] * (u * u + v * v);
67+ }
68+ }
69+ const omega = 2 * Math.PI * modes.freqs[m];
70+ padSigma[m] = omega > 0 ? (sr.eta * e) / (2 * omega) : 0;
71+ }
72+ }
73+ return { nGrid, nRows, count: M, freqs: modes.freqs, wShapes, wRow, rowNode: Int32Array.from(rows), padSigma, masses };
74+}
75+
76+/** The model restricted to the modes [from, to). */
77+export function sliceModel(model: ModalModel, from: number, to: number): ModalModel {
78+ const M = to - from;
79+ const wShapes = new Float32Array(model.nRows * M);
80+ for (let r = 0; r < model.nRows; r++) wShapes.set(model.wShapes.subarray(r * model.count + from, r * model.count + to), r * M);
81+ return { ...model, count: M, freqs: model.freqs.slice(from, to), wShapes, padSigma: model.padSigma.slice(from, to) };
82+}
83+
84+/** The transverse shape of mode m on the grid (zero outside the plate). */
85+export function modeShapeOnGrid(model: ModalModel, m: number): Float32Array {
86+ const w = new Float32Array(model.nGrid);
87+ for (let r = 0; r < model.nRows; r++) w[model.rowNode[r]] = model.wShapes[r * model.count + m];
88+ return w;
89+}
90+
91+/** Per-mode decay rates from the design's decay times plus the pads, kept below the mode's frequency (underdamped). */
92+export function modalRates(model: ModalModel, loss: Loss): Float64Array {
93+ const sigma = new Float64Array(model.count);
94+ for (let m = 0; m < model.count; m++) {
95+ const w = 2 * Math.PI * model.freqs[m];
96+ sigma[m] = Math.min(decayRate(loss, model.freqs[m]) + model.padSigma[m], 0.99 * w);
97+ }
98+ return sigma;
99+}
100+
101+/** Damped angular frequency of a mode. */
102+function dampedOmega(f: number, sigma: number): number {
103+ const w = 2 * Math.PI * f;
104+ return Math.sqrt(w * w - sigma * sigma);
105+}
106+
107+/** The contact: modal gains g_m = sum over the patch of (m_k / M_patch) phi_m(k), so that the modal force is -F g_m and the contact coordinate is sum_m g_m q_m. */
108+export function strikeGains(model: ModalModel, pl: Plate, p: SimParams): Float64Array {
109+ const patch = strikePatch(pl, p);
110+ const g = new Float64Array(model.count);
111+ for (const k of patch.nodes) {
112+ const r = model.wRow[k];
113+ if (r < 0) continue;
114+ const f = model.masses[k] / patch.mass;
115+ for (let m = 0; m < model.count; m++) g[m] += f * model.wShapes[r * model.count + m];
116+ }
117+ return g;
118+}
119+
120+/** The state of every mode at the end of the contact phase and the tables recorded during it. */
121+export interface ContactResult {
122+ /** Time at which the stick is withdrawn, STICK_RELEASE after the contact last opened [s]. */
123+ tEnd: number;
124+ /** Time at which the contact last opened [s]; every mode decays freely from here on. */
125+ tContact: number;
126+ /** Complex modal amplitudes A at tEnd: q(t) = Re[A exp(s (t - tEnd))], s = -sigma + i omega_d. */
127+ aRe: Float64Array;
128+ aIm: Float64Array;
129+ /** Modal accelerations sampled at `tableRate` from t = 0 to tEnd, mode-major: qdd[m * tableLen + i]. */
130+ qdd: Float32Array;
131+ tableLen: number;
132+ tableRate: number;
133+ /** Modal displacements at the movie frame times before tEnd, mode-major: qFrames[m * nFrames + f]. */
134+ qFrames: Float32Array;
135+ nFrames: number;
136+ /** Stick force history at the fine step [N] and the step [s], for display. */
137+ force: Float32Array;
138+ dt: number;
139+ peakForce: number;
140+ contactTime: number;
141+}
142+
143+/** The contact result restricted to the modes [from, to). */
144+export function sliceContact(c: ContactResult, from: number, to: number): ContactResult {
145+ return {
146+ ...c,
147+ aRe: c.aRe.slice(from, to),
148+ aIm: c.aIm.slice(from, to),
149+ qdd: c.qdd.slice(from * c.tableLen, to * c.tableLen),
150+ qFrames: c.qFrames.slice(from * c.nFrames, to * c.nFrames),
151+ };
152+}
153+
154+/**
155+ * Steps the stick against the modal system until the contact has been open
156+ * for STICK_RELEASE, recording the tables the synthesis needs. Every mode
157+ * is advanced by its exact state transition under a constant force over the
158+ * step; the stick by symplectic Euler; the force is the Hertz law of the
159+ * penetration at the start of the step.
160+ */
161+export function simulateContact(model: ModalModel, sigma: Float64Array, g: Float64Array, p: SimParams, fps: number, dt = 2e-6, tableRate = 2 * p.fs): ContactResult {
162+ const M = model.count;
163+ const omega = new Float64Array(M), omegaD = new Float64Array(M);
164+ const p11 = new Float64Array(M), p12 = new Float64Array(M), p21 = new Float64Array(M), p22 = new Float64Array(M);
165+ for (let m = 0; m < M; m++) {
166+ const w = 2 * Math.PI * model.freqs[m];
167+ const s = sigma[m];
168+ const wd = dampedOmega(model.freqs[m], s);
169+ omega[m] = w; omegaD[m] = wd;
170+ const e = Math.exp(-s * dt), c = Math.cos(wd * dt), sn = Math.sin(wd * dt);
171+ p11[m] = e * (c + (s * sn) / wd);
172+ p12[m] = (e * sn) / wd;
173+ p21[m] = (-e * w * w * sn) / wd;
174+ p22[m] = e * (c - (s * sn) / wd);
175+ }
176+ const q = new Float64Array(M), qd = new Float64Array(M);
177+ const tMax = 0.2;
178+ const maxSteps = Math.ceil(tMax / dt);
179+ const tableCap = Math.ceil(tMax * tableRate) + 2;
180+ const qddTable = new Float32Array(M * tableCap);
181+ const frameCap = Math.ceil(tMax * fps) + 2;
182+ const qFrames = new Float32Array(M * frameCap);
183+ const force = new Float32Array(maxSteps + 1);
184+ let tableLen = 0, nFrames = 0, peakForce = 0, contactTime = 0;
185+ let z = 0, zd = -p.strikeVelocity; // stick tip, positive away from the plate; it arrives moving toward it
186+ let lastOpen = 0, everContact = false, F = 0;
187+ let step = 0;
188+ const record = (t: number): void => {
189+ while (tableLen < tableCap && tableLen / tableRate <= t + 1e-12) {
190+ for (let m = 0; m < M; m++) qddTable[m * tableCap + tableLen] = -2 * sigma[m] * qd[m] - omega[m] * omega[m] * q[m] - F * g[m];
191+ tableLen++;
192+ }
193+ while (nFrames < frameCap && nFrames / fps <= t + 1e-12) {
194+ for (let m = 0; m < M; m++) qFrames[m * frameCap + nFrames] = q[m];
195+ nFrames++;
196+ }
197+ };
198+ for (step = 0; step < maxSteps; step++) {
199+ const t = step * dt;
200+ let wc = 0;
201+ for (let m = 0; m < M; m++) wc += g[m] * q[m];
202+ const delta = wc - z; // penetration: the plate has come past the tip
203+ F = delta > 0 ? p.contactStiffness * Math.pow(delta, p.contactExponent) : 0;
204+ if (F > 0) { everContact = true; lastOpen = t + dt; contactTime += dt; }
205+ if (F > peakForce) peakForce = F;
206+ force[step] = F;
207+ record(t);
208+ if (everContact && t - lastOpen >= STICK_RELEASE) break;
209+ for (let m = 0; m < M; m++) {
210+ const qp = (-F * g[m]) / (omega[m] * omega[m]);
211+ const x = q[m] - qp, xd = qd[m];
212+ q[m] = p11[m] * x + p12[m] * xd + qp;
213+ qd[m] = p21[m] * x + p22[m] * xd;
214+ }
215+ zd += (F / p.stickMass) * dt;
216+ z += zd * dt;
217+ }
218+ const tEnd = step * dt;
219+ const aRe = new Float64Array(M), aIm = new Float64Array(M);
220+ for (let m = 0; m < M; m++) {
221+ aRe[m] = q[m];
222+ aIm[m] = -(qd[m] + sigma[m] * q[m]) / omegaD[m];
223+ }
224+ const qdd = new Float32Array(M * tableLen);
225+ for (let m = 0; m < M; m++) qdd.set(qddTable.subarray(m * tableCap, m * tableCap + tableLen), m * tableLen);
226+ const frames = new Float32Array(M * nFrames);
227+ for (let m = 0; m < M; m++) frames.set(qFrames.subarray(m * frameCap, m * frameCap + nFrames), m * nFrames);
228+ return { tEnd, tContact: Math.min(lastOpen, tEnd), aRe, aIm, qdd, tableLen, tableRate, qFrames: frames, nFrames, force: force.slice(0, step + 1), dt, peakForce, contactTime };
229+}
230+
231+// ---------- Listener ----------
232+
233+/** Listener terms with the common delay removed, so that only the spread of the delays across the plate remains. */
234+export function listenerWeights(pl: Plate, p: SimParams): { weight: Float64Array; delay: Float64Array; maxDelay: number } {
235+ const { weight, delay } = listenerTerms(pl, p);
236+ let dmin = Infinity;
237+ for (let k = 0; k < weight.length; k++) if (weight[k] > 0) dmin = Math.min(dmin, delay[k]);
238+ let maxDelay = 0;
239+ for (let k = 0; k < weight.length; k++) if (weight[k] > 0) { delay[k] -= dmin; maxDelay = Math.max(maxDelay, delay[k]); } else delay[k] = 0;
240+ return { weight, delay, maxDelay };
241+}
242+
243+/**
244+ * The time from which the tail formula holds at the listener: every node's
245+ * retarded time is past the last contact. The table path covers the output
246+ * samples before it.
247+ */
248+export function tailStart(c: ContactResult, maxDelay: number): number {
249+ return Math.min(c.tEnd, c.tContact + maxDelay + 2 / c.tableRate);
250+}
251+
252+// ---------- Sums over the modes (accept a sliced model) ----------
253+
254+/**
255+ * The complex radiation gain of each mode for the listener:
256+ * G_m = sum_k weight_k phi_m(k) exp(-s_m delay_k), with s_m = -sigma + i omega_d.
257+ */
258+export function radiationGains(model: ModalModel, sigma: Float64Array, weight: Float64Array, delay: Float64Array): { re: Float64Array; im: Float64Array } {
259+ const M = model.count;
260+ const re = new Float64Array(M), im = new Float64Array(M);
261+ const wd = new Float64Array(M);
262+ for (let m = 0; m < M; m++) wd[m] = dampedOmega(model.freqs[m], sigma[m]);
263+ for (let r = 0; r < model.nRows; r++) {
264+ const k = model.rowNode[r];
265+ const wk = weight[k];
266+ if (wk === 0) continue;
267+ const tau = delay[k];
268+ const base = r * M;
269+ for (let m = 0; m < M; m++) {
270+ const a = wk * model.wShapes[base + m] * Math.exp(sigma[m] * tau);
271+ re[m] += a * Math.cos(wd[m] * tau);
272+ im[m] -= a * Math.sin(wd[m] * tau);
273+ }
274+ }
275+ return { re, im };
276+}
277+
278+/** The nodal accelerations of the model's modes over the first `len` samples of the contact table, row-major over the plate nodes: out[r * len + i] += sum_m phi_m(r) qdd_m(i). */
279+export function nodalAccelerationTable(model: ModalModel, c: ContactResult, len: number, out?: Float32Array): Float32Array {
280+ const L = c.tableLen, M = model.count;
281+ const table = out ?? new Float32Array(model.nRows * len);
282+ for (let r = 0; r < model.nRows; r++) {
283+ const row = r * len, base = r * M;
284+ for (let m = 0; m < M; m++) {
285+ const phi = model.wShapes[base + m];
286+ if (phi === 0) continue;
287+ const src = m * L;
288+ for (let i = 0; i < len; i++) table[row + i] += phi * c.qdd[src + i];
289+ }
290+ }
291+ return table;
292+}
293+
294+/**
295+ * The listener pressure from `tStart` on: p(t) = sum_m Re[G_m s_m^2 A_m
296+ * exp(s_m (t - tEnd))], generated by the two-term recurrence of a damped
297+ * sinusoid, in blocks that stay in cache. Output samples before tStart are
298+ * left alone.
299+ */
300+export function modalTail(model: ModalModel, sigma: Float64Array, c: ContactResult, gains: { re: Float64Array; im: Float64Array }, fs: number, tStart: number, nSamples: number, out?: Float32Array): Float32Array {
301+ const y = out ?? new Float32Array(nSamples);
302+ const dt = 1 / fs;
303+ const block = 4096;
304+ const M = model.count;
305+ const n0 = Math.ceil(tStart * fs - 1e-9);
306+ const a = new Float64Array(M), b = new Float64Array(M), p1 = new Float64Array(M), p2 = new Float64Array(M), limit = new Float64Array(M);
307+ for (let m = 0; m < M; m++) {
308+ const s = sigma[m], wd = dampedOmega(model.freqs[m], s);
309+ // c = G s^2 A, then rotated to the first output sample: c exp(s (n0/fs - tEnd)).
310+ const s2Re = s * s - wd * wd, s2Im = -2 * s * wd;
311+ const saRe = s2Re * c.aRe[m] - s2Im * c.aIm[m], saIm = s2Re * c.aIm[m] + s2Im * c.aRe[m];
312+ let cRe = gains.re[m] * saRe - gains.im[m] * saIm, cIm = gains.re[m] * saIm + gains.im[m] * saRe;
313+ const t0 = n0 / fs - c.tEnd;
314+ const e0 = Math.exp(-s * t0), c0 = Math.cos(wd * t0), s0 = Math.sin(wd * t0);
315+ const r = e0 * (cRe * c0 - cIm * s0), i = e0 * (cRe * s0 + cIm * c0);
316+ cRe = r; cIm = i;
317+ const e = Math.exp(-s * dt), zRe = e * Math.cos(wd * dt), zIm = e * Math.sin(wd * dt);
318+ a[m] = 2 * zRe; b[m] = -(e * e);
319+ p2[m] = cRe; // p(n0)
320+ p1[m] = cRe * zRe - cIm * zIm; // p(n0 + 1)
321+ limit[m] = s > 0 ? Math.ceil((Math.log(1e7) / s) * fs) : Infinity; // 140 dB down: dropped
322+ }
323+ for (let start = n0; start < nSamples; start += block) {
324+ const end = Math.min(nSamples, start + block);
325+ for (let m = 0; m < M; m++) {
326+ if (start - n0 > limit[m]) continue;
327+ const am = a[m], bm = b[m];
328+ let x1 = p1[m], x2 = p2[m];
329+ let n = start;
330+ if (start === n0) {
331+ y[n] += x2; n++;
332+ if (n < end) { y[n] += x1; n++; }
333+ }
334+ for (; n < end; n++) {
335+ const x = am * x1 + bm * x2;
336+ y[n] += x;
337+ x2 = x1; x1 = x;
338+ }
339+ p1[m] = x1; p2[m] = x2;
340+ }
341+ }
342+ return y;
343+}
344+
345+/**
346+ * Movie frames of the transverse displacement: frames[f * nGrid + k] +=
347+ * sum_m phi_m(k) q_m(t_f), with q from the contact tables before tEnd and
348+ * from the free decay after it.
349+ */
350+export function movieFrames(model: ModalModel, sigma: Float64Array, c: ContactResult, fps: number, nFrames: number, out?: Float32Array): Float32Array {
351+ const frames = out ?? new Float32Array(nFrames * model.nGrid);
352+ const M = model.count;
353+ const q = new Float64Array(M);
354+ const wd = new Float64Array(M);
355+ for (let m = 0; m < M; m++) wd[m] = dampedOmega(model.freqs[m], sigma[m]);
356+ for (let f = 0; f < nFrames; f++) {
357+ const tt = f / fps - c.tEnd;
358+ for (let m = 0; m < M; m++) {
359+ if (f < c.nFrames) { q[m] = c.qFrames[m * c.nFrames + f]; continue; }
360+ const e = Math.exp(-sigma[m] * tt);
361+ q[m] = e * (c.aRe[m] * Math.cos(wd[m] * tt) - c.aIm[m] * Math.sin(wd[m] * tt));
362+ }
363+ const base = f * model.nGrid;
364+ for (let r = 0; r < model.nRows; r++) {
365+ const rb = r * M;
366+ let s = 0;
367+ for (let m = 0; m < M; m++) s += model.wShapes[rb + m] * q[m];
368+ frames[base + model.rowNode[r]] += s;
369+ }
370+ }
371+ return frames;
372+}
373+
374+// ---------- Assembly of the parts ----------
375+
376+/**
377+ * The listener pressure over the attack from the nodal acceleration table:
378+ * p(t) = sum_k weight_k wdd_k(t - delay_k), linearly interpolated, for the
379+ * output samples before tStart.
380+ */
381+export function attackPressure(model: ModalModel, table: Float32Array, len: number, tableRate: number, weight: Float64Array, delay: Float64Array, fs: number, tStart: number, out: Float32Array): void {
382+ const nOut = Math.min(out.length, Math.ceil(tStart * fs - 1e-9));
383+ for (let r = 0; r < model.nRows; r++) {
384+ const k = model.rowNode[r];
385+ const wk = weight[k];
386+ if (wk === 0) continue;
387+ const row = r * len;
388+ const dk = delay[k];
389+ for (let n = 0; n < nOut; n++) {
390+ const tr = n / fs - dk;
391+ if (tr < 0) continue;
392+ const x = tr * tableRate;
393+ const i = Math.floor(x);
394+ if (i + 1 >= len) continue;
395+ const fr = x - i;
396+ out[n] += wk * ((1 - fr) * table[row + i] + fr * table[row + i + 1]);
397+ }
398+ }
399+}
400+
401+export interface Synthesis {
402+ audio: Float32Array;
403+ frames: Float32Array;
404+ nFrames: number;
405+ contact: ContactResult;
406+}
407+
408+/** Runs the whole synthesis on the calling thread (the tests, and browsers without workers). */
409+export function synthesize(model: ModalModel, pl: Plate, loss: Loss, p: SimParams, withMovie = true): Synthesis {
410+ const sigma = modalRates(model, loss);
411+ const g = strikeGains(model, pl, p);
412+ const contact = simulateContact(model, sigma, g, p, p.fps);
413+ const { weight, delay, maxDelay } = listenerWeights(pl, p);
414+ const tStart = tailStart(contact, maxDelay);
415+ const len = Math.min(contact.tableLen, Math.ceil(tStart * contact.tableRate) + 2);
416+ const nSamples = Math.round(p.duration * p.fs);
417+ const audio = new Float32Array(nSamples);
418+ const table = nodalAccelerationTable(model, contact, len);
419+ attackPressure(model, table, len, contact.tableRate, weight, delay, p.fs, tStart, audio);
420+ const gains = radiationGains(model, sigma, weight, delay);
421+ modalTail(model, sigma, contact, gains, p.fs, tStart, nSamples, audio);
422+ const nFrames = withMovie ? Math.round(p.duration * p.fps) : 0;
423+ const frames = withMovie ? movieFrames(model, sigma, contact, p.fps, nFrames) : new Float32Array(0);
424+ return { audio, frames, nFrames, contact };
425+}
src/modal/worker.tsadded+73−0View file
@@ -0,0 +1,73 @@
1+// The Web Worker of the linear model. A worker holds the linear system for
2+// the eigenvalue jobs of modes.ts (inertia counts and slices) and, later, a
3+// slice of the modes for the synthesis parts of synth.ts. pool.ts drives a
4+// set of these.
5+
6+import { type LinearSystem } from "../model/assemble.ts";
7+import { runJob, type Job } from "./modes.ts";
8+import { type ContactResult, type ModalModel, modalTail, movieFrames, nodalAccelerationTable, radiationGains } from "./synth.ts";
9+
10+export type WorkerRequest =
11+ | { type: "system"; n: number; bw: number; band: Float64Array; mass: Float64Array }
12+ | { type: "job"; id: number; job: Job }
13+ | { type: "model"; model: ModalModel }
14+ | {
15+ type: "synth";
16+ id: number;
17+ sigma: Float64Array;
18+ contact: ContactResult;
19+ weight: Float64Array;
20+ delay: Float64Array;
21+ tStart: number;
22+ len: number;
23+ fs: number;
24+ nSamples: number;
25+ audio: boolean;
26+ movie: { fps: number; nFrames: number } | null;
27+ };
28+
29+export type WorkerResponse =
30+ | { type: "ready" }
31+ | { type: "job"; id: number; result: unknown }
32+ | { type: "synth"; id: number; table: Float32Array | null; audio: Float32Array | null; frames: Float32Array | null }
33+ | { type: "error"; id: number; message: string };
34+
35+let sys: LinearSystem | null = null;
36+let model: ModalModel | null = null;
37+
38+self.onmessage = (ev: MessageEvent<WorkerRequest>) => {
39+ const msg = ev.data;
40+ try {
41+ if (msg.type === "system") {
42+ sys = { n: msg.n, bw: msg.bw, band: msg.band, mass: msg.mass, fields: 1, dofNode: new Int32Array(0), nodeDof: new Int32Array(0), onStand: false };
43+ post({ type: "ready" });
44+ } else if (msg.type === "job") {
45+ if (!sys) throw new Error("no system");
46+ const result = runJob(sys, msg.job);
47+ const transfer: ArrayBuffer[] = result.kind === "slice" ? result.shapes.map((s) => s.buffer as ArrayBuffer) : [];
48+ post({ type: "job", id: msg.id, result }, transfer);
49+ } else if (msg.type === "model") {
50+ model = msg.model;
51+ post({ type: "ready" });
52+ } else if (msg.type === "synth") {
53+ if (!model) throw new Error("no model");
54+ const c = msg.contact;
55+ let table: Float32Array | null = null, audio: Float32Array | null = null, frames: Float32Array | null = null;
56+ if (msg.audio) {
57+ table = nodalAccelerationTable(model, c, msg.len);
58+ const gains = radiationGains(model, msg.sigma, msg.weight, msg.delay);
59+ audio = modalTail(model, msg.sigma, c, gains, msg.fs, msg.tStart, msg.nSamples);
60+ }
61+ if (msg.movie) frames = movieFrames(model, msg.sigma, c, msg.movie.fps, msg.movie.nFrames);
62+ const transfer: ArrayBuffer[] = [];
63+ for (const a of [table, audio, frames]) if (a) transfer.push(a.buffer as ArrayBuffer);
64+ post({ type: "synth", id: msg.id, table, audio, frames }, transfer);
65+ }
66+ } catch (e) {
67+ post({ type: "error", id: (msg as { id?: number }).id ?? -1, message: e instanceof Error ? e.message : String(e) });
68+ }
69+};
70+
71+function post(r: WorkerResponse, transfer: ArrayBuffer[] = []): void {
72+ (self as unknown as Worker).postMessage(r, transfer);
73+}
src/model/assemble.tsadded+247−0View file
@@ -0,0 +1,247 @@
1+// The linearized plate as matrices: the stiffness K (the Hessian of the
2+// discrete energy of reference.ts at the stress-free state) in symmetric
3+// banded storage, and the lumped mass M as a diagonal. A flat plate has one
4+// unknown per node (the transverse displacement w; the in-plane motion
5+// decouples and is neither struck nor heard). A plate with a profile couples
6+// w to the in-plane displacements u and v through the slopes of the profile,
7+// and has three unknowns per node, interleaved so that the band stays narrow.
8+
9+import {
10+ type Plate,
11+ type StandRates,
12+ bendingStiffness,
13+ cellThickness,
14+ insideCell,
15+ insideNode,
16+ membraneStiffness,
17+ nodeMass,
18+ nodeWeight,
19+ shearStiffness,
20+ standRates,
21+} from "../params.ts";
22+import { shapeDerivatives } from "./reference.ts";
23+
24+export interface LinearSystem {
25+ /** Number of unknowns. */
26+ n: number;
27+ /** Half-bandwidth: K[i, j] = 0 for |i - j| > bw. */
28+ bw: number;
29+ /** Lower band of K, row-major: band[i * (bw + 1) + d] = K[i, i - d] for d = 0..bw. */
30+ band: Float64Array;
31+ /** Diagonal mass of every unknown [kg]. */
32+ mass: Float64Array;
33+ /** 1 for a flat plate (w only), 3 for a plate with a profile (u, v, w interleaved). */
34+ fields: 1 | 3;
35+ /** Grid node of unknown i (its node index j * nx + i on the grid). */
36+ dofNode: Int32Array;
37+ /** First unknown of a grid node, or -1 outside the plate. */
38+ nodeDof: Int32Array;
39+ /** Whether the plate is on a stand (no rigid-body modes at zero frequency). */
40+ onStand: boolean;
41+}
42+
43+/** Whether the profile vanishes, in which case only the transverse motion is assembled. */
44+export function isFlatPlate(pl: Plate): boolean {
45+ for (let k = 0; k < pl.w0.length; k++) if (pl.w0[k] !== 0) return false;
46+ return true;
47+}
48+
49+/**
50+ * Assembles K and M for the plate. `fields` may force the three-field
51+ * system for a flat plate (the tests use it); by default a flat plate gets
52+ * the one-field system.
53+ */
54+export function assemble(pl: Plate, fields?: 1 | 3, sr: StandRates | null = standRates(pl)): LinearSystem {
55+ const g = pl.grid, m = pl.mat;
56+ const { nx, ny, hx, hy } = g;
57+ const nf: 1 | 3 = fields ?? (isFlatPlate(pl) ? 1 : 3);
58+
59+ // Number the plate nodes with the shorter grid axis innermost so that the
60+ // 13-point bending stencil, which reaches two rows away, gives the
61+ // narrowest band.
62+ const rowMajor = nx <= ny;
63+ const nodeDof = new Int32Array(g.n).fill(-1);
64+ const nodeList: number[] = [];
65+ const outer = rowMajor ? ny : nx, inner = rowMajor ? nx : ny;
66+ for (let o = 0; o < outer; o++) {
67+ for (let q = 0; q < inner; q++) {
68+ const i = rowMajor ? q : o, j = rowMajor ? o : q;
69+ if (!insideNode(g, i, j) || nodeWeight(g, i, j) === 0) continue;
70+ nodeDof[j * nx + i] = nodeList.length * nf;
71+ nodeList.push(j * nx + i);
72+ }
73+ }
74+ const nNodes = nodeList.length;
75+ const n = nNodes * nf;
76+ const dofNode = new Int32Array(n);
77+ for (let p = 0; p < nNodes; p++) for (let c = 0; c < nf; c++) dofNode[p * nf + c] = nodeList[p];
78+ const bw = nf * (2 * inner) + (nf - 1);
79+ const band = new Float64Array(n * (bw + 1));
80+ const mass = new Float64Array(n);
81+ for (let p = 0; p < nNodes; p++) {
82+ const k = nodeList[p];
83+ const mk = nodeMass(pl, k % nx, Math.floor(k / nx));
84+ for (let c = 0; c < nf; c++) mass[p * nf + c] = mk;
85+ }
86+
87+ const add = (r: number, c: number, v: number): void => {
88+ if (v === 0) return;
89+ if (r < c) { const t = r; r = c; c = t; }
90+ const d = r - c;
91+ if (d > bw) throw new Error(`assembly outside the band (${d} > ${bw})`);
92+ band[r * (bw + 1) + d] += v;
93+ };
94+ const wdof = (k: number) => nodeDof[k] + (nf - 1); // the w unknown of grid node k
95+ const id = (i: number, j: number) => j * nx + i;
96+ const nu = m.nu;
97+
98+ // ---- Bending: hx hy [ sum_k w_k D_k (a a' + b b' + nu (a b' + b a')) + 2 (1 - nu) sum_c D_c c c' ] ----
99+ const stencilA = (i: number, j: number): [number[], number[]] | null => {
100+ if (!(insideNode(g, i - 1, j) && insideNode(g, i, j) && insideNode(g, i + 1, j))) return null;
101+ return [[wdof(id(i - 1, j)), wdof(id(i, j)), wdof(id(i + 1, j))], [1 / (hx * hx), -2 / (hx * hx), 1 / (hx * hx)]];
102+ };
103+ const stencilB = (i: number, j: number): [number[], number[]] | null => {
104+ if (!(insideNode(g, i, j - 1) && insideNode(g, i, j) && insideNode(g, i, j + 1))) return null;
105+ return [[wdof(id(i, j - 1)), wdof(id(i, j)), wdof(id(i, j + 1))], [1 / (hy * hy), -2 / (hy * hy), 1 / (hy * hy)]];
106+ };
107+ const outer2 = (s: [number[], number[]], t: [number[], number[]], f: number): void => {
108+ for (let a = 0; a < s[0].length; a++) for (let b = 0; b < t[0].length; b++) {
109+ const r = s[0][a], c = t[0][b];
110+ // Add the full outer product once per (r, c) pair. The band stores
111+ // only r >= c, and the symmetric counterpart is added when the loop
112+ // reaches it, so skip r < c here unless the product is symmetric in
113+ // (s, t), in which case each unordered pair appears twice.
114+ if (r >= c) add(r, c, f * s[1][a] * t[1][b]);
115+ }
116+ };
117+ for (let j = 0; j < ny; j++) {
118+ for (let i = 0; i < nx; i++) {
119+ const wgt = nodeWeight(g, i, j);
120+ if (wgt === 0) continue;
121+ const Dk = bendingStiffness(m, pl.h[id(i, j)]);
122+ const f = hx * hy * wgt * Dk;
123+ const A = stencilA(i, j), B = stencilB(i, j);
124+ if (A) outer2(A, A, f);
125+ if (B) outer2(B, B, f);
126+ if (A && B) { outer2(A, B, f * nu); outer2(B, A, f * nu); }
127+ }
128+ }
129+ for (let cj = 0; cj < ny - 1; cj++) {
130+ for (let ci = 0; ci < nx - 1; ci++) {
131+ if (!insideCell(g, ci, cj)) continue;
132+ const Dc = bendingStiffness(m, cellThickness(pl, ci, cj));
133+ const C: [number[], number[]] = [
134+ [wdof(id(ci, cj)), wdof(id(ci + 1, cj)), wdof(id(ci, cj + 1)), wdof(id(ci + 1, cj + 1))],
135+ [1 / (hx * hy), -1 / (hx * hy), -1 / (hx * hy), 1 / (hx * hy)],
136+ ];
137+ outer2(C, C, 2 * (1 - nu) * Dc * hx * hy);
138+ }
139+ }
140+
141+ // ---- Membrane, linearized about the profile (three-field system only) ----
142+ if (nf === 3) {
143+ const q = (hx * hy) / 4;
144+ const dNdx = new Float64Array(4), dNdy = new Float64Array(4);
145+ const Bm = new Float64Array(3 * 12);
146+ const dofs = new Int32Array(12);
147+ const Ke = new Float64Array(12 * 12);
148+ for (let cj = 0; cj < ny - 1; cj++) {
149+ for (let ci = 0; ci < nx - 1; ci++) {
150+ if (!insideCell(g, ci, cj)) continue;
151+ const hc = cellThickness(pl, ci, cj);
152+ const Cm = membraneStiffness(m, hc), Gs = shearStiffness(m, hc);
153+ const nn = [id(ci, cj), id(ci + 1, cj), id(ci + 1, cj + 1), id(ci, cj + 1)];
154+ for (let b = 0; b < 4; b++) {
155+ dofs[b] = nodeDof[nn[b]]; dofs[4 + b] = nodeDof[nn[b]] + 1; dofs[8 + b] = nodeDof[nn[b]] + 2;
156+ }
157+ Ke.fill(0);
158+ for (let gp = 0; gp < 4; gp++) {
159+ shapeDerivatives(gp, hx, hy, dNdx, dNdy);
160+ let w0x = 0, w0y = 0;
161+ for (let b = 0; b < 4; b++) { w0x += dNdx[b] * pl.w0[nn[b]]; w0y += dNdy[b] * pl.w0[nn[b]]; }
162+ // Linear strains: exx = u_x + w0_x w_x, eyy = v_y + w0_y w_y, gxy = u_y + v_x + w0_x w_y + w0_y w_x.
163+ Bm.fill(0);
164+ for (let b = 0; b < 4; b++) {
165+ Bm[0 * 12 + b] = dNdx[b]; Bm[0 * 12 + 8 + b] = w0x * dNdx[b];
166+ Bm[1 * 12 + 4 + b] = dNdy[b]; Bm[1 * 12 + 8 + b] = w0y * dNdy[b];
167+ Bm[2 * 12 + b] = dNdy[b]; Bm[2 * 12 + 4 + b] = dNdx[b]; Bm[2 * 12 + 8 + b] = w0x * dNdy[b] + w0y * dNdx[b];
168+ }
169+ // Ke += q B' C B with C = [[Cm, nu Cm, 0], [nu Cm, Cm, 0], [0, 0, Gs]].
170+ for (let a = 0; a < 12; a++) {
171+ const b0 = Bm[a], b1 = Bm[12 + a], b2 = Bm[24 + a];
172+ const s0 = Cm * (b0 + nu * b1), s1 = Cm * (b1 + nu * b0), s2 = Gs * b2;
173+ for (let c = 0; c <= a; c++) Ke[a * 12 + c] += q * (s0 * Bm[c] + s1 * Bm[12 + c] + s2 * Bm[24 + c]);
174+ }
175+ }
176+ for (let a = 0; a < 12; a++) for (let c = 0; c <= a; c++) {
177+ if (a === c) add(dofs[a], dofs[c], Ke[a * 12 + c]);
178+ else if (dofs[a] !== dofs[c]) add(dofs[a], dofs[c], Ke[a * 12 + c]);
179+ }
180+ }
181+ }
182+ }
183+
184+ // ---- Stand: the pads as springs to ground ----
185+ if (sr) {
186+ for (const k of sr.nodes) {
187+ if (nodeDof[k] < 0) continue;
188+ add(wdof(k), wdof(k), sr.kw[k]);
189+ if (nf === 3) { add(nodeDof[k], nodeDof[k], sr.ku[k]); add(nodeDof[k] + 1, nodeDof[k] + 1, sr.ku[k]); }
190+ }
191+ }
192+
193+ return { n, bw, band, mass, fields: nf, dofNode, nodeDof, onStand: !!sr && sr.nodes.length > 0 };
194+}
195+
196+/** y = K x from the banded storage. */
197+export function stiffnessMatvec(sys: LinearSystem, x: Float64Array, y: Float64Array): void {
198+ const { n, bw, band } = sys;
199+ y.fill(0);
200+ for (let i = 0; i < n; i++) {
201+ const base = i * (bw + 1);
202+ const dmax = Math.min(bw, i);
203+ let s = band[base] * x[i];
204+ for (let d = 1; d <= dmax; d++) {
205+ const v = band[base + d];
206+ if (v === 0) continue;
207+ s += v * x[i - d];
208+ y[i - d] += v * x[i];
209+ }
210+ y[i] += s;
211+ }
212+}
213+
214+/** K[i, j] from the banded storage (zero outside the band). */
215+export function stiffnessEntry(sys: LinearSystem, i: number, j: number): number {
216+ if (i < j) { const t = i; i = j; j = t; }
217+ const d = i - j;
218+ return d > sys.bw ? 0 : sys.band[i * (sys.bw + 1) + d];
219+}
220+
221+/** Scatters an unknown vector to grid arrays (u, v, w), zero outside the plate; u and v are zero for the one-field system. */
222+export function toGrid(sys: LinearSystem, x: ArrayLike<number>, nGrid: number): { u: Float64Array; v: Float64Array; w: Float64Array } {
223+ const u = new Float64Array(nGrid), v = new Float64Array(nGrid), w = new Float64Array(nGrid);
224+ for (let i = 0; i < sys.n; i++) {
225+ const k = sys.dofNode[i];
226+ if (sys.fields === 1) w[k] = x[i];
227+ else {
228+ const c = i % 3;
229+ if (c === 0) u[k] = x[i]; else if (c === 1) v[k] = x[i]; else w[k] = x[i];
230+ }
231+ }
232+ return { u, v, w };
233+}
234+
235+/** Gathers grid arrays into an unknown vector. */
236+export function fromGrid(sys: LinearSystem, f: { u: Float64Array; v: Float64Array; w: Float64Array }): Float64Array {
237+ const x = new Float64Array(sys.n);
238+ for (let i = 0; i < sys.n; i++) {
239+ const k = sys.dofNode[i];
240+ if (sys.fields === 1) x[i] = f.w[k];
241+ else {
242+ const c = i % 3;
243+ x[i] = c === 0 ? f.u[k] : c === 1 ? f.v[k] : f.w[k];
244+ }
245+ }
246+ return x;
247+}
src/model/reference.tsadded+318−0View file
@@ -0,0 +1,318 @@
1+// Float64 reference implementation of the spatial discretization: the
2+// forces of the geometrically nonlinear plate on the grid, the loss terms,
3+// the discrete energy, and the largest frequency of the linearized system.
4+// It mirrors the WGSL kernel of the full simulation and is used for the
5+// time-step estimate, the tests, and the GPU-versus-CPU self-test; the
6+// linear model assembles the same discretization as sparse matrices in
7+// assemble.ts. It is not used in any time loop.
8+
9+import {
10+ type Grid,
11+ type Material,
12+ type Plate,
13+ type StandRates,
14+ bendingStiffness,
15+ cellThickness,
16+ insideCell,
17+ insideNode,
18+ membraneStiffness,
19+ nodeMass,
20+ nodeWeight,
21+ shearStiffness,
22+ standRates,
23+} from "../params.ts";
24+import { type SigmaFit } from "../loss.ts";
25+
26+export interface Fields {
27+ u: Float64Array;
28+ v: Float64Array;
29+ w: Float64Array;
30+}
31+
32+export function fields(n: number): Fields {
33+ return { u: new Float64Array(n), v: new Float64Array(n), w: new Float64Array(n) };
34+}
35+
36+/** A plate of uniform thickness on the given grid (for the tests and the time-step estimate). */
37+export function uniformPlate(g: Grid, mat: Material, h: number, w0?: Float64Array): Plate {
38+ const hs = new Float64Array(g.n);
39+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) if (insideNode(g, i, j)) hs[j * g.nx + i] = h;
40+ const freqs = new Float64Array([10, 40000]);
41+ return { name: "uniform", Lx: (g.nx - 1) * g.hx, Ly: (g.ny - 1) * g.hy, grid: g, mat, h: hs, w0: w0 ?? new Float64Array(g.n), stand: null, loss: { freqs, t60: new Float64Array([1e9, 1e9]) } };
42+}
43+
44+const G3 = 1 / Math.sqrt(3);
45+
46+/** Shape function derivatives of the bilinear cell at Gauss point gp (0..3), scaled by 2/h. */
47+export function shapeDerivatives(gp: number, hx: number, hy: number, dNdx: Float64Array, dNdy: Float64Array): void {
48+ const xi = gp & 1 ? G3 : -G3;
49+ const eta = gp & 2 ? G3 : -G3;
50+ dNdx[0] = (-(1 - eta) / 4) * (2 / hx);
51+ dNdx[1] = ((1 - eta) / 4) * (2 / hx);
52+ dNdx[2] = ((1 + eta) / 4) * (2 / hx);
53+ dNdx[3] = (-(1 + eta) / 4) * (2 / hx);
54+ dNdy[0] = (-(1 - xi) / 4) * (2 / hy);
55+ dNdy[1] = (-(1 + xi) / 4) * (2 / hy);
56+ dNdy[2] = ((1 + xi) / 4) * (2 / hy);
57+ dNdy[3] = ((1 - xi) / 4) * (2 / hy);
58+}
59+
60+/**
61+ * Force of the pads at a node with transverse displacement w. Without
62+ * lift-off the pads are a linear spring of rate kw. With lift-off each pad
63+ * pushes only while compressed: the bottom pad by (preloadBottom - w), the
64+ * top pad by (preloadTop + w); the constant part of the two, which balances
65+ * the weight of the plate, is subtracted so that w = 0 is the rest state.
66+ */
67+export function padForce(sr: StandRates, k: number, w: number, liftoff: boolean): number {
68+ if (!liftoff) return -sr.kw[k] * w;
69+ const kb = sr.kwBottom[k];
70+ const hasTop = sr.kw[k] > kb + 1e-300;
71+ const bottom = kb * Math.max(sr.preloadBottom - w, 0);
72+ const top = hasTop ? kb * Math.max(sr.preloadTop + w, 0) : 0;
73+ const rest = kb * sr.preloadBottom - (hasTop ? kb * sr.preloadTop : 0);
74+ return bottom - top - rest;
75+}
76+
77+/**
78+ * Undamped accelerations (out.u, out.v, out.w) of every node for the
79+ * displacement fields f, measured from the stress-free shape pl.w0. Bending
80+ * forces are the gradient of the discrete Kirchhoff energy with the local
81+ * bending stiffness D(h) at the nodes and at the cells (natural free-edge
82+ * conditions); membrane forces come from bilinear cells with 2x2 Gauss
83+ * integration, the cell's mean thickness, and the Marguerre form of the von
84+ * Karman strains; the pads of the stand add springs to ground over their
85+ * annulus. Nodes outside the plate get zero.
86+ */
87+export function accelerations(pl: Plate, f: Fields, out: Fields, liftoff = false, sr: StandRates | null = standRates(pl)): void {
88+ const g = pl.grid, m = pl.mat, w0 = pl.w0;
89+ const { nx, ny, hx, hy } = g;
90+ const { u, v, w } = f;
91+ const hx2 = hx * hx, hy2 = hy * hy;
92+ const nu = m.nu;
93+ const id = (i: number, j: number) => j * nx + i;
94+ const Dn = (i: number, j: number) => bendingStiffness(m, pl.h[id(i, j)]);
95+ const Dc = (ci: number, cj: number) => bendingStiffness(m, cellThickness(pl, ci, cj));
96+
97+ const hasA = (i: number, j: number) => insideNode(g, i - 1, j) && insideNode(g, i, j) && insideNode(g, i + 1, j);
98+ const hasB = (i: number, j: number) => insideNode(g, i, j - 1) && insideNode(g, i, j) && insideNode(g, i, j + 1);
99+ const dxx = (i: number, j: number) => (w[id(i + 1, j)] - 2 * w[id(i, j)] + w[id(i - 1, j)]) / hx2;
100+ const dyy = (i: number, j: number) => (w[id(i, j + 1)] - 2 * w[id(i, j)] + w[id(i, j - 1)]) / hy2;
101+ const Ma = (i: number, j: number): number => {
102+ if (!hasA(i, j)) return 0;
103+ return nodeWeight(g, i, j) * Dn(i, j) * (dxx(i, j) + nu * (hasB(i, j) ? dyy(i, j) : 0));
104+ };
105+ const Mb = (i: number, j: number): number => {
106+ if (!hasB(i, j)) return 0;
107+ return nodeWeight(g, i, j) * Dn(i, j) * (dyy(i, j) + nu * (hasA(i, j) ? dxx(i, j) : 0));
108+ };
109+ const Cc = (ci: number, cj: number): number => {
110+ if (!insideCell(g, ci, cj)) return 0;
111+ return (Dc(ci, cj) * (w[id(ci + 1, cj + 1)] - w[id(ci + 1, cj)] - w[id(ci, cj + 1)] + w[id(ci, cj)])) / (hx * hy);
112+ };
113+
114+ const q = (hx * hy) / 4;
115+ const U = new Float64Array(4), V = new Float64Array(4), Wn = new Float64Array(4), W0 = new Float64Array(4);
116+ const dNdx = new Float64Array(4), dNdy = new Float64Array(4);
117+
118+ const cellForce = (ci: number, cj: number, a: number, F: Float64Array): void => {
119+ if (!insideCell(g, ci, cj)) return;
120+ const hc = cellThickness(pl, ci, cj);
121+ const C = membraneStiffness(m, hc), Gs = shearStiffness(m, hc);
122+ const nn = [id(ci, cj), id(ci + 1, cj), id(ci + 1, cj + 1), id(ci, cj + 1)];
123+ for (let b = 0; b < 4; b++) {
124+ U[b] = u[nn[b]]; V[b] = v[nn[b]]; Wn[b] = w[nn[b]]; W0[b] = w0[nn[b]];
125+ }
126+ for (let gp = 0; gp < 4; gp++) {
127+ shapeDerivatives(gp, hx, hy, dNdx, dNdy);
128+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0, w0x = 0, w0y = 0;
129+ for (let b = 0; b < 4; b++) {
130+ ux += dNdx[b] * U[b]; uy += dNdy[b] * U[b];
131+ vx += dNdx[b] * V[b]; vy += dNdy[b] * V[b];
132+ wx += dNdx[b] * (Wn[b] + W0[b]); wy += dNdy[b] * (Wn[b] + W0[b]);
133+ w0x += dNdx[b] * W0[b]; w0y += dNdy[b] * W0[b];
134+ }
135+ const exx = ux + 0.5 * (wx * wx - w0x * w0x);
136+ const eyy = vy + 0.5 * (wy * wy - w0y * w0y);
137+ const gxy = uy + vx + wx * wy - w0x * w0y;
138+ const Nxx = C * (exx + nu * eyy);
139+ const Nyy = C * (eyy + nu * exx);
140+ const Nxy = Gs * gxy;
141+ F[0] -= q * (Nxx * dNdx[a] + Nxy * dNdy[a]);
142+ F[1] -= q * (Nyy * dNdy[a] + Nxy * dNdx[a]);
143+ F[2] -= q * ((Nxx * wx + Nxy * wy) * dNdx[a] + (Nxy * wx + Nyy * wy) * dNdy[a]);
144+ }
145+ };
146+
147+ const F = new Float64Array(3);
148+ for (let j = 0; j < ny; j++) {
149+ for (let i = 0; i < nx; i++) {
150+ const k = id(i, j);
151+ const mass = nodeMass(pl, i, j);
152+ if (mass === 0) {
153+ out.u[k] = 0; out.v[k] = 0; out.w[k] = 0;
154+ continue;
155+ }
156+ const bend =
157+ (Ma(i - 1, j) - 2 * Ma(i, j) + Ma(i + 1, j)) / hx2 +
158+ (Mb(i, j - 1) - 2 * Mb(i, j) + Mb(i, j + 1)) / hy2 +
159+ (2 * (1 - nu) * (Cc(i, j) - Cc(i - 1, j) - Cc(i, j - 1) + Cc(i - 1, j - 1))) / (hx * hy);
160+ F[0] = 0; F[1] = 0; F[2] = -hx * hy * bend;
161+ if (sr) {
162+ F[0] -= sr.ku[k] * u[k]; F[1] -= sr.ku[k] * v[k];
163+ F[2] += padForce(sr, k, w[k], liftoff);
164+ }
165+ cellForce(i, j, 0, F);
166+ cellForce(i - 1, j, 1, F);
167+ cellForce(i - 1, j - 1, 2, F);
168+ cellForce(i, j - 1, 3, F);
169+ out.u[k] = F[0] / mass;
170+ out.v[k] = F[1] / mass;
171+ out.w[k] = F[2] / mass;
172+ }
173+ }
174+}
175+
176+/** The loss terms of the full simulation: the fitted sigma model on the transverse motion, an in-plane rate, and the pads' dashpots. */
177+export interface FullLoss {
178+ sigma0: number; // [1/s], applied as 2 sigma0 on all components
179+ sigma1: number; // [m^2/s], applied as 2 sigma1 times the Laplacian of the transverse velocity
180+ sigmaIn: number; // [1/s], extra rate on the in-plane motion
181+ /** Dashpot rate of the pads per unit of pad stiffness [s]: c_k = padDamping * kw_k. */
182+ padDamping: number;
183+}
184+
185+/** The loss terms of the full simulation from the fitted sigma model and the stand. */
186+export function fullLoss(fit: SigmaFit, sr: StandRates | null, inPlaneRate: number): FullLoss {
187+ const padDamping = sr && sr.bounceHz > 0 ? sr.eta / (2 * Math.PI * sr.bounceHz) : 0;
188+ return { sigma0: fit.sigma0, sigma1: fit.sigma1, sigmaIn: inPlaneRate, padDamping };
189+}
190+
191+/**
192+ * Adds the loss terms to the accelerations `out` for the velocities `vel`:
193+ * -2 sigma0 v on every component, +2 sigma1 (graph Laplacian of w_t) on the
194+ * transverse component, -2 sigmaIn v on the in-plane components, and the
195+ * dashpots of the pads over their annulus.
196+ */
197+export function addDamping(pl: Plate, loss: FullLoss, vel: Fields, out: Fields, sr: StandRates | null = standRates(pl)): void {
198+ const g = pl.grid;
199+ const { sigma0, sigma1, sigmaIn } = loss;
200+ const { nx, ny, hx, hy } = g;
201+ const id = (i: number, j: number) => j * nx + i;
202+ for (let j = 0; j < ny; j++) {
203+ for (let i = 0; i < nx; i++) {
204+ const k = id(i, j);
205+ const mass = nodeMass(pl, i, j);
206+ if (mass === 0) continue;
207+ const c = vel.w[k];
208+ let lap = 0;
209+ if (insideNode(g, i - 1, j)) lap += (vel.w[id(i - 1, j)] - c) / (hx * hx);
210+ if (insideNode(g, i + 1, j)) lap += (vel.w[id(i + 1, j)] - c) / (hx * hx);
211+ if (insideNode(g, i, j - 1)) lap += (vel.w[id(i, j - 1)] - c) / (hy * hy);
212+ if (insideNode(g, i, j + 1)) lap += (vel.w[id(i, j + 1)] - c) / (hy * hy);
213+ const cw = sr ? (loss.padDamping * sr.kw[k]) / mass : 0;
214+ const cu = sr ? (loss.padDamping * sr.ku[k]) / mass : 0;
215+ out.u[k] -= (2 * (sigma0 + sigmaIn) + cu) * vel.u[k];
216+ out.v[k] -= (2 * (sigma0 + sigmaIn) + cu) * vel.v[k];
217+ out.w[k] += -(2 * sigma0 + cw) * c + 2 * sigma1 * lap;
218+ }
219+ }
220+}
221+
222+/** Discrete potential energy (bending + membrane + pads, linear pads) whose negative gradient is the force in `accelerations`. */
223+export function potentialEnergy(pl: Plate, f: Fields, sr: StandRates | null = standRates(pl)): number {
224+ const g = pl.grid, m = pl.mat, w0 = pl.w0;
225+ const { nx, ny, hx, hy } = g;
226+ const { u, v, w } = f;
227+ const nu = m.nu;
228+ const id = (i: number, j: number) => j * nx + i;
229+ let Ub = 0;
230+ for (let j = 0; j < ny; j++) {
231+ for (let i = 0; i < nx; i++) {
232+ let a = 0, b = 0;
233+ if (insideNode(g, i - 1, j) && insideNode(g, i, j) && insideNode(g, i + 1, j)) a = (w[id(i + 1, j)] - 2 * w[id(i, j)] + w[id(i - 1, j)]) / (hx * hx);
234+ if (insideNode(g, i, j - 1) && insideNode(g, i, j) && insideNode(g, i, j + 1)) b = (w[id(i, j + 1)] - 2 * w[id(i, j)] + w[id(i, j - 1)]) / (hy * hy);
235+ Ub += nodeWeight(g, i, j) * bendingStiffness(m, pl.h[id(i, j)]) * (a * a + b * b + 2 * nu * a * b);
236+ }
237+ }
238+ for (let cj = 0; cj < ny - 1; cj++) {
239+ for (let ci = 0; ci < nx - 1; ci++) {
240+ if (!insideCell(g, ci, cj)) continue;
241+ const c = (w[id(ci + 1, cj + 1)] - w[id(ci + 1, cj)] - w[id(ci, cj + 1)] + w[id(ci, cj)]) / (hx * hy);
242+ Ub += 2 * (1 - nu) * bendingStiffness(m, cellThickness(pl, ci, cj)) * c * c;
243+ }
244+ }
245+ Ub *= 0.5 * hx * hy;
246+
247+ let Um = 0;
248+ const q = (hx * hy) / 4;
249+ const dNdx = new Float64Array(4), dNdy = new Float64Array(4);
250+ for (let cj = 0; cj < ny - 1; cj++) {
251+ for (let ci = 0; ci < nx - 1; ci++) {
252+ if (!insideCell(g, ci, cj)) continue;
253+ const hc = cellThickness(pl, ci, cj);
254+ const C = membraneStiffness(m, hc), Gs = shearStiffness(m, hc);
255+ const nn = [id(ci, cj), id(ci + 1, cj), id(ci + 1, cj + 1), id(ci, cj + 1)];
256+ for (let gp = 0; gp < 4; gp++) {
257+ shapeDerivatives(gp, hx, hy, dNdx, dNdy);
258+ let ux = 0, uy = 0, vx = 0, vy = 0, wx = 0, wy = 0, w0x = 0, w0y = 0;
259+ for (let b = 0; b < 4; b++) {
260+ const z0 = w0[nn[b]];
261+ ux += dNdx[b] * u[nn[b]]; uy += dNdy[b] * u[nn[b]];
262+ vx += dNdx[b] * v[nn[b]]; vy += dNdy[b] * v[nn[b]];
263+ wx += dNdx[b] * (w[nn[b]] + z0); wy += dNdy[b] * (w[nn[b]] + z0);
264+ w0x += dNdx[b] * z0; w0y += dNdy[b] * z0;
265+ }
266+ const exx = ux + 0.5 * (wx * wx - w0x * w0x);
267+ const eyy = vy + 0.5 * (wy * wy - w0y * w0y);
268+ const gxy = uy + vx + wx * wy - w0x * w0y;
269+ Um += q * 0.5 * (C * (exx * exx + eyy * eyy + 2 * nu * exx * eyy) + Gs * gxy * gxy);
270+ }
271+ }
272+ }
273+
274+ let Us = 0;
275+ if (sr) for (const k of sr.nodes) Us += 0.5 * (sr.ku[k] * (u[k] * u[k] + v[k] * v[k]) + sr.kw[k] * w[k] * w[k]);
276+ return Ub + Um + Us;
277+}
278+
279+/**
280+ * Largest angular frequency of the linearized discrete system, by power
281+ * iteration on a small full grid with the same spacing and a uniform
282+ * thickness h (the largest of the plate, which bounds the bending frequency;
283+ * the in-plane wave speed does not depend on the thickness). The extreme
284+ * mode is a grid-scale mode, so neither the plate size nor its outline
285+ * matters.
286+ */
287+export function maxAngularFrequency(g: Grid, m: Material, h: number, nSmall = 12, iters = 300): number {
288+ const gs: Grid = { nx: nSmall, ny: nSmall, hx: g.hx, hy: g.hy, n: nSmall * nSmall };
289+ const pl = uniformPlate(gs, m, h);
290+ const n = gs.n;
291+ const x = fields(n), y = fields(n);
292+ let seed = 12345;
293+ const rnd = () => {
294+ seed = (seed * 1664525 + 1013904223) >>> 0;
295+ return seed / 4294967296 - 0.5;
296+ };
297+ for (let k = 0; k < n; k++) {
298+ x.u[k] = rnd(); x.v[k] = rnd(); x.w[k] = rnd();
299+ }
300+ const eps = 1e-9;
301+ let lambda = 0;
302+ for (let it = 0; it < iters; it++) {
303+ let norm = 0;
304+ for (let k = 0; k < n; k++) norm += x.u[k] ** 2 + x.v[k] ** 2 + x.w[k] ** 2;
305+ norm = Math.sqrt(norm);
306+ for (let k = 0; k < n; k++) {
307+ x.u[k] *= eps / norm; x.v[k] *= eps / norm; x.w[k] *= eps / norm;
308+ }
309+ accelerations(pl, x, y, false, null);
310+ let dot = 0;
311+ for (let k = 0; k < n; k++) dot += x.u[k] * y.u[k] + x.v[k] * y.v[k] + x.w[k] * y.w[k];
312+ lambda = -dot / (eps * eps);
313+ for (let k = 0; k < n; k++) {
314+ x.u[k] = -y.u[k]; x.v[k] = -y.v[k]; x.w[k] = -y.w[k];
315+ }
316+ }
317+ return Math.sqrt(Math.max(lambda, 0));
318+}
src/mruntime.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+// The only .m file the simulator puts in scope of a design script. It is not
2+// a design helper: the trailer that evaluatePlate appends calls it to sample
3+// a field (a number or a function handle) on the grid matrices, or the decay
4+// time on a row of frequencies, and to report a bad return value in the
5+// script's own terms. Design scripts define whatever shape functions they
6+// need themselves, at the bottom of the script.
7+
8+import type { WorkspaceFile } from "numbl";
9+
10+const SAMPLE = `function v = cymbalsim_field(f, what, x, y, r, theta)
11+% Samples a design field (a number or a function handle) on the grid matrices.
12+if isa(f, 'function_handle')
13+ try
14+ v = f(x, y, r, theta);
15+ catch e
16+ error('%s: %s', what, e.message);
17+ end
18+elseif isnumeric(f) || islogical(f)
19+ v = f;
20+else
21+ error('%s must be a number or a function handle @(x, y, r, theta)', what);
22+end
23+if isscalar(v)
24+ v = double(v) * ones(size(x));
25+elseif ~isequal(size(v), size(x))
26+ error('%s returned a %d x %d array for a %d x %d grid; use elementwise operators (.*, ./, .^)', what, size(v, 1), size(v, 2), size(x, 1), size(x, 2));
27+else
28+ v = double(v);
29+end
30+end
31+`;
32+
33+const T60 = `function v = cymbalsim_t60(f, freqs)
34+% Samples the decay time on a row of frequencies when it is a function handle.
35+try
36+ v = f(freqs);
37+catch e
38+ error('design.loss.T60: %s', e.message);
39+end
40+if isscalar(v)
41+ v = double(v) * ones(size(freqs));
42+elseif numel(v) ~= numel(freqs)
43+ error('design.loss.T60 returned %d values for %d frequencies; use elementwise operators (./, .^)', numel(v), numel(freqs));
44+else
45+ v = double(v);
46+end
47+end
48+`;
49+
50+export const RUNTIME_FILES: WorkspaceFile[] = [
51+ { name: "cymbalsim_field.m", source: SAMPLE },
52+ { name: "cymbalsim_t60.m", source: T60 },
53+];
src/params.tsadded+472−0View file
@@ -0,0 +1,472 @@
1+// Simulation parameters (strike, listener, numerics), the grid with its mask,
2+// the plate as evaluated on the grid (material, thickness field, stress-free
3+// shape, stand, losses), and the derived quantities both solvers need. All
4+// values are SI. The design itself is produced by the script in design.ts.
5+
6+export interface SimParams {
7+ // Strike
8+ strikeFracX: number; // strike point as a fraction of the bounding-box width from the left edge
9+ strikeFracY: number; // strike point as a fraction of the bounding-box height from the bottom edge
10+ strikeVelocity: number; // stick tip speed at impact [m/s]
11+ stickMass: number; // effective mass of the stick [kg]
12+ contactStiffness: number; // Hertz contact stiffness K [N/m^alpha]
13+ contactExponent: number; // Hertz exponent alpha (1.5 for a rounded tip)
14+ contactRadius: number; // radius of the contact patch [m]
15+ // Listener
16+ listenerDist: number; // [m]
17+ listenerAngle: number; // [deg] angle from the plate's normal (0 = straight above the plate)
18+ listenerAzimuth: number; // [deg] rotation about the normal (0 = toward +x)
19+ // Numerics and output
20+ spacing: number; // requested grid spacing [m]
21+ modeMaxFreq: number; // modes up to this frequency are kept in the linear model [Hz]
22+ padLiftoff: boolean; // full simulation: the pads push only while compressed
23+ fs: number; // audio sample rate [Hz]
24+ fps: number; // movie frame rate, also the batch rate of the full simulation [Hz]
25+ duration: number; // recorded duration after the impact [s]
26+}
27+
28+export const defaultParams: SimParams = {
29+ strikeFracX: 0.9,
30+ strikeFracY: 0.5,
31+ strikeVelocity: 6,
32+ stickMass: 0.03,
33+ contactStiffness: 5e8,
34+ contactExponent: 1.5,
35+ contactRadius: 5e-3,
36+ listenerDist: 0.5,
37+ listenerAngle: 30,
38+ listenerAzimuth: 0,
39+ spacing: 0.008,
40+ modeMaxFreq: 16000,
41+ padLiftoff: true,
42+ fs: 48000,
43+ fps: 120,
44+ duration: 4,
45+};
46+
47+export const SPEED_OF_SOUND = 343; // [m/s]
48+export const RHO_AIR = 1.2; // [kg/m^3]
49+export const GRAVITY = 9.81; // [m/s^2]
50+export const CFL = 0.9; // fraction of the stability limit used for the time step of the full simulation
51+export const HIST_MARGIN = 128; // ring buffer margin of the retarded-time listener sum [samples]
52+export const STICK_RELEASE = 5e-3; // the stick is withdrawn once the contact has been open this long [s]
53+export const PAD_POISSON = 0.3; // Poisson ratio of the felt, which sets its shear stiffness from E
54+
55+// ---------- Grid ----------
56+
57+export interface Grid {
58+ nx: number;
59+ ny: number;
60+ hx: number;
61+ hy: number;
62+ n: number;
63+ /** 1 for nodes that belong to the plate, 0 otherwise; absent for a full rectangle. */
64+ mask?: Uint8Array;
65+}
66+
67+/** True when the point (x, y), relative to the center of the bounding box, lies in the plate. */
68+export type InsideFn = (x: number, y: number) => boolean;
69+
70+/** The grid covering the bounding box Lx by Ly with about the requested spacing, restricted to the outline when one is given. */
71+export function makeGrid(Lx: number, Ly: number, spacing: number, inside?: InsideFn): Grid {
72+ const nx = Math.max(6, Math.round(Lx / spacing) + 1);
73+ const ny = Math.max(6, Math.round(Ly / spacing) + 1);
74+ const g: Grid = { nx, ny, hx: Lx / (nx - 1), hy: Ly / (ny - 1), n: nx * ny };
75+ return inside ? withMask(g, inside) : g;
76+}
77+
78+/**
79+ * Restricts a grid to an outline. The edge is a staircase of whole cells: a
80+ * cell belongs to the plate when its center lies inside the outline, and a
81+ * node when it is a corner of such a cell. Deciding by cell centers keeps
82+ * the covered area unbiased. A node left with no cell of its own would carry
83+ * no mass, and the trailing loop removes any such node. A hole is handled by
84+ * the same rule.
85+ */
86+export function withMask(g: Grid, inside: InsideFn): Grid {
87+ return withCellMask(g, (ci, cj) => inside(nodeX(g, ci) + g.hx / 2, nodeY(g, cj) + g.hy / 2));
88+}
89+
90+/** The same restriction with the membership of each cell (ci, cj) given directly. */
91+export function withCellMask(g: Grid, cellInside: (ci: number, cj: number) => boolean): Grid {
92+ const mask = new Uint8Array(g.n);
93+ for (let cj = 0; cj < g.ny - 1; cj++) {
94+ for (let ci = 0; ci < g.nx - 1; ci++) {
95+ if (cellInside(ci, cj)) {
96+ mask[cj * g.nx + ci] = 1; mask[cj * g.nx + ci + 1] = 1;
97+ mask[(cj + 1) * g.nx + ci] = 1; mask[(cj + 1) * g.nx + ci + 1] = 1;
98+ }
99+ }
100+ }
101+ const gm: Grid = { ...g, mask };
102+ for (let changed = true; changed; ) {
103+ changed = false;
104+ for (let j = 0; j < g.ny; j++) {
105+ for (let i = 0; i < g.nx; i++) {
106+ if (mask[j * g.nx + i] && nodeWeight(gm, i, j) === 0) {
107+ mask[j * g.nx + i] = 0;
108+ changed = true;
109+ }
110+ }
111+ }
112+ }
113+ return gm;
114+}
115+
116+/** Node coordinates relative to the center of the bounding box. */
117+export function nodeX(g: Grid, i: number): number {
118+ return (i - (g.nx - 1) / 2) * g.hx;
119+}
120+export function nodeY(g: Grid, j: number): number {
121+ return (j - (g.ny - 1) / 2) * g.hy;
122+}
123+
124+/** True when node (i, j) exists and belongs to the plate. */
125+export function insideNode(g: Grid, i: number, j: number): boolean {
126+ if (i < 0 || i >= g.nx || j < 0 || j >= g.ny) return false;
127+ return g.mask ? g.mask[j * g.nx + i] !== 0 : true;
128+}
129+
130+/** True when cell (ci, cj), with nodes (ci..ci+1, cj..cj+1), lies within the plate. */
131+export function insideCell(g: Grid, ci: number, cj: number): boolean {
132+ return insideNode(g, ci, cj) && insideNode(g, ci + 1, cj) && insideNode(g, ci + 1, cj + 1) && insideNode(g, ci, cj + 1);
133+}
134+
135+/**
136+ * Quadrature and lumped-mass weight of a node: the fraction of its four
137+ * surrounding cells that belong to the plate (1 inside, 1/2 on an edge, 1/4
138+ * at a corner of a full rectangle). Zero outside.
139+ */
140+export function nodeWeight(g: Grid, i: number, j: number): number {
141+ if (!insideNode(g, i, j)) return 0;
142+ let c = 0;
143+ if (insideCell(g, i, j)) c++;
144+ if (insideCell(g, i - 1, j)) c++;
145+ if (insideCell(g, i, j - 1)) c++;
146+ if (insideCell(g, i - 1, j - 1)) c++;
147+ return c / 4;
148+}
149+
150+/** Per-node weights as a flat array. */
151+export function nodeWeights(g: Grid): Float32Array<ArrayBuffer> {
152+ const w = new Float32Array(g.n);
153+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) w[j * g.nx + i] = nodeWeight(g, i, j);
154+ return w;
155+}
156+
157+/** Per-cell membership (1 or 0) as a flat array of (nx - 1) * (ny - 1). */
158+export function cellMask(g: Grid): Float32Array<ArrayBuffer> {
159+ const c = new Float32Array((g.nx - 1) * (g.ny - 1));
160+ for (let cj = 0; cj < g.ny - 1; cj++) for (let ci = 0; ci < g.nx - 1; ci++) c[cj * (g.nx - 1) + ci] = insideCell(g, ci, cj) ? 1 : 0;
161+ return c;
162+}
163+
164+/** Number of nodes in the plate. */
165+export function countInside(g: Grid): number {
166+ if (!g.mask) return g.n;
167+ let c = 0;
168+ for (let k = 0; k < g.n; k++) c += g.mask[k];
169+ return c;
170+}
171+
172+// ---------- Material, stand, losses, plate ----------
173+
174+export interface Material {
175+ E: number; // Young's modulus [Pa]
176+ nu: number; // Poisson ratio
177+ rho: number; // density [kg/m^3]
178+}
179+
180+/** A felt pad of the stand: an annulus of felt between the plate and the stand (or the wing nut). */
181+export interface Pad {
182+ innerRadius: number; // [m]
183+ outerRadius: number; // [m]
184+ thickness: number; // [m]
185+ E: number; // Young's modulus of the felt in compression [Pa]
186+ lossFactor: number; // loss factor eta of the felt
187+}
188+
189+/**
190+ * The stand: a rod through the center hole at (x, y) with a felt pad below
191+ * the plate and, when topPad is set, the same pad above it under the wing
192+ * nut, which compresses the top pad by `preload` at rest.
193+ */
194+export interface Stand {
195+ x: number;
196+ y: number;
197+ pad: Pad;
198+ topPad: boolean;
199+ preload: number; // compression of the top pad at rest [m]
200+}
201+
202+/**
203+ * Decay times of the plate as a function of frequency, sampled by the design
204+ * script on a fixed log-spaced set of frequencies (see loss.ts).
205+ */
206+export interface Loss {
207+ freqs: Float64Array;
208+ t60: Float64Array;
209+}
210+
211+/** The plate as evaluated on the grid. */
212+export interface Plate {
213+ name: string;
214+ Lx: number;
215+ Ly: number;
216+ grid: Grid;
217+ mat: Material;
218+ /** Thickness at the nodes [m]; zero outside the plate. */
219+ h: Float64Array;
220+ /** Stress-free height at the nodes [m], mean plane removed; zero outside the plate. */
221+ w0: Float64Array;
222+ stand: Stand | null;
223+ loss: Loss;
224+}
225+
226+export function bendingStiffness(m: Material, h: number): number {
227+ return (m.E * h * h * h) / (12 * (1 - m.nu * m.nu));
228+}
229+export function membraneStiffness(m: Material, h: number): number {
230+ return (m.E * h) / (1 - m.nu * m.nu);
231+}
232+export function shearStiffness(m: Material, h: number): number {
233+ return (m.E * h) / (2 * (1 + m.nu));
234+}
235+/** In-plane wave speed, which sets the time step of the full simulation. */
236+export function inPlaneSpeed(m: Material): number {
237+ return Math.sqrt(m.E / (m.rho * (1 - m.nu * m.nu)));
238+}
239+/** Bending-wave constant kappa = sqrt(D / rho h), so that omega = kappa k^2. */
240+export function bendingConstant(m: Material, h: number): number {
241+ return Math.sqrt(bendingStiffness(m, h) / (m.rho * h));
242+}
243+
244+/** Thickness of a cell: the mean of its four corners. */
245+export function cellThickness(pl: Plate, ci: number, cj: number): number {
246+ const nx = pl.grid.nx;
247+ return 0.25 * (pl.h[cj * nx + ci] + pl.h[cj * nx + ci + 1] + pl.h[(cj + 1) * nx + ci] + pl.h[(cj + 1) * nx + ci + 1]);
248+}
249+
250+/** Lumped mass of a node: rho h dA times its weight. */
251+export function nodeMass(pl: Plate, i: number, j: number): number {
252+ const g = pl.grid;
253+ return pl.mat.rho * pl.h[j * g.nx + i] * g.hx * g.hy * nodeWeight(g, i, j);
254+}
255+
256+/** Per-node masses as a flat array. */
257+export function nodeMasses(pl: Plate): Float64Array {
258+ const g = pl.grid;
259+ const m = new Float64Array(g.n);
260+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) m[j * g.nx + i] = nodeMass(pl, i, j);
261+ return m;
262+}
263+
264+/** Total mass, the mass-weighted center, and the moments of inertia about the in-plane axes through it. */
265+export function massProperties(pl: Plate): { mass: number; cx: number; cy: number; Ix: number; Iy: number; area: number } {
266+ const g = pl.grid;
267+ let mass = 0, sx = 0, sy = 0, area = 0;
268+ for (let j = 0; j < g.ny; j++) {
269+ for (let i = 0; i < g.nx; i++) {
270+ const m = nodeMass(pl, i, j);
271+ mass += m; sx += m * nodeX(g, i); sy += m * nodeY(g, j);
272+ area += g.hx * g.hy * nodeWeight(g, i, j);
273+ }
274+ }
275+ const cx = mass > 0 ? sx / mass : 0, cy = mass > 0 ? sy / mass : 0;
276+ let Ix = 0, Iy = 0;
277+ for (let j = 0; j < g.ny; j++) {
278+ for (let i = 0; i < g.nx; i++) {
279+ const m = nodeMass(pl, i, j);
280+ Ix += m * (nodeY(g, j) - cy) ** 2;
281+ Iy += m * (nodeX(g, i) - cx) ** 2;
282+ }
283+ }
284+ return { mass, cx, cy, Ix, Iy, area };
285+}
286+
287+/** Mean thickness over the plate (area-weighted). */
288+export function meanThickness(pl: Plate): number {
289+ const g = pl.grid;
290+ let s = 0, a = 0;
291+ for (let j = 0; j < g.ny; j++) {
292+ for (let i = 0; i < g.nx; i++) {
293+ const w = nodeWeight(g, i, j);
294+ s += w * pl.h[j * g.nx + i]; a += w;
295+ }
296+ }
297+ return a > 0 ? s / a : 0;
298+}
299+
300+/** Smallest and largest thickness over the plate. */
301+export function thicknessRange(pl: Plate): { min: number; max: number } {
302+ const g = pl.grid;
303+ let lo = Infinity, hi = 0;
304+ for (let j = 0; j < g.ny; j++) {
305+ for (let i = 0; i < g.nx; i++) {
306+ if (!insideNode(g, i, j)) continue;
307+ const h = pl.h[j * g.nx + i];
308+ lo = Math.min(lo, h); hi = Math.max(hi, h);
309+ }
310+ }
311+ return { min: isFinite(lo) ? lo : 0, max: hi };
312+}
313+
314+// ---------- Stand as seen by the solvers ----------
315+
316+/**
317+ * The pads as per-node spring rates. `kw[k]` is the transverse stiffness of
318+ * the pads at node k (both pads summed), `ku[k]` the in-plane (shear)
319+ * stiffness, both in N/m; `kwBottom` is the bottom pad alone, which the full
320+ * simulation needs for the lift-off rule. `eta` is the loss factor, and
321+ * `preloadTop`, `preloadBottom` are the pad compressions at rest.
322+ */
323+export interface StandRates {
324+ kw: Float64Array;
325+ ku: Float64Array;
326+ kwBottom: Float64Array;
327+ nodes: number[];
328+ eta: number;
329+ preloadTop: number;
330+ preloadBottom: number;
331+ /** Total transverse stiffness of the pads [N/m]. */
332+ totalKw: number;
333+ /** Bounce frequency of the plate on the pads [Hz]. */
334+ bounceHz: number;
335+}
336+
337+/**
338+ * Per-node pad stiffness of the stand, or null for a free plate. The pads
339+ * act on the plate nodes within the annulus; because a coarse grid covers
340+ * the annulus with a handful of nodes, the per-node rates are scaled so
341+ * that their sum equals the stiffness of the exact annulus area.
342+ */
343+export function standRates(pl: Plate): StandRates | null {
344+ const st = pl.stand;
345+ if (!st) return null;
346+ const g = pl.grid;
347+ const kw = new Float64Array(g.n), ku = new Float64Array(g.n), kwBottom = new Float64Array(g.n);
348+ const nodes: number[] = [];
349+ const pad = st.pad;
350+ const kArea = pad.E / pad.thickness; // N/m^3
351+ const gArea = pad.E / (2 * (1 + PAD_POISSON)) / pad.thickness;
352+ const nPads = st.topPad ? 2 : 1;
353+ const annulus = Math.PI * (pad.outerRadius ** 2 - pad.innerRadius ** 2);
354+ let nodeArea = 0;
355+ for (let j = 0; j < g.ny; j++) {
356+ for (let i = 0; i < g.nx; i++) {
357+ const wgt = nodeWeight(g, i, j);
358+ if (wgt === 0) continue;
359+ const r = Math.hypot(nodeX(g, i) - st.x, nodeY(g, j) - st.y);
360+ if (r < pad.innerRadius || r > pad.outerRadius) continue;
361+ nodes.push(j * g.nx + i);
362+ nodeArea += g.hx * g.hy * wgt;
363+ }
364+ }
365+ const empty = { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop: 0, preloadBottom: 0, totalKw: 0, bounceHz: 0 };
366+ if (nodes.length === 0 || nodeArea === 0) return empty;
367+ const scale = annulus / nodeArea;
368+ let totalKw = 0;
369+ for (const k of nodes) {
370+ const dA = scale * g.hx * g.hy * nodeWeight(g, k % g.nx, Math.floor(k / g.nx));
371+ kwBottom[k] = kArea * dA;
372+ kw[k] = nPads * kArea * dA;
373+ ku[k] = nPads * gArea * dA;
374+ totalKw += kw[k];
375+ }
376+ const { mass } = massProperties(pl);
377+ const preloadTop = st.topPad ? st.preload : 0;
378+ // The bottom pad carries the top pad's preload plus the weight of the plate.
379+ const preloadBottom = preloadTop + (mass * GRAVITY) / (kArea * annulus);
380+ return { kw, ku, kwBottom, nodes, eta: pad.lossFactor, preloadTop, preloadBottom, totalKw, bounceHz: Math.sqrt(totalKw / mass) / (2 * Math.PI) };
381+}
382+
383+// ---------- Patches ----------
384+
385+/** A set of plate nodes with the quantities the strike needs. */
386+export interface Patch {
387+ nodes: number[];
388+ nearest: number;
389+ mass: number;
390+ cx: number;
391+ cy: number;
392+}
393+
394+/** The plate nodes within `radius` of (x, y), always including the nearest one. */
395+export function patchAround(pl: Plate, x: number, y: number, radius: number): Patch {
396+ const g = pl.grid;
397+ const nodes: number[] = [];
398+ let nearest = -1, best = Infinity;
399+ for (let j = 0; j < g.ny; j++) {
400+ for (let i = 0; i < g.nx; i++) {
401+ if (nodeMass(pl, i, j) === 0) continue;
402+ const d = Math.hypot(nodeX(g, i) - x, nodeY(g, j) - y);
403+ if (d < best) { best = d; nearest = j * g.nx + i; }
404+ if (d <= radius) nodes.push(j * g.nx + i);
405+ }
406+ }
407+ if (nearest >= 0 && !nodes.includes(nearest)) nodes.push(nearest);
408+ let mass = 0, cx = 0, cy = 0;
409+ for (const k of nodes) {
410+ const m = nodeMass(pl, k % g.nx, Math.floor(k / g.nx));
411+ mass += m; cx += m * nodeX(g, k % g.nx); cy += m * nodeY(g, Math.floor(k / g.nx));
412+ }
413+ return { nodes, nearest, mass, cx: mass > 0 ? cx / mass : 0, cy: mass > 0 ? cy / mass : 0 };
414+}
415+
416+/** The strike point in plate coordinates from the fractions of the bounding box. */
417+export function strikePoint(pl: Plate, p: SimParams): { x: number; y: number } {
418+ return { x: (p.strikeFracX - 0.5) * pl.Lx, y: (p.strikeFracY - 0.5) * pl.Ly };
419+}
420+
421+export function strikePatch(pl: Plate, p: SimParams): Patch {
422+ const { x, y } = strikePoint(pl, p);
423+ return patchAround(pl, x, y, p.contactRadius);
424+}
425+
426+// ---------- Listener ----------
427+
428+/** Listener position relative to the center of the plate, z along the plate's normal. */
429+export function listenerPosition(p: SimParams): { x: number; y: number; z: number } {
430+ const th = (p.listenerAngle * Math.PI) / 180, ph = (p.listenerAzimuth * Math.PI) / 180;
431+ return { x: p.listenerDist * Math.sin(th) * Math.cos(ph), y: p.listenerDist * Math.sin(th) * Math.sin(ph), z: p.listenerDist * Math.cos(th) };
432+}
433+
434+/**
435+ * Per-node terms of the Rayleigh integral for the listener: the weight
436+ * rho_air dA / (2 pi r) and the delay r / c in seconds, for every node (zero
437+ * outside the plate). The profile is included in the node positions.
438+ */
439+export function listenerTerms(pl: Plate, p: SimParams): { weight: Float64Array; delay: Float64Array } {
440+ const g = pl.grid;
441+ const L = listenerPosition(p);
442+ const weight = new Float64Array(g.n), delay = new Float64Array(g.n);
443+ for (let j = 0; j < g.ny; j++) {
444+ for (let i = 0; i < g.nx; i++) {
445+ const wgt = nodeWeight(g, i, j);
446+ if (wgt === 0) continue;
447+ const k = j * g.nx + i;
448+ const r = Math.hypot(L.x - nodeX(g, i), L.y - nodeY(g, j), L.z - pl.w0[k]);
449+ weight[k] = (RHO_AIR * g.hx * g.hy * wgt) / (2 * Math.PI * Math.max(r, 1e-3));
450+ delay[k] = r / SPEED_OF_SOUND;
451+ }
452+ }
453+ return { weight, delay };
454+}
455+
456+// ---------- Grid dispersion ----------
457+
458+/**
459+ * Frequencies at which grid dispersion lowers a bending partial by the given
460+ * fraction, for a plate of thickness h: a plane wave along a grid axis has the
461+ * discrete frequency ratio (2 - 2 cos kh) / (kh)^2.
462+ */
463+export function dispersionFrequency(m: Material, h: number, spacing: number, fraction: number): number {
464+ let lo = 0, hi = Math.PI;
465+ for (let it = 0; it < 60; it++) {
466+ const mid = (lo + hi) / 2;
467+ const ratio = (2 - 2 * Math.cos(mid)) / (mid * mid);
468+ if (1 - ratio < fraction) lo = mid; else hi = mid;
469+ }
470+ const k = lo / spacing;
471+ return (bendingConstant(m, h) * k * k) / (2 * Math.PI);
472+}
src/presets.tsadded+235−0View file
@@ -0,0 +1,235 @@
1+// Predefined design scripts, selectable from the interface. Each is a
2+// MATLAB-syntax script that fills in the `design` struct; see design.ts for
3+// the fields. A script is self-contained: the shape functions it uses are
4+// ordinary MATLAB functions written at the bottom of the script itself.
5+
6+export interface Preset {
7+ id: string;
8+ label: string;
9+ source: string;
10+}
11+
12+const flatRectangle = `% A flat rectangular sheet of stainless steel, hanging freely (no stand).
13+% The script is an ordinary MATLAB program that fills in a struct named
14+% design. Lengths are meters; the constants at the top are the only units.
15+% A field can be a number or a function handle @(x, y, r, theta) of the
16+% point, measured from the center of the bounding box; the handles are
17+% applied to whole grid matrices, so use elementwise operators (.*, ./, .^)
18+% inside them. Anything else a script needs it defines itself, as MATLAB
19+% functions at the bottom; the other presets show how.
20+inch = 0.0254; % m
21+mm = 1e-3; % m
22+
23+design.name = 'Flat steel rectangle';
24+design.material.E = 170e9; % Young's modulus [Pa]
25+design.material.nu = 0.29; % Poisson ratio
26+design.material.rho = 7900; % density [kg/m^3]
27+design.bounds.width = 12*inch; % bounding box [m]
28+design.bounds.height = 18*inch;
29+design.thickness = 0.79*mm;
30+design.stand = []; % free plate
31+design.loss.T60 = @(f) 3 ./ (1 + f/1500); % decay time [s] as a function of frequency [Hz]
32+`;
33+
34+const bowedRectangle = `% The same steel sheet with a 3 mm parabolic bow across its width, the
35+% kind of bow that rolling and handling leave in real sheet metal. The bow
36+% makes the sheet a shallow shell: the linear modes couple bending to
37+% stretching, and the full simulation oil-cans and wobbles when struck.
38+inch = 0.0254; mm = 1e-3;
39+W = 12*inch; H = 18*inch;
40+
41+design.name = 'Bowed steel rectangle';
42+design.material.E = 170e9;
43+design.material.nu = 0.29;
44+design.material.rho = 7900;
45+design.bounds.width = W;
46+design.bounds.height = H;
47+design.thickness = 0.79*mm;
48+design.profile = @(x, y, r, theta) bow(x, W, 3*mm, 0.3); % stress-free height [m]
49+design.stand = [];
50+design.loss.T60 = @(f) 3 ./ (1 + f/1500);
51+
52+function v = bow(s, span, sag, offset)
53+% A parabola of the given sag across a span centered on 0; s runs along the
54+% span, and the offset in -1..1 moves the crest toward one end.
55+t = 2*s/span;
56+v = sag * (1 - t.^2) .* (1 + offset*t);
57+end
58+`;
59+
60+const flatDisc = `% A flat 16 inch bronze disc with a center hole on a cymbal stand. The
61+% stand is a rod through the hole with a felt pad below the plate and
62+% another above it under the wing nut; each pad is an annulus of felt of
63+% the given radii and thickness, and its stiffness per unit area is E over
64+% the thickness. The pads hold the disc level and let it bounce and rock.
65+inch = 0.0254; mm = 1e-3;
66+D = 16*inch; hole = 13*mm;
67+
68+design.name = 'Flat bronze disc';
69+design.material.E = 110e9; % bronze
70+design.material.nu = 0.34;
71+design.material.rho = 8800;
72+design.bounds.width = D;
73+design.bounds.height = D;
74+design.inside = @(x, y, r, theta) (r <= D/2) & (r >= hole/2);
75+design.thickness = 1.0*mm;
76+design.stand.pad.innerRadius = 7*mm; % the felt sits on the plastic sleeve of the rod
77+design.stand.pad.outerRadius = 20*mm;
78+design.stand.pad.thickness = 12*mm;
79+design.stand.pad.E = 0.5e6; % Pa, felt in compression
80+design.stand.pad.lossFactor = 0.3;
81+design.stand.topPad = true;
82+design.stand.preload = 1*mm; % compression of the top pad by the wing nut
83+design.loss.T60 = @(f) 4 ./ (1 + f/2000);
84+`;
85+
86+const crash16 = `% A 16 inch flat crash: bronze, lathed to a taper from 1.2 mm at the
87+% center to 0.7 mm at the edge, on a stand. Flat, so the linear modes are
88+% pure bending modes; the tapered profile is what makes it a crash rather
89+% than a ride.
90+inch = 0.0254; mm = 1e-3;
91+D = 16*inch; R = D/2; hole = 13*mm;
92+tCenter = 1.2*mm; tEdge = 0.7*mm;
93+
94+design.name = '16 inch flat crash';
95+design.material.E = 110e9;
96+design.material.nu = 0.34;
97+design.material.rho = 8800;
98+design.bounds.width = D;
99+design.bounds.height = D;
100+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
101+design.thickness = @(x, y, r, theta) tCenter + (tEdge - tCenter) * ramp(40*mm, R, r);
102+design.stand.pad.innerRadius = 7*mm;
103+design.stand.pad.outerRadius = 20*mm;
104+design.stand.pad.thickness = 12*mm;
105+design.stand.pad.E = 0.5e6;
106+design.stand.pad.lossFactor = 0.3;
107+design.stand.topPad = true;
108+design.stand.preload = 1*mm;
109+design.loss.T60 = [100 5; 1000 3.5; 5000 1.2; 15000 0.4]; % table of [Hz seconds], interpolated
110+
111+function v = ramp(a, b, x)
112+% 0 at x = a, 1 at x = b, clamped to that range outside.
113+v = min(max((x - a) ./ (b - a), 0), 1);
114+end
115+`;
116+
117+const ride20 = `% A 20 inch flat ride: thicker bronze, 2.2 mm at the center to 1.4 mm at
118+% the edge, on a stand with a loose wing nut (a small preload). The
119+% thickness raises every frequency in proportion, and the ride has a
120+% clearer ping and a longer decay than the crash.
121+inch = 0.0254; mm = 1e-3;
122+D = 20*inch; R = D/2; hole = 13*mm;
123+
124+design.name = '20 inch flat ride';
125+design.material.E = 110e9;
126+design.material.nu = 0.34;
127+design.material.rho = 8800;
128+design.bounds.width = D;
129+design.bounds.height = D;
130+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
131+design.thickness = @(x, y, r, theta) 2.2*mm + (1.4*mm - 2.2*mm) * ramp(50*mm, R, r);
132+design.stand.pad.innerRadius = 7*mm;
133+design.stand.pad.outerRadius = 22*mm;
134+design.stand.pad.thickness = 12*mm;
135+design.stand.pad.E = 0.5e6;
136+design.stand.pad.lossFactor = 0.3;
137+design.stand.topPad = true;
138+design.stand.preload = 0.3*mm;
139+design.loss.T60 = @(f) 8 ./ (1 + f/2500);
140+
141+function v = ramp(a, b, x)
142+v = min(max((x - a) ./ (b - a), 0), 1);
143+end
144+`;
145+
146+const crashBell16 = `% A 16 inch crash with a shallow bow and a cosine bell, on a stand. The
147+% profile makes the cymbal a shallow shell: the linear model couples the
148+% bending to the stretching (a larger eigenproblem, so the modes take
149+% longer), and the full simulation shows the asymmetric nonlinearity of a
150+% curved plate. The slopes are kept small; the interface reports the
151+% largest one.
152+inch = 0.0254; mm = 1e-3;
153+D = 16*inch; R = D/2; hole = 13*mm;
154+bellR = 55*mm; bellH = 8*mm; bowH = 10*mm;
155+tCenter = 1.2*mm; tEdge = 0.7*mm;
156+
157+design.name = '16 inch crash with bell';
158+design.material.E = 110e9;
159+design.material.nu = 0.34;
160+design.material.rho = 8800;
161+design.bounds.width = D;
162+design.bounds.height = D;
163+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
164+design.thickness = @(x, y, r, theta) tCenter + (tEdge - tCenter) * ramp(bellR, R, r);
165+design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH);
166+design.stand.pad.innerRadius = 7*mm;
167+design.stand.pad.outerRadius = 20*mm;
168+design.stand.pad.thickness = 12*mm;
169+design.stand.pad.E = 0.5e6;
170+design.stand.pad.lossFactor = 0.3;
171+design.stand.topPad = true;
172+design.stand.preload = 1*mm;
173+design.loss.T60 = [100 5; 1000 3.5; 5000 1.2; 15000 0.4];
174+
175+function v = ramp(a, b, x)
176+v = min(max((x - a) ./ (b - a), 0), 1);
177+end
178+
179+function v = dome(d, radius, height)
180+% A cosine dome of the given height and base radius, with zero slope both at
181+% its rim and at its apex; d is the distance from its center.
182+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
183+end
184+`;
185+
186+const ellipse = `% A free elliptical steel plate. The outline is any test on the point;
187+% an ellipse inscribed in the bounding box here. The degenerate mode pairs
188+% of a disc split on an ellipse, which the mode browser shows.
189+inch = 0.0254; mm = 1e-3;
190+A = 14*inch; B = 10*inch;
191+
192+design.name = 'Steel ellipse';
193+design.material.E = 170e9;
194+design.material.nu = 0.29;
195+design.material.rho = 7900;
196+design.bounds.width = A;
197+design.bounds.height = B;
198+design.inside = @(x, y, r, theta) (x/(A/2)).^2 + (y/(B/2)).^2 <= 1;
199+design.thickness = 1.0*mm;
200+design.stand = [];
201+design.loss.T60 = @(f) 3 ./ (1 + f/1500);
202+`;
203+
204+const thunderSheet = `% A thunder sheet: a large thin steel sheet hung from one corner. Its modes
205+% are dense and low, and the full simulation turns a hard hit into a roar.
206+% Long at the default spacing; a coarser grid keeps it interactive.
207+inch = 0.0254; mm = 1e-3;
208+
209+design.name = 'Thunder sheet';
210+design.material.E = 200e9;
211+design.material.nu = 0.3;
212+design.material.rho = 7850;
213+design.bounds.width = 24*inch;
214+design.bounds.height = 36*inch;
215+design.thickness = 0.5*mm;
216+design.stand = [];
217+design.loss.T60 = @(f) 2.5 ./ (1 + f/1000);
218+`;
219+
220+export const PRESETS: Preset[] = [
221+ { id: "flat-rectangle", label: "Flat steel rectangle", source: flatRectangle },
222+ { id: "bowed-rectangle", label: "Bowed steel rectangle", source: bowedRectangle },
223+ { id: "flat-disc", label: "Flat bronze disc on a stand", source: flatDisc },
224+ { id: "crash-16", label: "16 inch flat crash", source: crash16 },
225+ { id: "ride-20", label: "20 inch flat ride", source: ride20 },
226+ { id: "crash-bell-16", label: "16 inch crash with bell", source: crashBell16 },
227+ { id: "ellipse", label: "Steel ellipse", source: ellipse },
228+ { id: "thunder-sheet", label: "Thunder sheet", source: thunderSheet },
229+];
230+
231+export const DEFAULT_PRESET = "crash-16";
232+
233+export function presetById(id: string): Preset | undefined {
234+ return PRESETS.find((p) => p.id === id);
235+}
src/render/mat4.tsadded+69−0View file
@@ -0,0 +1,69 @@
1+// Column-major 4x4 matrices in the WebGPU convention (clip z in [0, 1]).
2+
3+export type Mat4 = Float32Array;
4+export type Vec3 = [number, number, number];
5+
6+export function perspective(fovY: number, aspect: number, near: number, far: number): Mat4 {
7+ const f = 1 / Math.tan(fovY / 2);
8+ const m = new Float32Array(16);
9+ m[0] = f / aspect;
10+ m[5] = f;
11+ m[10] = far / (near - far);
12+ m[11] = -1;
13+ m[14] = (near * far) / (near - far);
14+ return m;
15+}
16+
17+export function ortho(left: number, right: number, bottom: number, top: number, near: number, far: number): Mat4 {
18+ const m = new Float32Array(16);
19+ m[0] = 2 / (right - left);
20+ m[5] = 2 / (top - bottom);
21+ m[10] = -1 / (far - near);
22+ m[12] = -(right + left) / (right - left);
23+ m[13] = -(top + bottom) / (top - bottom);
24+ m[14] = -near / (far - near);
25+ m[15] = 1;
26+ return m;
27+}
28+
29+export function lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4 {
30+ const z = unit([eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]]);
31+ const x = unit(cross(up, z));
32+ const y = cross(z, x);
33+ const m = new Float32Array(16);
34+ m[0] = x[0]; m[1] = y[0]; m[2] = z[0];
35+ m[4] = x[1]; m[5] = y[1]; m[6] = z[1];
36+ m[8] = x[2]; m[9] = y[2]; m[10] = z[2];
37+ m[12] = -dot(x, eye); m[13] = -dot(y, eye); m[14] = -dot(z, eye);
38+ m[15] = 1;
39+ return m;
40+}
41+
42+export function multiply(a: Mat4, b: Mat4): Mat4 {
43+ const out = new Float32Array(16);
44+ for (let c = 0; c < 4; c++) {
45+ for (let r = 0; r < 4; r++) {
46+ let s = 0;
47+ for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
48+ out[c * 4 + r] = s;
49+ }
50+ }
51+ return out;
52+}
53+
54+export function cross(a: Vec3, b: Vec3): Vec3 {
55+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
56+}
57+export function dot(a: Vec3, b: Vec3): number {
58+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
59+}
60+export function unit(a: Vec3): Vec3 {
61+ const l = Math.hypot(a[0], a[1], a[2]) || 1;
62+ return [a[0] / l, a[1] / l, a[2] / l];
63+}
64+export function scale(a: Vec3, s: number): Vec3 {
65+ return [a[0] * s, a[1] * s, a[2] * s];
66+}
67+export function add(a: Vec3, b: Vec3): Vec3 {
68+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
69+}
src/render/renderer.tsadded+872−0View file
@@ -0,0 +1,872 @@
1+// Drawing of the plate: a 2D color map, or (WebGPU only) a lit 3D height
2+// field of the displayed field plus, optionally, the stress-free profile,
3+// colored by the height or by the thickness. Both views paint the strike
4+// point, the stand pad and the listener as markers. Without WebGPU the map is
5+// drawn with the 2D canvas API and the 3D mode falls back to the map.
6+//
7+// On the GPU the plate is one indexed mesh: a static vertex buffer of node
8+// positions (x, y), a per-frame vertex buffer of height, slope and color per
9+// node, and an index buffer of two triangles per cell of the plate built once
10+// per setPlate. The 2D map is the same mesh under an orthographic camera with
11+// the heights zeroed. Markers are a small triangle mesh rebuilt on the CPU
12+// whenever the markers, the view or the field change.
13+
14+import { type Plate, insideCell, insideNode, nodeX, nodeY, thicknessRange } from "../params.ts";
15+import { type Mat4, type Vec3, add, cross, dot, lookAt, multiply, ortho, perspective, scale, unit } from "./mat4.ts";
16+import shaderSource from "./shaders.wgsl?raw";
17+
18+export type ViewMode = "2d" | "3d";
19+export type ColorMode = "height" | "thickness";
20+
21+export interface ViewOptions {
22+ mode: ViewMode;
23+ color: ColorMode;
24+ /** Add the stress-free profile w0 to the displayed field. */
25+ showProfile: boolean;
26+ /** Vertical scale factor of the 3D height field (1 = true scale). */
27+ exaggeration: number;
28+ /** Color range in meters (symmetric +-range for the height); 0 = automatic from the field. */
29+ range: number;
30+}
31+
32+export interface Markers {
33+ /** Plate coordinates [m]. */
34+ strike: { x: number; y: number; radius: number } | null;
35+ /** Pad annulus of the stand [m]. */
36+ stand: { x: number; y: number; innerRadius: number; outerRadius: number } | null;
37+ /** Relative to the plate center [m], z along the normal. */
38+ listener: { x: number; y: number; z: number } | null;
39+}
40+
41+type RGB = [number, number, number];
42+
43+const BG: RGB = [0.11, 0.12, 0.14];
44+const COLOR_STRIKE: RGB = [0.95, 0.78, 0.12];
45+const COLOR_STAND: RGB = [0.1, 0.1, 0.1];
46+const COLOR_POST: RGB = [0.55, 0.55, 0.58];
47+const COLOR_LINE: RGB = [0.55, 0.55, 0.55];
48+const COLOR_MIC_HEAD: RGB = [0.32, 0.32, 0.35];
49+const COLOR_MIC_BODY: RGB = [0.72, 0.73, 0.76];
50+const LIGHT: Vec3 = [0.35, -0.5, 0.8];
51+
52+/** Vertical field of view of the 3D camera [rad] and the framing margin. */
53+const FOV = 0.7;
54+const FIT_MARGIN = 1.08;
55+/** The drawn listener distance saturates at this many plate radii from the center (about one radius outside the edge). */
56+const LISTENER_REACH = 1.8;
57+/** Fill fraction of the canvas taken by the plate in the 2D map. */
58+const FILL_2D = 0.82;
59+const RING_SEGMENTS = 64;
60+const MESH_STRIDE = 16; // bytes per node in the per-frame buffer: z, slope x, slope y, packed color
61+const MARKER_STRIDE = 28; // bytes per marker vertex: position, normal, packed color
62+const AUTO_RANGE_FLOOR = 1e-6;
63+
64+// ---------- Color maps (shared by the GPU path and the 2D fallback) ----------
65+
66+/** Diverging blue-white-red for t in [-1, 1]. */
67+function diverging(t: number): RGB {
68+ const s = Math.max(-1, Math.min(1, t));
69+ const mid: RGB = [0.95, 0.95, 0.95];
70+ const end: RGB = s < 0 ? [0.1, 0.35, 0.85] : [0.85, 0.2, 0.12];
71+ const a = Math.abs(s);
72+ return [mid[0] + (end[0] - mid[0]) * a, mid[1] + (end[1] - mid[1]) * a, mid[2] + (end[2] - mid[2]) * a];
73+}
74+
75+const VIRIDIS: RGB[] = [
76+ [0.267, 0.005, 0.329], [0.283, 0.141, 0.458], [0.254, 0.265, 0.530], [0.207, 0.372, 0.553], [0.164, 0.471, 0.558],
77+ [0.128, 0.567, 0.551], [0.135, 0.659, 0.518], [0.267, 0.749, 0.441], [0.478, 0.821, 0.318], [0.741, 0.873, 0.150], [0.993, 0.906, 0.144],
78+];
79+
80+/** Sequential viridis-like map for t in [0, 1]. */
81+function viridis(t: number): RGB {
82+ const s = Math.max(0, Math.min(1, t)) * (VIRIDIS.length - 1);
83+ const i = Math.min(VIRIDIS.length - 2, Math.floor(s));
84+ const f = s - i;
85+ const a = VIRIDIS[i], b = VIRIDIS[i + 1];
86+ return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
87+}
88+
89+/** RGBA8 packed as the little-endian u32 a vertex attribute of format unorm8x4 reads. */
90+function pack(c: RGB): number {
91+ const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255);
92+ const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255);
93+ const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255);
94+ return (r | (g << 8) | (b << 16) | (255 << 24)) >>> 0;
95+}
96+
97+function css(c: RGB): string {
98+ return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
99+}
100+
101+// ---------- Marker geometry ----------
102+
103+/** A growable triangle list in the marker vertex layout (position, flat normal, packed color). */
104+class MarkerMesh {
105+ f = new Float32Array(7 * 4096);
106+ u = new Uint32Array(this.f.buffer);
107+ count = 0;
108+
109+ clear(): void {
110+ this.count = 0;
111+ }
112+ get byteLength(): number {
113+ return this.count * MARKER_STRIDE;
114+ }
115+ private ensure(nVerts: number): void {
116+ const need = (this.count + nVerts) * 7;
117+ if (need <= this.f.length) return;
118+ const f = new Float32Array(Math.max(need, this.f.length * 2));
119+ f.set(this.f.subarray(0, this.count * 7));
120+ this.f = f;
121+ this.u = new Uint32Array(f.buffer);
122+ }
123+ tri(a: Vec3, b: Vec3, c: Vec3, color: number): void {
124+ this.ensure(3);
125+ const n = unit(cross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
126+ for (const p of [a, b, c]) {
127+ const o = this.count * 7;
128+ this.f[o] = p[0]; this.f[o + 1] = p[1]; this.f[o + 2] = p[2];
129+ this.f[o + 3] = n[0]; this.f[o + 4] = n[1]; this.f[o + 5] = n[2];
130+ this.u[o + 6] = color;
131+ this.count++;
132+ }
133+ }
134+ quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: number): void {
135+ this.tri(a, b, c, color);
136+ this.tri(a, c, d, color);
137+ }
138+ /** An annulus of mean radius r and half-width hw about (cx, cy), lifted to zAt(x, y). */
139+ ring(cx: number, cy: number, r: number, hw: number, zAt: (x: number, y: number) => number, color: number): void {
140+ const ri = Math.max(0, r - hw), ro = r + hw;
141+ for (let s = 0; s < RING_SEGMENTS; s++) {
142+ const a0 = (2 * Math.PI * s) / RING_SEGMENTS, a1 = (2 * Math.PI * (s + 1)) / RING_SEGMENTS;
143+ const p = (rad: number, a: number): Vec3 => {
144+ const x = cx + rad * Math.cos(a), y = cy + rad * Math.sin(a);
145+ return [x, y, zAt(x, y)];
146+ };
147+ this.quad(p(ri, a0), p(ro, a0), p(ro, a1), p(ri, a1), color);
148+ }
149+ }
150+ /** A closed cylinder from p0 to p1. */
151+ cylinder(p0: Vec3, p1: Vec3, radius: number, segments: number, color: number): void {
152+ const axis = unit([p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]);
153+ const [e1, e2] = frame(axis);
154+ const rim = (p: Vec3, a: number): Vec3 => add(p, add(scale(e1, radius * Math.cos(a)), scale(e2, radius * Math.sin(a))));
155+ for (let s = 0; s < segments; s++) {
156+ const a0 = (2 * Math.PI * s) / segments, a1 = (2 * Math.PI * (s + 1)) / segments;
157+ this.quad(rim(p0, a0), rim(p1, a0), rim(p1, a1), rim(p0, a1), color);
158+ this.tri(p0, rim(p0, a1), rim(p0, a0), color);
159+ this.tri(p1, rim(p1, a0), rim(p1, a1), color);
160+ }
161+ }
162+ /** A box centered at c with the unit axes (ax, ay, az) and half sizes h. */
163+ box(c: Vec3, ax: Vec3, ay: Vec3, az: Vec3, h: Vec3, color: number): void {
164+ const corner = (sx: number, sy: number, sz: number): Vec3 =>
165+ add(c, add(scale(ax, sx * h[0]), add(scale(ay, sy * h[1]), scale(az, sz * h[2]))));
166+ const faces: [Vec3, Vec3, Vec3, Vec3][] = [
167+ [corner(-1, -1, 1), corner(1, -1, 1), corner(1, 1, 1), corner(-1, 1, 1)],
168+ [corner(-1, -1, -1), corner(-1, 1, -1), corner(1, 1, -1), corner(1, -1, -1)],
169+ [corner(1, -1, -1), corner(1, 1, -1), corner(1, 1, 1), corner(1, -1, 1)],
170+ [corner(-1, -1, -1), corner(-1, -1, 1), corner(-1, 1, 1), corner(-1, 1, -1)],
171+ [corner(-1, 1, -1), corner(-1, 1, 1), corner(1, 1, 1), corner(1, 1, -1)],
172+ [corner(-1, -1, -1), corner(1, -1, -1), corner(1, -1, 1), corner(-1, -1, 1)],
173+ ];
174+ for (const [a, b, cc, d] of faces) this.quad(a, b, cc, d, color);
175+ }
176+}
177+
178+/** Two unit vectors completing a right-handed frame with the unit vector u. */
179+function frame(u: Vec3): [Vec3, Vec3] {
180+ const t: Vec3 = Math.abs(u[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
181+ const e1 = unit(cross(t, u));
182+ return [e1, cross(u, e1)];
183+}
184+
185+/**
186+ * The distance at which a listener is drawn, given its true distance d: it
187+ * follows d at first and saturates at `limit`, so a listener a meter away
188+ * still appears just outside the plate.
189+ */
190+function saturate(d: number, limit: number): number {
191+ return limit * Math.tanh(d / limit);
192+}
193+
194+// ---------- Renderer ----------
195+
196+export class PlateRenderer {
197+ readonly has3d: boolean;
198+
199+ private canvas: HTMLCanvasElement;
200+ private device: GPUDevice | null;
201+ private gpuCtx: GPUCanvasContext | null = null;
202+ private ctx2d: CanvasRenderingContext2D | null = null;
203+ private format: GPUTextureFormat = "bgra8unorm";
204+ private meshPipeline: GPURenderPipeline | null = null;
205+ private markerPipeline: GPURenderPipeline | null = null;
206+ private uniformBuf: GPUBuffer | null = null;
207+ private bindGroup: GPUBindGroup | null = null;
208+ private xyBuf: GPUBuffer | null = null;
209+ private nodeBuf: GPUBuffer | null = null;
210+ private indexBuf: GPUBuffer | null = null;
211+ private indexCount = 0;
212+ private markerBuf: GPUBuffer | null = null;
213+ private markerCapacity = 0;
214+ private depth: GPUTexture | null = null;
215+
216+ private plate: Plate | null = null;
217+ private field: Float32Array | Float64Array | null = null;
218+ private opts: ViewOptions = { mode: "2d", color: "height", showProfile: true, exaggeration: 1, range: 0 };
219+ private markers: Markers = { strike: null, stand: null, listener: null };
220+
221+ // Derived per plate
222+ private hRange = { min: 0, max: 1 };
223+ private w0Max = 0;
224+ /** Largest in-plane distance of a plate node from the center [m], and half of the larger box side. */
225+ private plateRadius = 0.1;
226+ private boxRadius = 0.1;
227+ private runMax = 0;
228+
229+ // Per-frame node records: display value, height (exaggerated), and the packed vertex records.
230+ private val = new Float32Array(0);
231+ private z = new Float32Array(0);
232+ private nodeData = new ArrayBuffer(0);
233+ private zMax = 0;
234+ private markerMesh = new MarkerMesh();
235+
236+ private geomDirty = true;
237+ private fieldDirty = true;
238+ private markerDirty = true;
239+ private imageDirty = true;
240+ private image: HTMLCanvasElement | null = null;
241+
242+ // Orbit camera of the 3D view
243+ private azimuth = -Math.PI / 2 + 0.3;
244+ private elevation = Math.PI / 6;
245+ private zoom = 1;
246+ private frameRequested = false;
247+ private destroyed = false;
248+ private listeners: [string, EventListener, AddEventListenerOptions | undefined][] = [];
249+
250+ constructor(canvas: HTMLCanvasElement, device: GPUDevice | null) {
251+ this.canvas = canvas;
252+ this.device = device;
253+ this.has3d = device !== null;
254+ if (device) {
255+ this.gpuCtx = canvas.getContext("webgpu") as GPUCanvasContext | null;
256+ if (!this.gpuCtx) throw new Error("WebGPU canvas context unavailable");
257+ this.format = navigator.gpu.getPreferredCanvasFormat();
258+ this.gpuCtx.configure({ device, format: this.format, alphaMode: "opaque" });
259+ this.createPipelines(device);
260+ } else {
261+ this.ctx2d = canvas.getContext("2d");
262+ if (!this.ctx2d) throw new Error("2D canvas context unavailable");
263+ }
264+ this.installControls();
265+ }
266+
267+ private createPipelines(device: GPUDevice): void {
268+ const module = device.createShaderModule({ code: shaderSource });
269+ this.uniformBuf = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
270+ const bgLayout = device.createBindGroupLayout({
271+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
272+ });
273+ this.bindGroup = device.createBindGroup({ layout: bgLayout, entries: [{ binding: 0, resource: { buffer: this.uniformBuf } }] });
274+ const layout = device.createPipelineLayout({ bindGroupLayouts: [bgLayout] });
275+ const depthStencil: GPUDepthStencilState = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" };
276+ const fragment: GPUFragmentState = { module, entryPoint: "fsMain", targets: [{ format: this.format }] };
277+ this.meshPipeline = device.createRenderPipeline({
278+ layout,
279+ vertex: {
280+ module,
281+ entryPoint: "vsMesh",
282+ buffers: [
283+ { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] },
284+ {
285+ arrayStride: MESH_STRIDE,
286+ attributes: [
287+ { shaderLocation: 1, offset: 0, format: "float32" },
288+ { shaderLocation: 2, offset: 4, format: "float32x2" },
289+ { shaderLocation: 3, offset: 12, format: "unorm8x4" },
290+ ],
291+ },
292+ ],
293+ },
294+ fragment,
295+ primitive: { topology: "triangle-list", cullMode: "none" },
296+ depthStencil,
297+ });
298+ this.markerPipeline = device.createRenderPipeline({
299+ layout,
300+ vertex: {
301+ module,
302+ entryPoint: "vsMarker",
303+ buffers: [
304+ {
305+ arrayStride: MARKER_STRIDE,
306+ attributes: [
307+ { shaderLocation: 0, offset: 0, format: "float32x3" },
308+ { shaderLocation: 1, offset: 12, format: "float32x3" },
309+ { shaderLocation: 2, offset: 24, format: "unorm8x4" },
310+ ],
311+ },
312+ ],
313+ },
314+ fragment,
315+ primitive: { topology: "triangle-list", cullMode: "none" },
316+ depthStencil,
317+ });
318+ }
319+
320+ private installControls(): void {
321+ if (!this.has3d) return;
322+ let dragging = false, lastX = 0, lastY = 0;
323+ const on = <K extends keyof HTMLElementEventMap>(type: K, fn: (e: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions) => {
324+ this.canvas.addEventListener(type, fn, opts);
325+ this.listeners.push([type, fn as EventListener, opts]);
326+ };
327+ on("pointerdown", (e) => {
328+ if (this.opts.mode !== "3d") return;
329+ dragging = true; lastX = e.clientX; lastY = e.clientY;
330+ this.canvas.setPointerCapture(e.pointerId);
331+ });
332+ on("pointerup", () => { dragging = false; });
333+ on("pointercancel", () => { dragging = false; });
334+ on("pointermove", (e) => {
335+ if (!dragging || this.opts.mode !== "3d") return;
336+ this.azimuth -= (e.clientX - lastX) * 0.008;
337+ this.elevation = Math.max(-1.45, Math.min(1.45, this.elevation + (e.clientY - lastY) * 0.008));
338+ lastX = e.clientX; lastY = e.clientY;
339+ this.markerDirty = true;
340+ this.requestFrame();
341+ });
342+ on("wheel", (e) => {
343+ if (this.opts.mode !== "3d") return;
344+ e.preventDefault();
345+ this.zoom = Math.max(0.05, Math.min(20, this.zoom * Math.exp(e.deltaY * 0.001)));
346+ this.requestFrame();
347+ }, { passive: false });
348+ on("dblclick", () => {
349+ if (this.opts.mode !== "3d") return;
350+ this.azimuth = -Math.PI / 2 + 0.3;
351+ this.elevation = Math.PI / 6;
352+ this.zoom = 1;
353+ this.requestFrame();
354+ });
355+ }
356+
357+ private requestFrame(): void {
358+ if (this.frameRequested) return;
359+ this.frameRequested = true;
360+ requestAnimationFrame(() => {
361+ this.frameRequested = false;
362+ this.render();
363+ });
364+ }
365+
366+ // ---------- Public API ----------
367+
368+ /** New geometry (grid, mask, thickness, profile); resets the automatic color range and the field (to zeros). */
369+ setPlate(plate: Plate): void {
370+ this.plate = plate;
371+ const g = plate.grid;
372+ this.hRange = thicknessRange(plate);
373+ let w0Max = 0, rp = 0;
374+ for (let j = 0; j < g.ny; j++) {
375+ for (let i = 0; i < g.nx; i++) {
376+ if (!insideNode(g, i, j)) continue;
377+ w0Max = Math.max(w0Max, Math.abs(plate.w0[j * g.nx + i]));
378+ rp = Math.max(rp, Math.hypot(nodeX(g, i), nodeY(g, j)));
379+ }
380+ }
381+ this.w0Max = w0Max;
382+ this.plateRadius = Math.max(rp, 1e-3);
383+ this.boxRadius = Math.max(plate.Lx, plate.Ly) / 2 || 1e-3;
384+ this.runMax = 0;
385+ this.field = null;
386+ this.val = new Float32Array(g.n);
387+ this.z = new Float32Array(g.n);
388+ this.nodeData = new ArrayBuffer(g.n * MESH_STRIDE);
389+ this.image = null;
390+ this.geomDirty = true;
391+ this.fieldDirty = true;
392+ this.markerDirty = true;
393+ this.imageDirty = true;
394+ this.updateRunMax();
395+ }
396+
397+ /** The displayed field per grid node [m]; null shows zeros (the design preview). The array is read at render time. */
398+ setField(w: Float32Array | Float64Array | null): void {
399+ this.field = w && this.plate && w.length >= this.plate.grid.n ? w : null;
400+ this.updateRunMax();
401+ this.fieldDirty = true;
402+ this.markerDirty = true;
403+ this.imageDirty = true;
404+ }
405+
406+ setOptions(opts: Partial<ViewOptions>): void {
407+ this.opts = { ...this.opts, ...opts };
408+ this.fieldDirty = true;
409+ this.markerDirty = true;
410+ this.imageDirty = true;
411+ }
412+
413+ setMarkers(m: Markers): void {
414+ this.markers = m;
415+ this.markerDirty = true;
416+ }
417+
418+ /** Matches the canvas to its CSS size and devicePixelRatio; recreates the depth buffer when the size changes. */
419+ resize(): void {
420+ const dpr = window.devicePixelRatio || 1;
421+ const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));
422+ const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));
423+ if (this.canvas.width === w && this.canvas.height === h && (this.depth || !this.device)) return;
424+ this.canvas.width = w;
425+ this.canvas.height = h;
426+ if (this.device) {
427+ this.depth?.destroy();
428+ this.depth = this.device.createTexture({ size: [w, h], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT });
429+ }
430+ this.markerDirty = true;
431+ }
432+
433+ render(): void {
434+ if (this.destroyed) return;
435+ this.resize();
436+ if (this.device) this.renderGpu(this.device);
437+ else this.renderFallback();
438+ }
439+
440+ destroy(): void {
441+ this.destroyed = true;
442+ for (const [type, fn, opts] of this.listeners) this.canvas.removeEventListener(type, fn, opts);
443+ this.listeners = [];
444+ this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
445+ this.markerBuf?.destroy(); this.uniformBuf?.destroy(); this.depth?.destroy();
446+ this.xyBuf = this.nodeBuf = this.indexBuf = this.markerBuf = this.uniformBuf = null;
447+ this.depth = null;
448+ this.gpuCtx?.unconfigure();
449+ this.gpuCtx = null;
450+ this.device = null;
451+ this.plate = null;
452+ }
453+
454+ // ---------- Field and color ----------
455+
456+ private updateRunMax(): void {
457+ const pl = this.plate, w = this.field;
458+ if (!pl || !w) return;
459+ const g = pl.grid;
460+ let m = 0;
461+ if (g.mask) {
462+ for (let k = 0; k < g.n; k++) if (g.mask[k]) m = Math.max(m, Math.abs(w[k]));
463+ } else {
464+ for (let k = 0; k < g.n; k++) m = Math.max(m, Math.abs(w[k]));
465+ }
466+ if (isFinite(m)) this.runMax = Math.max(this.runMax, m);
467+ }
468+
469+ /** The color range in use for the height coloring [m]. */
470+ private colorRange(): number {
471+ if (this.opts.range > 0) return this.opts.range;
472+ return Math.max(this.runMax + (this.opts.showProfile ? this.w0Max : 0), AUTO_RANGE_FLOOR);
473+ }
474+
475+ private colorOf(val: number, h: number, range: number): RGB {
476+ if (this.opts.color === "thickness") return viridis((h - this.hRange.min) / Math.max(this.hRange.max - this.hRange.min, 1e-9));
477+ return diverging(val / range);
478+ }
479+
480+ /** Recomputes the displayed value, the height and the vertex records of every node. */
481+ private rebuildField(): void {
482+ const pl = this.plate!;
483+ const g = pl.grid;
484+ const w = this.field;
485+ const exag = this.opts.exaggeration;
486+ const range = this.colorRange();
487+ const val = this.val, z = this.z;
488+ let zMax = 0;
489+ for (let k = 0; k < g.n; k++) {
490+ const v = (w ? w[k] : 0) + (this.opts.showProfile ? pl.w0[k] : 0);
491+ val[k] = v;
492+ z[k] = v * exag;
493+ if (!g.mask || g.mask[k]) zMax = Math.max(zMax, Math.abs(z[k]));
494+ }
495+ this.zMax = zMax;
496+ const f = new Float32Array(this.nodeData), u = new Uint32Array(this.nodeData);
497+ for (let j = 0; j < g.ny; j++) {
498+ for (let i = 0; i < g.nx; i++) {
499+ const k = j * g.nx + i;
500+ const o = k * 4;
501+ if (!insideNode(g, i, j)) { f[o] = 0; f[o + 1] = 0; f[o + 2] = 0; u[o + 3] = 0; continue; }
502+ f[o] = z[k];
503+ f[o + 1] = this.slope(i, j, 1, 0, g.hx);
504+ f[o + 2] = this.slope(i, j, 0, 1, g.hy);
505+ u[o + 3] = pack(this.colorOf(val[k], pl.h[k], range));
506+ }
507+ }
508+ this.fieldDirty = false;
509+ }
510+
511+ /** Slope of the displayed height along one axis, one-sided where a neighbor lies outside the plate. */
512+ private slope(i: number, j: number, di: number, dj: number, h: number): number {
513+ const g = this.plate!.grid, z = this.z;
514+ const hasP = insideNode(g, i + di, j + dj), hasM = insideNode(g, i - di, j - dj);
515+ const k = j * g.nx + i, kp = (j + dj) * g.nx + i + di, km = (j - dj) * g.nx + i - di;
516+ if (hasP && hasM) return (z[kp] - z[km]) / (2 * h);
517+ if (hasP) return (z[kp] - z[k]) / h;
518+ if (hasM) return (z[k] - z[km]) / h;
519+ return 0;
520+ }
521+
522+ /**
523+ * The displayed height at (x, y), bilinear over the plate nodes of its cell
524+ * (missing corners dropped), or `fallback` when no corner is in the plate.
525+ */
526+ private surfaceZ(x: number, y: number, fallback: number): number {
527+ const g = this.plate!.grid;
528+ const fi = x / g.hx + (g.nx - 1) / 2, fj = y / g.hy + (g.ny - 1) / 2;
529+ const i0 = Math.max(0, Math.min(g.nx - 2, Math.floor(fi))), j0 = Math.max(0, Math.min(g.ny - 2, Math.floor(fj)));
530+ const a = Math.max(0, Math.min(1, fi - i0)), b = Math.max(0, Math.min(1, fj - j0));
531+ let s = 0, wsum = 0;
532+ const acc = (i: number, j: number, wt: number) => {
533+ if (wt <= 0 || !insideNode(g, i, j)) return;
534+ s += wt * this.z[j * g.nx + i]; wsum += wt;
535+ };
536+ acc(i0, j0, (1 - a) * (1 - b)); acc(i0 + 1, j0, a * (1 - b)); acc(i0, j0 + 1, (1 - a) * b); acc(i0 + 1, j0 + 1, a * b);
537+ return wsum > 0 ? s / wsum : fallback;
538+ }
539+
540+ /** Mean displayed height of the plate nodes within `r` of (cx, cy), or 0 when there are none. */
541+ private meanZWithin(cx: number, cy: number, r: number): number {
542+ const g = this.plate!.grid;
543+ let s = 0, c = 0;
544+ for (let j = 0; j < g.ny; j++) {
545+ for (let i = 0; i < g.nx; i++) {
546+ if (!insideNode(g, i, j) || Math.hypot(nodeX(g, i) - cx, nodeY(g, j) - cy) > r) continue;
547+ s += this.z[j * g.nx + i]; c++;
548+ }
549+ }
550+ return c > 0 ? s / c : 0;
551+ }
552+
553+ // ---------- Listener placement ----------
554+
555+ /** Where the listener is drawn in the 2D map: its in-plane direction, at a saturated distance from the center. */
556+ private listener2d(): { x: number; y: number; dir: [number, number] } | null {
557+ const L = this.markers.listener;
558+ if (!L) return null;
559+ const rho = Math.hypot(L.x, L.y);
560+ const dir: [number, number] = rho > 1e-9 ? [L.x / rho, L.y / rho] : [0, -1];
561+ const d = saturate(rho, 1.15 * this.boxRadius);
562+ return { x: dir[0] * d, y: dir[1] * d, dir };
563+ }
564+
565+ /** Where the listener is drawn in 3D: its true direction, at a distance saturating about one plate radius outside the edge. */
566+ private listener3d(): { p: Vec3; u: Vec3 } | null {
567+ const L = this.markers.listener;
568+ if (!L) return null;
569+ const d = Math.hypot(L.x, L.y, L.z);
570+ if (d < 1e-9) return null;
571+ const u: Vec3 = [L.x / d, L.y / d, L.z / d];
572+ return { p: scale(u, saturate(d, LISTENER_REACH * this.plateRadius)), u };
573+ }
574+
575+ // ---------- Markers ----------
576+
577+ private rebuildMarkers(is3d: boolean): void {
578+ const mesh = this.markerMesh;
579+ mesh.clear();
580+ const R = this.boxRadius;
581+ const lw = 0.012 * R; // width of the ring lines
582+ const lift = 0.004 * R; // markers float this far above the surface
583+ const { strike, stand, listener } = this.markers;
584+ const zStand = stand ? this.meanZWithin(stand.x, stand.y, stand.outerRadius) : 0;
585+ const zAt = is3d ? (x: number, y: number) => this.surfaceZ(x, y, zStand) + lift : () => lift;
586+ if (stand) {
587+ const dark = pack(COLOR_STAND);
588+ if (stand.innerRadius > 0) mesh.ring(stand.x, stand.y, stand.innerRadius, lw / 2, zAt, dark);
589+ mesh.ring(stand.x, stand.y, stand.outerRadius, lw / 2, zAt, dark);
590+ if (is3d) {
591+ const ro = stand.outerRadius;
592+ const rod = stand.innerRadius > 0 ? 0.6 * stand.innerRadius : 0.3 * ro;
593+ mesh.cylinder([stand.x, stand.y, zStand - 2 * ro], [stand.x, stand.y, zStand + 0.5 * ro], rod, 20, pack(COLOR_POST));
594+ }
595+ }
596+ if (strike) mesh.ring(strike.x, strike.y, Math.max(strike.radius, 2 * lw), lw / 2, zAt, pack(COLOR_STRIKE));
597+ if (listener) {
598+ if (is3d) {
599+ const l = this.listener3d();
600+ if (l) {
601+ const [e1, e2] = frame(l.u);
602+ const head = 0.035 * R, body = 0.025 * R, len = 0.06 * R;
603+ const zc = this.surfaceZ(0, 0, zStand);
604+ const start: Vec3 = [0, 0, zc + lift];
605+ const end = add(l.p, scale(l.u, -head));
606+ if (dot(end, l.u) > dot(start, l.u)) mesh.cylinder(start, end, 0.003 * R, 6, pack(COLOR_LINE));
607+ mesh.box(l.p, e1, e2, l.u, [head, head, head], pack(COLOR_MIC_HEAD));
608+ mesh.box(add(l.p, scale(l.u, head + len)), e1, e2, l.u, [body, body, len], pack(COLOR_MIC_BODY));
609+ }
610+ } else {
611+ const l = this.listener2d();
612+ if (l) {
613+ const rm = 0.045 * R;
614+ const z1 = lift, z2 = 2 * lift;
615+ // Line from the plate center, then the symbol: a round head and a stem pointing away from the center.
616+ const [dx, dy] = l.dir;
617+ const nx = -dy, ny = dx, hw = 0.3 * lw;
618+ mesh.quad([nx * hw, ny * hw, z1], [-nx * hw, -ny * hw, z1], [l.x - nx * hw, l.y - ny * hw, z1], [l.x + nx * hw, l.y + ny * hw, z1], pack(COLOR_LINE));
619+ mesh.ring(l.x, l.y, rm / 2, rm / 2, () => z2, pack(COLOR_MIC_BODY));
620+ const sw = 0.28 * rm, s0 = 0.8 * rm, s1 = 2.2 * rm;
621+ mesh.quad(
622+ [l.x + dx * s0 + nx * sw, l.y + dy * s0 + ny * sw, z2], [l.x + dx * s0 - nx * sw, l.y + dy * s0 - ny * sw, z2],
623+ [l.x + dx * s1 - nx * sw, l.y + dy * s1 - ny * sw, z2], [l.x + dx * s1 + nx * sw, l.y + dy * s1 + ny * sw, z2],
624+ pack(COLOR_MIC_HEAD),
625+ );
626+ }
627+ }
628+ }
629+ this.markerDirty = false;
630+ }
631+
632+ // ---------- Cameras ----------
633+
634+ /** Half extents of the orthographic 2D view in plate coordinates. */
635+ private extent2d(aspect: number): { W: number; H: number } {
636+ const pl = this.plate!;
637+ let need = Math.max(pl.Lx / 2, (pl.Ly / 2) * aspect);
638+ const l = this.listener2d();
639+ if (l) {
640+ const m = 0.12 * this.boxRadius; // extent of the microphone symbol
641+ need = Math.max(need, Math.abs(l.x) + m, (Math.abs(l.y) + m) * aspect);
642+ }
643+ const W = need / FILL_2D;
644+ return { W, H: W / aspect };
645+ }
646+
647+ private camera2d(aspect: number): Mat4 {
648+ const { W, H } = this.extent2d(aspect);
649+ return multiply(ortho(-W, W, -H, H, 0.5, 1.5), lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]));
650+ }
651+
652+ /**
653+ * The orbit camera about the plate center. The distance is fitted, unless
654+ * the user has zoomed, so that the plate (an in-plane circle of the plate
655+ * radius at the displayed heights) and the microphone fill the view; the
656+ * frustum is then shifted so that the fitted scene, not the plate center,
657+ * sits in the middle of the canvas, which matters when the microphone is
658+ * high above the plate.
659+ */
660+ private camera3d(aspect: number): { mvp: Mat4; eye: Vec3 } {
661+ const el = this.elevation, az = this.azimuth;
662+ const dir: Vec3 = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
663+ const fwd = scale(dir, -1);
664+ const right = unit(cross(fwd, [0, 0, 1]));
665+ const up = cross(right, fwd);
666+ const horiz = unit(Math.hypot(fwd[0], fwd[1]) > 1e-6 ? [fwd[0], fwd[1], 0] : [1, 0, 0]);
667+ const Rp = this.plateRadius, zm = Math.max(this.zMax, 1e-4);
668+ const pts: [Vec3, number][] = [];
669+ for (const s of [-1, 1]) for (const sz of [-1, 1]) {
670+ pts.push([add(scale(right, s * Rp), [0, 0, sz * zm]), 0]);
671+ pts.push([add(scale(horiz, s * Rp), [0, 0, sz * zm]), 0]);
672+ }
673+ const l = this.listener3d();
674+ if (l) pts.push([l.p, 0.12 * this.boxRadius]);
675+ const tanH = Math.tan(FOV / 2), tanW = tanH * aspect;
676+ // Screen bounds of the points, as tangents of the angles from the view axis, seen from distance D.
677+ const bounds = (D: number) => {
678+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
679+ for (const [p, r] of pts) {
680+ const depth = D - dot(p, dir);
681+ if (depth <= 1e-6) return null;
682+ const px = dot(p, right) / depth, py = dot(p, up) / depth, pr = r / depth;
683+ x0 = Math.min(x0, px - pr); x1 = Math.max(x1, px + pr);
684+ y0 = Math.min(y0, py - pr); y1 = Math.max(y1, py + pr);
685+ }
686+ return { x0, x1, y0, y1 };
687+ };
688+ const fits = (D: number) => {
689+ const b = bounds(D);
690+ return !!b && b.x1 - b.x0 <= (2 * tanW) / FIT_MARGIN && b.y1 - b.y0 <= (2 * tanH) / FIT_MARGIN;
691+ };
692+ // The symmetric fit about the center always holds; bisect down from it toward the depth of the scene.
693+ let hi = 0, lo = 0, reach = 0;
694+ for (const [p, r] of pts) {
695+ const v = (Math.abs(dot(p, up)) + r) / tanH, h = (Math.abs(dot(p, right)) + r) / tanW;
696+ hi = Math.max(hi, dot(p, dir) + Math.max(v, h));
697+ lo = Math.max(lo, dot(p, dir) + r);
698+ reach = Math.max(reach, Math.hypot(p[0], p[1], p[2]) + r);
699+ }
700+ hi = Math.max(hi * FIT_MARGIN, 1e-3);
701+ lo = Math.min(lo + 1e-3, hi);
702+ for (let it = 0; it < 24; it++) {
703+ const mid = (lo + hi) / 2;
704+ if (fits(mid)) hi = mid; else lo = mid;
705+ }
706+ const auto = hi;
707+ const b = bounds(auto);
708+ const sx = b ? (b.x0 + b.x1) / 2 / tanW : 0, sy = b ? (b.y0 + b.y1) / 2 / tanH : 0;
709+ const shift = new Float32Array(16);
710+ shift[0] = shift[5] = shift[10] = shift[15] = 1;
711+ shift[12] = -sx; shift[13] = -sy;
712+ const dist = Math.max(1e-3, auto * this.zoom);
713+ const eye = scale(dir, dist);
714+ const near = Math.max(1e-4, 0.02 * dist), far = dist + 4 * reach + 1;
715+ const mvp = multiply(shift, multiply(perspective(FOV, aspect, near, far), lookAt(eye, [0, 0, 0], [0, 0, 1])));
716+ return { mvp, eye };
717+ }
718+
719+ // ---------- WebGPU path ----------
720+
721+ private uploadGeometry(device: GPUDevice): void {
722+ const g = this.plate!.grid;
723+ const xy = new Float32Array(g.n * 2);
724+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) { xy[2 * (j * g.nx + i)] = nodeX(g, i); xy[2 * (j * g.nx + i) + 1] = nodeY(g, j); }
725+ const idx = new Uint32Array((g.nx - 1) * (g.ny - 1) * 6);
726+ let c = 0;
727+ for (let cj = 0; cj < g.ny - 1; cj++) {
728+ for (let ci = 0; ci < g.nx - 1; ci++) {
729+ if (!insideCell(g, ci, cj)) continue;
730+ const a = cj * g.nx + ci, b = a + 1, d = a + g.nx, e = d + 1;
731+ idx[c++] = a; idx[c++] = b; idx[c++] = e;
732+ idx[c++] = a; idx[c++] = e; idx[c++] = d;
733+ }
734+ }
735+ this.indexCount = c;
736+ this.xyBuf?.destroy(); this.nodeBuf?.destroy(); this.indexBuf?.destroy();
737+ this.xyBuf = device.createBuffer({ size: Math.max(16, xy.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
738+ device.queue.writeBuffer(this.xyBuf, 0, xy);
739+ this.nodeBuf = device.createBuffer({ size: Math.max(16, g.n * MESH_STRIDE), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
740+ this.indexBuf = device.createBuffer({ size: Math.max(16, c * 4), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
741+ if (c > 0) device.queue.writeBuffer(this.indexBuf, 0, idx, 0, c);
742+ this.geomDirty = false;
743+ }
744+
745+ private renderGpu(device: GPUDevice): void {
746+ const ctx = this.gpuCtx;
747+ if (!ctx || !this.depth || !this.meshPipeline || !this.markerPipeline || !this.uniformBuf || !this.bindGroup) return;
748+ const is3d = this.opts.mode === "3d";
749+ const aspect = this.canvas.width / this.canvas.height;
750+ const clear = { r: BG[0], g: BG[1], b: BG[2], a: 1 };
751+ const encoder = device.createCommandEncoder();
752+ const pass = encoder.beginRenderPass({
753+ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: clear, loadOp: "clear", storeOp: "store" }],
754+ depthStencilAttachment: { view: this.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
755+ });
756+ if (this.plate) {
757+ if (this.geomDirty) this.uploadGeometry(device);
758+ if (this.fieldDirty) {
759+ this.rebuildField();
760+ device.queue.writeBuffer(this.nodeBuf!, 0, this.nodeData);
761+ }
762+ if (this.markerDirty) {
763+ this.rebuildMarkers(is3d);
764+ const bytes = this.markerMesh.byteLength;
765+ if (bytes > this.markerCapacity) {
766+ this.markerBuf?.destroy();
767+ this.markerCapacity = Math.max(1024, bytes * 2);
768+ this.markerBuf = device.createBuffer({ size: this.markerCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
769+ }
770+ if (bytes > 0) device.queue.writeBuffer(this.markerBuf!, 0, this.markerMesh.f.buffer, 0, bytes);
771+ }
772+ let mvp: Mat4, eye: Vec3;
773+ if (is3d) ({ mvp, eye } = this.camera3d(aspect));
774+ else { mvp = this.camera2d(aspect); eye = [0, 0, 1]; }
775+ const u = new Float32Array(24);
776+ u.set(mvp, 0);
777+ u[16] = eye[0]; u[17] = eye[1]; u[18] = eye[2]; u[19] = is3d ? 1 : 0;
778+ u[20] = LIGHT[0]; u[21] = LIGHT[1]; u[22] = LIGHT[2]; u[23] = is3d ? 1 : 0;
779+ device.queue.writeBuffer(this.uniformBuf, 0, u);
780+
781+ pass.setBindGroup(0, this.bindGroup);
782+ if (this.indexCount > 0) {
783+ pass.setPipeline(this.meshPipeline);
784+ pass.setVertexBuffer(0, this.xyBuf!);
785+ pass.setVertexBuffer(1, this.nodeBuf!);
786+ pass.setIndexBuffer(this.indexBuf!, "uint32");
787+ pass.drawIndexed(this.indexCount);
788+ }
789+ if (this.markerMesh.count > 0 && this.markerBuf) {
790+ pass.setPipeline(this.markerPipeline);
791+ pass.setVertexBuffer(0, this.markerBuf);
792+ pass.draw(this.markerMesh.count);
793+ }
794+ }
795+ pass.end();
796+ device.queue.submit([encoder.finish()]);
797+ }
798+
799+ // ---------- 2D canvas fallback ----------
800+
801+ /** The cell colors as an image of (nx - 1) by (ny - 1) pixels, row 0 at the top (+y up). */
802+ private rebuildImage(): void {
803+ const pl = this.plate!;
804+ const g = pl.grid;
805+ const cw = g.nx - 1, ch = g.ny - 1;
806+ if (!this.image) {
807+ this.image = document.createElement("canvas");
808+ this.image.width = cw; this.image.height = ch;
809+ }
810+ const ictx = this.image.getContext("2d")!;
811+ const img = ictx.createImageData(cw, ch);
812+ const range = this.colorRange();
813+ const val = this.val;
814+ for (let cj = 0; cj < ch; cj++) {
815+ for (let ci = 0; ci < cw; ci++) {
816+ if (!insideCell(g, ci, cj)) continue;
817+ const a = cj * g.nx + ci, b = a + 1, c = a + g.nx, d = c + 1;
818+ const v = 0.25 * (val[a] + val[b] + val[c] + val[d]);
819+ const h = 0.25 * (pl.h[a] + pl.h[b] + pl.h[c] + pl.h[d]);
820+ const rgb = this.colorOf(v, h, range);
821+ const o = 4 * ((ch - 1 - cj) * cw + ci);
822+ img.data[o] = Math.round(rgb[0] * 255); img.data[o + 1] = Math.round(rgb[1] * 255);
823+ img.data[o + 2] = Math.round(rgb[2] * 255); img.data[o + 3] = 255;
824+ }
825+ }
826+ ictx.putImageData(img, 0, 0);
827+ this.imageDirty = false;
828+ }
829+
830+ private renderFallback(): void {
831+ const ctx = this.ctx2d!;
832+ const W = this.canvas.width, H = this.canvas.height;
833+ ctx.setTransform(1, 0, 0, 1, 0, 0);
834+ ctx.fillStyle = css(BG);
835+ ctx.fillRect(0, 0, W, H);
836+ const pl = this.plate;
837+ if (!pl) return;
838+ if (this.fieldDirty) this.rebuildField();
839+ if (this.imageDirty) this.rebuildImage();
840+ const { W: hw } = this.extent2d(W / H);
841+ const s = W / (2 * hw); // pixels per meter
842+ const px = (x: number) => W / 2 + x * s, py = (y: number) => H / 2 - y * s;
843+ ctx.imageSmoothingEnabled = false;
844+ ctx.drawImage(this.image!, px(-pl.Lx / 2), py(pl.Ly / 2), pl.Lx * s, pl.Ly * s);
845+
846+ const R = this.boxRadius;
847+ const lw = 0.012 * R * s;
848+ const ring = (x: number, y: number, r: number, color: RGB) => {
849+ ctx.beginPath();
850+ ctx.arc(px(x), py(y), r * s, 0, 2 * Math.PI);
851+ ctx.strokeStyle = css(color); ctx.lineWidth = lw;
852+ ctx.stroke();
853+ };
854+ const { strike, stand } = this.markers;
855+ if (stand) {
856+ if (stand.innerRadius > 0) ring(stand.x, stand.y, stand.innerRadius, COLOR_STAND);
857+ ring(stand.x, stand.y, stand.outerRadius, COLOR_STAND);
858+ }
859+ if (strike) ring(strike.x, strike.y, Math.max(strike.radius, 0.024 * R), COLOR_STRIKE);
860+ const l = this.listener2d();
861+ if (l) {
862+ const rm = 0.045 * R * s;
863+ ctx.beginPath(); ctx.moveTo(px(0), py(0)); ctx.lineTo(px(l.x), py(l.y));
864+ ctx.strokeStyle = css(COLOR_LINE); ctx.lineWidth = 0.6 * lw; ctx.stroke();
865+ const [dx, dy] = l.dir;
866+ ctx.beginPath(); ctx.moveTo(px(l.x) + dx * 0.8 * rm, py(l.y) - dy * 0.8 * rm); ctx.lineTo(px(l.x) + dx * 2.2 * rm, py(l.y) - dy * 2.2 * rm);
867+ ctx.strokeStyle = css(COLOR_MIC_HEAD); ctx.lineWidth = 0.56 * rm; ctx.stroke();
868+ ctx.beginPath(); ctx.arc(px(l.x), py(l.y), rm, 0, 2 * Math.PI);
869+ ctx.fillStyle = css(COLOR_MIC_BODY); ctx.fill();
870+ }
871+ }
872+}
src/render/shaders.wgsladded+76−0View file
@@ -0,0 +1,76 @@
1+// Two vertex paths share one fragment shader: the plate mesh (static x, y
2+// per node plus a per-frame record of height, slope and color) and the
3+// marker geometry (explicit position, normal and color). The plate frame is
4+// used directly as the world frame, with z up, so a cymbal lies flat. The 2D
5+// map is the same mesh under an orthographic camera with the heights zeroed
6+// (zScale = 0) and the lighting off (lit = 0).
7+
8+struct Uniforms {
9+ mvp: mat4x4f,
10+ eye: vec3f,
11+ zScale: f32,
12+ light: vec3f,
13+ lit: f32,
14+};
15+
16+@group(0) @binding(0) var<uniform> U: Uniforms;
17+
18+struct VOut {
19+ @builtin(position) pos: vec4f,
20+ @location(0) world: vec3f,
21+ @location(1) normal: vec3f,
22+ @location(2) color: vec3f,
23+};
24+
25+struct MeshIn {
26+ @location(0) xy: vec2f,
27+ @location(1) z: f32,
28+ @location(2) slope: vec2f,
29+ @location(3) color: vec4f,
30+};
31+
32+@vertex
33+fn vsMesh(in: MeshIn) -> VOut {
34+ let p = vec3f(in.xy, in.z * U.zScale);
35+ var o: VOut;
36+ o.pos = U.mvp * vec4f(p, 1.0);
37+ o.world = p;
38+ o.normal = vec3f(-in.slope * U.zScale, 1.0);
39+ o.color = in.color.rgb;
40+ return o;
41+}
42+
43+struct MarkerIn {
44+ @location(0) pos: vec3f,
45+ @location(1) normal: vec3f,
46+ @location(2) color: vec4f,
47+};
48+
49+@vertex
50+fn vsMarker(in: MarkerIn) -> VOut {
51+ var o: VOut;
52+ o.pos = U.mvp * vec4f(in.pos, 1.0);
53+ o.world = in.pos;
54+ o.normal = in.normal;
55+ o.color = in.color.rgb;
56+ return o;
57+}
58+
59+// Lambert with a little Blinn-Phong specular; two-sided, so the underside of
60+// the plate is lit when the camera goes below it.
61+fn shade(color: vec3f, normal: vec3f, world: vec3f) -> vec3f {
62+ if (U.lit < 0.5) { return color; }
63+ let v = normalize(U.eye - world);
64+ var n = normalize(normal);
65+ if (dot(n, v) < 0.0) { n = -n; }
66+ let l = normalize(U.light);
67+ let diff = max(dot(n, l), 0.0);
68+ let h = normalize(l + v);
69+ let spec = pow(max(dot(n, h), 0.0), 40.0);
70+ return color * (0.28 + 0.72 * diff) + vec3f(0.14) * spec;
71+}
72+
73+@fragment
74+fn fsMain(in: VOut) -> @location(0) vec4f {
75+ return vec4f(shade(in.color, in.normal, in.world), 1.0);
76+}
src/sim/selftest.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+// In-browser check of the WGSL step kernel against the float64 reference:
2+// one step from a smooth random state of a few thicknesses amplitude, with
3+// the losses and the stand (lift-off included), compared as accelerations.
4+
5+import { type Plate, type SimParams, meanThickness, nodeX, nodeY, insideNode, standRates } from "../params.ts";
6+import { accelerations, addDamping, fields, fullLoss } from "../model/reference.ts";
7+import { fitSigma, decayRate } from "../loss.ts";
8+import { Simulation } from "./simulation.ts";
9+
10+export interface SelfTestResult {
11+ n: number;
12+ /** Largest acceleration error over all nodes and components relative to the largest reference acceleration. */
13+ maxRel: number;
14+ dt: number;
15+}
16+
17+/** A smooth random field of the given amplitude on the plate nodes (zero outside). */
18+export function smoothField(pl: Plate, amplitude: number, seed: number): Float64Array {
19+ const g = pl.grid;
20+ const f = new Float64Array(g.n);
21+ let s = seed >>> 0;
22+ const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
23+ const terms = Array.from({ length: 6 }, () => ({ kx: (rnd() - 0.5) * 60, ky: (rnd() - 0.5) * 60, ph: rnd() * 2 * Math.PI, a: rnd() }));
24+ for (let j = 0; j < g.ny; j++) {
25+ for (let i = 0; i < g.nx; i++) {
26+ if (!insideNode(g, i, j)) continue;
27+ const x = nodeX(g, i), y = nodeY(g, j);
28+ let v = 0;
29+ for (const t of terms) v += t.a * Math.sin(t.kx * x + t.ky * y + t.ph);
30+ f[j * g.nx + i] = v * amplitude / 3;
31+ }
32+ }
33+ return f;
34+}
35+
36+export async function runSelfTest(device: GPUDevice, pl: Plate, params: SimParams): Promise<SelfTestResult> {
37+ const g = pl.grid, n = g.n;
38+ const hm = meanThickness(pl);
39+ const fit = fitSigma(pl.loss, pl.mat, hm);
40+ const sr = standRates(pl);
41+ const loss = fullLoss(fit, sr, decayRate(pl.loss, 5000));
42+ const sim = new Simulation(device, pl, params, loss);
43+ const amp = 2.5 * hm;
44+ const f = { u: smoothField(pl, 0.02 * amp, 1), v: smoothField(pl, 0.02 * amp, 2), w: smoothField(pl, amp, 3) };
45+ const vel = { u: smoothField(pl, 0.05, 4), v: smoothField(pl, 0.05, 5), w: smoothField(pl, 1.0, 6) };
46+ const pos32 = new Float32Array(4 * n), vel32 = new Float32Array(3 * n);
47+ for (let k = 0; k < n; k++) {
48+ pos32[k] = f.u[k]; pos32[n + k] = f.v[k]; pos32[2 * n + k] = f.w[k];
49+ vel32[k] = vel.u[k]; vel32[n + k] = vel.v[k]; vel32[2 * n + k] = vel.w[k];
50+ }
51+ // Use the float32-rounded state as the reference input.
52+ for (let k = 0; k < n; k++) {
53+ f.u[k] = pos32[k]; f.v[k] = pos32[n + k]; f.w[k] = pos32[2 * n + k];
54+ vel.u[k] = vel32[k]; vel.v[k] = vel32[n + k]; vel.w[k] = vel32[2 * n + k];
55+ }
56+ sim.writeState(pos32, vel32);
57+ await sim.stepOnce();
58+ const st = await sim.readState();
59+ const ref = fields(n);
60+ accelerations(pl, f, ref, params.padLiftoff, sr);
61+ addDamping(pl, loss, vel, ref, sr);
62+ let maxRef = 0, maxErr = 0;
63+ const dt = sim.dt;
64+ for (let k = 0; k < n; k++) {
65+ const au = (st.vel[k] - vel.u[k]) / dt, av = (st.vel[n + k] - vel.v[k]) / dt, aw = (st.vel[2 * n + k] - vel.w[k]) / dt;
66+ maxRef = Math.max(maxRef, Math.abs(ref.u[k]), Math.abs(ref.v[k]), Math.abs(ref.w[k]));
67+ maxErr = Math.max(maxErr, Math.abs(au - ref.u[k]), Math.abs(av - ref.v[k]), Math.abs(aw - ref.w[k]));
68+ }
69+ sim.destroy();
70+ return { n, maxRel: maxErr / maxRef, dt };
71+}
src/sim/shaders/audio.wgsladded+46−0View file
@@ -0,0 +1,46 @@
1+// Retarded-time sum over the plate (Rayleigh integral), one workgroup per
2+// output sample of the batch, dispatched once at the end of the batch.
3+// lw[k] = (weight, delay in samples) for node k; the delay is relative to
4+// the closest node so that the ring buffer only has to cover the spread.
5+// The ring buffer holds the transverse velocity at the sample instants; the
6+// acceleration is the first difference of the delayed velocity, which is the
7+// average acceleration over one sample interval (band-limited, and immune to
8+// the per-step rounding noise of the force evaluation). The bound StepParams
9+// are those of the batch's first audio step; workgroup q handles the sample
10+// q slots later.
11+
12+@group(0) @binding(0) var<uniform> P: Params;
13+@group(0) @binding(1) var<uniform> SP: StepParams;
14+@group(0) @binding(2) var<storage, read> hist: array<f32>;
15+@group(0) @binding(3) var<storage, read> lw: array<vec2f>;
16+@group(0) @binding(4) var<storage, read_write> audioOut: array<f32>;
17+
18+var<workgroup> sh: array<f32, 256>;
19+
20+@compute @workgroup_size(256)
21+fn main(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
22+ let t = lid.x;
23+ let H = i32(P.histLen);
24+ let slot = (SP.audioSlot + i32(wid.x)) % H;
25+ var acc = 0.0;
26+ for (var k = t; k < P.n; k += 256u) {
27+ let d = lw[k].y;
28+ let k0 = i32(floor(d));
29+ let f = d - f32(k0);
30+ let s0 = ((slot - k0) % H + H) % H;
31+ let s1 = ((slot - k0 - 1) % H + H) % H;
32+ let s2 = ((slot - k0 - 2) % H + H) % H;
33+ let v0 = hist[u32(s0) * P.n + k];
34+ let v1 = hist[u32(s1) * P.n + k];
35+ let v2 = hist[u32(s2) * P.n + k];
36+ let a = (mix(v0, v1, f) - mix(v1, v2, f)) * P.fs;
37+ acc += lw[k].x * a;
38+ }
39+ sh[t] = acc;
40+ workgroupBarrier();
41+ for (var stride = 128u; stride > 0u; stride >>= 1u) {
42+ if (t < stride) { sh[t] += sh[t + stride]; }
43+ workgroupBarrier();
44+ }
45+ if (t == 0u) { audioOut[u32(SP.audioOut) + wid.x] = sh[0]; }
46+}
src/sim/shaders/common.wgsladded+80−0View file
@@ -0,0 +1,80 @@
1+// Shared declarations, prepended to every kernel of the full simulation.
2+
3+struct Params {
4+ nx: u32,
5+ ny: u32,
6+ n: u32,
7+ histLen: u32,
8+ hx: f32,
9+ hy: f32,
10+ dt: f32,
11+ nu: f32,
12+ Db: f32, // E / (12 (1 - nu^2)): bending stiffness per h^3 [Pa]
13+ Cm: f32, // E / (1 - nu^2): membrane stiffness per h [Pa]
14+ Gm: f32, // E / (2 (1 + nu)): membrane shear stiffness per h [Pa]
15+ rho: f32, // density [kg/m^3]
16+ mass: f32, // total mass [kg]
17+ Ix: f32, // sum m y^2
18+ Iy: f32, // sum m x^2
19+ fs: f32, // audio sample rate [Hz]
20+ strikeX: f32, // drumstick contact point [m]
21+ strikeY: f32,
22+ strikeR2: f32, // squared contact radius [m^2]
23+ patchMass: f32, // mass of the contact patch [kg]
24+ strikeNode: u32, // plate node nearest the contact point
25+ K: f32, // Hertz contact stiffness [N/m^alpha]
26+ alpha: f32, // Hertz exponent
27+ stickMass: f32, // [kg]
28+ patchI0: u32, // index box enclosing the contact patch (inclusive)
29+ patchI1: u32,
30+ patchJ0: u32,
31+ patchJ1: u32,
32+ patchCX: f32, // mass-weighted center of the contact patch [m]
33+ patchCY: f32,
34+ standOn: u32, // 1 when the plate sits on a stand, 0 when it is free
35+ liftoff: u32, // 1 when the pads push only while compressed
36+ preloadTop: f32, // compression of the top pad at rest [m] (0 without a top pad)
37+ preloadBottom: f32, // compression of the bottom pad at rest [m]
38+ padDamping: f32, // dashpot rate of the pads per unit of pad stiffness [s]
39+ hasTop: u32, // 1 when there is a top pad
40+ pad0: u32,
41+ pad1: u32,
42+ pad2: u32,
43+ pad3: u32,
44+};
45+
46+// Per-step parameters, addressed with a dynamic uniform offset.
47+struct StepParams {
48+ step: u32,
49+ flags: u32, // bit 0: drumstick active
50+ audioSlot: i32, // ring-buffer slot to record the velocity into (-1: none)
51+ audioOut: i32, // index of this sample within the batch's output
52+ sigma0: f32,
53+ sigma1: f32,
54+ sigmaIn: f32, // additional frequency-independent loss of the in-plane motion
55+ parity: u32, // which of the two stick-state slots holds the current state
56+ pad0: u32,
57+ pad1: u32,
58+ pad2: u32,
59+ pad3: u32,
60+};
61+
62+fn nodeX(i: i32) -> f32 { return (f32(i) - 0.5 * f32(P.nx - 1u)) * P.hx; }
63+fn nodeY(j: i32) -> f32 { return (f32(j) - 0.5 * f32(P.ny - 1u)) * P.hy; }
64+
65+// Static per-node data, written once by the host: x = stress-free shape w0 [m],
66+// y = quadrature and lumped-mass weight (the fraction of the node's four cells
67+// that belong to the plate; zero outside), z = thickness [m], w = bottom-pad
68+// stiffness at the node [N/m] (zero away from the pads; the top pad, when
69+// present, has the same rate).
70+@group(0) @binding(8) var<storage, read> nd: array<vec4f>;
71+fn nodeWeight(i: i32, j: i32) -> f32 {
72+ if (i < 0 || i >= i32(P.nx) || j < 0 || j >= i32(P.ny)) { return 0.0; }
73+ return nd[u32(j) * P.nx + u32(i)].y;
74+}
75+// Lumped mass of a node [kg].
76+fn nodeMass(i: i32, j: i32) -> f32 {
77+ if (i < 0 || i >= i32(P.nx) || j < 0 || j >= i32(P.ny)) { return 0.0; }
78+ let k = u32(j) * P.nx + u32(i);
79+ return P.rho * nd[k].z * P.hx * P.hy * nd[k].y;
80+}
src/sim/shaders/extract.wgsladded+12−0View file
@@ -0,0 +1,12 @@
1+// Copies the transverse displacement into a plain f32 array (display / movie frame).
2+
3+@group(0) @binding(0) var<uniform> P: Params;
4+@group(0) @binding(1) var<storage, read> pos: array<f32>;
5+@group(0) @binding(2) var<storage, read_write> out: array<f32>;
6+
7+@compute @workgroup_size(256)
8+fn main(@builtin(global_invocation_id) gid: vec3u) {
9+ let k = gid.x;
10+ if (k >= P.n) { return; }
11+ out[k] = pos[2u * P.n + k];
12+}
src/sim/shaders/step.wgsladded+328−0View file
@@ -0,0 +1,328 @@
1+// One explicit time step (symplectic Euler) of the von Karman plate with a
2+// thickness field, a stress-free profile, the drumstick, and the pads of the
3+// stand.
4+//
5+// State layout (structure of arrays, n = nx * ny nodes):
6+// pos: [u | v | w | wLo] wLo carries the rounding error of the w update
7+// vel: [du | dv | dw]
8+// A workgroup of 16x16 threads advances a 12x12 tile of nodes in three
9+// phases separated by barriers. (1) Every thread loads one entry of the tile
10+// plus its 2-node halo (w, u, v, w0, thickness, node weights) into shared
11+// memory. (2) The threads compute, once each and again into shared memory,
12+// the membrane forces of the 13x13 cells around the tile and the weighted
13+// bending moments and mixed differences that the bending stencil gathers.
14+// (3) Each of the 144 tile nodes evaluates its update from shared memory.
15+// On grids of a few thousand nodes the kernel is bound by the latency of one
16+// workgroup rather than by throughput, so the shared phases, which replace a
17+// tenfold per-node recomputation of the stencil ingredients, are what make a
18+// step cheap.
19+//
20+// Nodes outside the plate (weight 0) are skipped: a second difference exists
21+// only where the node and both neighbors belong to the plate, and a cell
22+// contributes only when its four corners do. That gives the free edge of the
23+// rectangle and a staircase free edge for any other outline, hole included.
24+
25+@group(0) @binding(0) var<uniform> P: Params;
26+@group(0) @binding(1) var<uniform> SP: StepParams;
27+@group(0) @binding(2) var<storage, read> posIn: array<f32>;
28+@group(0) @binding(3) var<storage, read> velIn: array<f32>;
29+@group(0) @binding(4) var<storage, read_write> posOut: array<f32>;
30+@group(0) @binding(5) var<storage, read_write> velOut: array<f32>;
31+@group(0) @binding(6) var<storage, read_write> hist: array<f32>; // [slot * n + node]: transverse velocity at the audio sample instants
32+// Two 8-float slots of drumstick state: position, velocity, force, max force,
33+// time since the contact released, withdrawn flag, and two spare.
34+const STICK_RELEASE: f32 = 5e-3; // [s] a release longer than this ends the hit
35+@group(0) @binding(7) var<storage, read_write> stick: array<f32>;
36+
37+const TILE = 12; // nodes per workgroup along each axis (must match simulation.ts)
38+const HALO = 2;
39+const TW = 16; // TILE + 2 * HALO: the loaded tile, one thread per entry
40+const CT = 13; // TILE + 1: cells adjacent to the tile's nodes
41+const MT = 14; // TILE + 2: nodes at which the bending moments are needed
42+var<workgroup> tw: array<f32, 256>; // w
43+var<workgroup> tu: array<f32, 256>; // u
44+var<workgroup> tv: array<f32, 256>; // v
45+var<workgroup> tw0: array<f32, 256>; // stress-free shape
46+var<workgroup> th: array<f32, 256>; // thickness
47+var<workgroup> tn: array<u32, 64>; // node weights in quarters (0..4), four to a word
48+var<workgroup> cellF: array<f32, 2028>; // membrane forces of the CT x CT cells, 4 nodes x 3 components
49+var<workgroup> ma: array<f32, 196>; // weighted bending moments at the MT x MT inner nodes
50+var<workgroup> mb: array<f32, 196>;
51+var<workgroup> cc: array<f32, 169>; // weighted mixed differences at the CT x CT cells
52+
53+var<private> ox: i32;
54+var<private> oy: i32;
55+var<private> ihx2: f32;
56+var<private> ihy2: f32;
57+var<private> ihxy: f32;
58+
59+fn id(i: i32, j: i32) -> u32 { return u32(j) * P.nx + u32(i); }
60+fn lidx(i: i32, j: i32) -> u32 { return u32(j - oy) * u32(TW) + u32(i - ox); }
61+fn midx(i: i32, j: i32) -> u32 { return u32(j - oy - 1) * u32(MT) + u32(i - ox - 1); }
62+fn cidx(ci: i32, cj: i32) -> u32 { return u32(cj - oy - 1) * u32(CT) + u32(ci - ox - 1); }
63+fn W(i: i32, j: i32) -> f32 { return tw[lidx(i, j)]; }
64+fn U(i: i32, j: i32) -> f32 { return tu[lidx(i, j)]; }
65+fn V(i: i32, j: i32) -> f32 { return tv[lidx(i, j)]; }
66+fn W0(i: i32, j: i32) -> f32 { return tw0[lidx(i, j)]; }
67+fn DW(i: i32, j: i32) -> f32 { return velIn[2u * P.n + id(i, j)]; }
68+fn TH(i: i32, j: i32) -> f32 { return th[lidx(i, j)]; }
69+fn NWq(i: i32, j: i32) -> u32 { let l = lidx(i, j); return (tn[l >> 2u] >> ((l & 3u) * 8u)) & 0xFFu; }
70+fn NW(i: i32, j: i32) -> f32 { return 0.25 * f32(NWq(i, j)); }
71+fn INS(i: i32, j: i32) -> bool { return NWq(i, j) != 0u; }
72+fn insCell(ci: i32, cj: i32) -> bool { return INS(ci, cj) && INS(ci + 1, cj) && INS(ci + 1, cj + 1) && INS(ci, cj + 1); }
73+fn Dn(i: i32, j: i32) -> f32 { let h = TH(i, j); return P.Db * h * h * h; }
74+fn cellH(ci: i32, cj: i32) -> f32 { return 0.25 * (TH(ci, cj) + TH(ci + 1, cj) + TH(ci + 1, cj + 1) + TH(ci, cj + 1)); }
75+
76+// Graph Laplacian of the transverse velocity at node (i, j), over neighbors in the plate.
77+fn lapDW(i: i32, j: i32) -> f32 {
78+ let c = DW(i, j);
79+ var lap = 0.0;
80+ if (INS(i - 1, j)) { lap += (DW(i - 1, j) - c) * ihx2; }
81+ if (INS(i + 1, j)) { lap += (DW(i + 1, j) - c) * ihx2; }
82+ if (INS(i, j - 1)) { lap += (DW(i, j - 1) - c) * ihy2; }
83+ if (INS(i, j + 1)) { lap += (DW(i, j + 1) - c) * ihy2; }
84+ return lap;
85+}
86+
87+// Weighted bending moments at nodes; zero where the second difference is undefined.
88+fn Ma(i: i32, j: i32) -> f32 {
89+ if (!(INS(i - 1, j) && INS(i, j) && INS(i + 1, j))) { return 0.0; }
90+ let a = (W(i + 1, j) - 2.0 * W(i, j) + W(i - 1, j)) * ihx2;
91+ var b = 0.0;
92+ if (INS(i, j - 1) && INS(i, j + 1)) { b = (W(i, j + 1) - 2.0 * W(i, j) + W(i, j - 1)) * ihy2; }
93+ return NW(i, j) * Dn(i, j) * (a + P.nu * b);
94+}
95+fn Mb(i: i32, j: i32) -> f32 {
96+ if (!(INS(i, j - 1) && INS(i, j) && INS(i, j + 1))) { return 0.0; }
97+ let b = (W(i, j + 1) - 2.0 * W(i, j) + W(i, j - 1)) * ihy2;
98+ var a = 0.0;
99+ if (INS(i - 1, j) && INS(i + 1, j)) { a = (W(i + 1, j) - 2.0 * W(i, j) + W(i - 1, j)) * ihx2; }
100+ return NW(i, j) * Dn(i, j) * (b + P.nu * a);
101+}
102+fn Cc(ci: i32, cj: i32) -> f32 {
103+ if (!insCell(ci, cj)) { return 0.0; }
104+ let h = cellH(ci, cj);
105+ return P.Db * h * h * h * (W(ci + 1, cj + 1) - W(ci + 1, cj) - W(ci, cj + 1) + W(ci, cj)) * ihxy;
106+}
107+
108+// Membrane forces of the bilinear cell (ci, cj) on its four nodes (2x2 Gauss), written to cellF at local cell index lc.
109+fn cellForces(ci: i32, cj: i32, lc: u32) {
110+ let base = lc * 12u;
111+ if (!insCell(ci, cj)) {
112+ for (var q = 0u; q < 12u; q++) { cellF[base + q] = 0.0; }
113+ return;
114+ }
115+ let hc = cellH(ci, cj);
116+ let C = P.Cm * hc;
117+ let G = P.Gm * hc;
118+ let Un = vec4f(U(ci, cj), U(ci + 1, cj), U(ci + 1, cj + 1), U(ci, cj + 1));
119+ let Vn = vec4f(V(ci, cj), V(ci + 1, cj), V(ci + 1, cj + 1), V(ci, cj + 1));
120+ let W0n = vec4f(W0(ci, cj), W0(ci + 1, cj), W0(ci + 1, cj + 1), W0(ci, cj + 1));
121+ let Wn = vec4f(W(ci, cj), W(ci + 1, cj), W(ci + 1, cj + 1), W(ci, cj + 1)) + W0n; // total shape
122+ let q = 0.25 * P.hx * P.hy;
123+ let ihx = 1.0 / P.hx; let ihy = 1.0 / P.hy;
124+ let g3 = 0.57735026918962576;
125+ var Fu = vec4f(0.0); var Fv = vec4f(0.0); var Fw = vec4f(0.0);
126+ for (var gp = 0; gp < 4; gp++) {
127+ var xi = -g3; if ((gp & 1) != 0) { xi = g3; }
128+ var eta = -g3; if ((gp & 2) != 0) { eta = g3; }
129+ let dNdx = vec4f(-(1.0 - eta), 1.0 - eta, 1.0 + eta, -(1.0 + eta)) * (0.5 * ihx);
130+ let dNdy = vec4f(-(1.0 - xi), -(1.0 + xi), 1.0 + xi, 1.0 - xi) * (0.5 * ihy);
131+ let ux = dot(dNdx, Un); let uy = dot(dNdy, Un);
132+ let vx = dot(dNdx, Vn); let vy = dot(dNdy, Vn);
133+ let wx = dot(dNdx, Wn); let wy = dot(dNdy, Wn);
134+ let w0x = dot(dNdx, W0n); let w0y = dot(dNdy, W0n);
135+ // Marguerre strains: total slope minus the stress-free slope.
136+ let exx = ux + 0.5 * (wx * wx - w0x * w0x);
137+ let eyy = vy + 0.5 * (wy * wy - w0y * w0y);
138+ let gxy = uy + vx + wx * wy - w0x * w0y;
139+ let Nxx = C * (exx + P.nu * eyy);
140+ let Nyy = C * (eyy + P.nu * exx);
141+ let Nxy = G * gxy;
142+ Fu -= q * (Nxx * dNdx + Nxy * dNdy);
143+ Fv -= q * (Nyy * dNdy + Nxy * dNdx);
144+ Fw -= q * ((Nxx * wx + Nxy * wy) * dNdx + (Nxy * wx + Nyy * wy) * dNdy);
145+ }
146+ for (var a = 0u; a < 4u; a++) {
147+ cellF[base + a * 3u] = Fu[a];
148+ cellF[base + a * 3u + 1u] = Fv[a];
149+ cellF[base + a * 3u + 2u] = Fw[a];
150+ }
151+}
152+
153+// True for the plate nodes of the drumstick contact patch (same rule as patchAround in params.ts).
154+fn inContactPatch(i: i32, j: i32) -> bool {
155+ if (nodeWeight(i, j) == 0.0) { return false; }
156+ let dxs = nodeX(i) - P.strikeX; let dys = nodeY(j) - P.strikeY;
157+ return dxs * dxs + dys * dys <= P.strikeR2 || id(i, j) == P.strikeNode;
158+}
159+
160+fn cellForce(ci: i32, cj: i32, a: u32) -> vec3f {
161+ let base = cidx(ci, cj) * 12u + a * 3u;
162+ return vec3f(cellF[base], cellF[base + 1u], cellF[base + 2u]);
163+}
164+
165+// Force of the pads on a node with bottom-pad rate kb and transverse
166+// displacement w (same rule as padForce in reference.ts). The constant part
167+// that balances the weight of the plate is subtracted, so w = 0 is rest.
168+fn padForce(kb: f32, w: f32) -> f32 {
169+ if (P.liftoff == 0u) {
170+ if (P.hasTop != 0u) { return -2.0 * kb * w; }
171+ return -kb * w;
172+ }
173+ var f = kb * max(P.preloadBottom - w, 0.0) - kb * P.preloadBottom;
174+ if (P.hasTop != 0u) { f -= kb * max(P.preloadTop + w, 0.0) - kb * P.preloadTop; }
175+ return f;
176+}
177+
178+@compute @workgroup_size(16, 16)
179+fn main(@builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) {
180+ let n = P.n;
181+ ox = i32(wid.x) * TILE - HALO;
182+ oy = i32(wid.y) * TILE - HALO;
183+ ihx2 = 1.0 / (P.hx * P.hx); ihy2 = 1.0 / (P.hy * P.hy); ihxy = 1.0 / (P.hx * P.hy);
184+ let lt = lid.y * 16u + lid.x;
185+
186+ // Phase 1: one entry of the tile with halo per thread.
187+ {
188+ let gi = ox + i32(lid.x); let gj = oy + i32(lid.y);
189+ var ww = 0.0; var uu = 0.0; var vv = 0.0; var z0 = 0.0; var hh = 0.0;
190+ if (gi >= 0 && gi < i32(P.nx) && gj >= 0 && gj < i32(P.ny)) {
191+ let k = id(gi, gj);
192+ let st = nd[k];
193+ ww = posIn[2u * n + k]; uu = posIn[k]; vv = posIn[n + k]; z0 = st.x; hh = st.z;
194+ }
195+ tw[lt] = ww; tu[lt] = uu; tv[lt] = vv; tw0[lt] = z0; th[lt] = hh;
196+ if (lt < 64u) {
197+ var word = 0u;
198+ for (var q = 0u; q < 4u; q++) {
199+ let e = lt * 4u + q;
200+ let ei = ox + i32(e % u32(TW)); let ej = oy + i32(e / u32(TW));
201+ var wq = 0u;
202+ if (ei >= 0 && ei < i32(P.nx) && ej >= 0 && ej < i32(P.ny)) { wq = u32(round(4.0 * nd[id(ei, ej)].y)); }
203+ word |= wq << (q * 8u);
204+ }
205+ tn[lt] = word;
206+ }
207+ }
208+ workgroupBarrier();
209+
210+ // Phase 2: membrane forces of the cells, bending moments and mixed differences.
211+ if (lt < 169u) {
212+ let ci = ox + 1 + i32(lt % u32(CT)); let cj = oy + 1 + i32(lt / u32(CT));
213+ cellForces(ci, cj, lt);
214+ cc[lt] = Cc(ci, cj);
215+ }
216+ if (lt < 196u) {
217+ let mi = ox + 1 + i32(lt % u32(MT)); let mj = oy + 1 + i32(lt / u32(MT));
218+ ma[lt] = Ma(mi, mj);
219+ mb[lt] = Mb(mi, mj);
220+ }
221+ workgroupBarrier();
222+
223+ // Phase 3: the update of the TILE x TILE nodes (no barriers past this point).
224+ if (lt >= 144u) { return; }
225+ let i = ox + HALO + i32(lt % u32(TILE)); let j = oy + HALO + i32(lt / u32(TILE));
226+ if (i >= i32(P.nx) || j >= i32(P.ny)) { return; }
227+ let k = id(i, j);
228+ let weight = NW(i, j);
229+ if (weight == 0.0) {
230+ posOut[k] = 0.0; posOut[n + k] = 0.0; posOut[2u * n + k] = 0.0; posOut[3u * n + k] = 0.0;
231+ velOut[k] = 0.0; velOut[n + k] = 0.0; velOut[2u * n + k] = 0.0;
232+ return;
233+ }
234+
235+ // Elastic forces: bending from the discrete Kirchhoff energy, membrane from the cells.
236+ let bend = (ma[midx(i - 1, j)] - 2.0 * ma[midx(i, j)] + ma[midx(i + 1, j)]) * ihx2
237+ + (mb[midx(i, j - 1)] - 2.0 * mb[midx(i, j)] + mb[midx(i, j + 1)]) * ihy2
238+ + 2.0 * (1.0 - P.nu) * (cc[cidx(i, j)] - cc[cidx(i - 1, j)] - cc[cidx(i, j - 1)] + cc[cidx(i - 1, j - 1)]) * ihxy;
239+ var F = vec3f(0.0, 0.0, -P.hx * P.hy * bend);
240+ F += cellForce(i, j, 0u);
241+ F += cellForce(i - 1, j, 1u);
242+ F += cellForce(i - 1, j - 1, 2u);
243+ F += cellForce(i, j - 1, 3u);
244+ let pOld = vec3f(posIn[k], posIn[n + k], posIn[2u * n + k]);
245+ let vel = vec3f(velIn[k], velIn[n + k], velIn[2u * n + k]);
246+ // The pads: a spring to ground on the transverse motion (compression-only
247+ // with lift-off), the felt's shear on the in-plane motion, and a dashpot.
248+ let kb = nd[k].w;
249+ if (P.standOn != 0u && kb > 0.0) {
250+ var nPads = 1.0; if (P.hasTop != 0u) { nPads = 2.0; }
251+ let ku = nPads * kb * 0.3846153846; // shear: G / E = 1 / (2 (1 + 0.3))
252+ F.x -= ku * pOld.x + P.padDamping * ku * vel.x;
253+ F.y -= ku * pOld.y + P.padDamping * ku * vel.y;
254+ F.z += padForce(kb, pOld.z) - P.padDamping * nPads * kb * vel.z;
255+ }
256+ let mass = P.rho * TH(i, j) * P.hx * P.hy * weight;
257+ var acc = F * (1.0 / mass);
258+
259+ // Losses: frequency-independent on all components, the graph Laplacian of the
260+ // transverse velocity (~k^2), and an extra frequency-independent in-plane loss.
261+ acc -= 2.0 * SP.sigma0 * vel;
262+ acc.z += 2.0 * SP.sigma1 * lapDW(i, j);
263+ acc.x -= 2.0 * SP.sigmaIn * vel.x;
264+ acc.y -= 2.0 * SP.sigmaIn * vel.y;
265+
266+ // Drumstick: Hertz contact between a free mass and the contact patch (the
267+ // plate nodes within the tip radius, plus the nearest one). The contact
268+ // coordinate is the mass-weighted mean displacement of the patch, and the
269+ // force is spread over the patch in proportion to the node masses, so the
270+ // pair is conjugate and the contact has a potential energy. Once the
271+ // contact has been released for STICK_RELEASE the stick is withdrawn.
272+ if ((SP.flags & 1u) != 0u) {
273+ let sIn = 8u * SP.parity;
274+ if (P.standOn == 0u) {
275+ // Free plate: inertia relief. The force of the previous step is applied
276+ // to the whole plate as the rigid acceleration field that balances the
277+ // patch load and its moments about the center, so the plate gains no net
278+ // momentum (the thread takes it up in reality). A plate on a stand takes
279+ // the reaction through the pads.
280+ let Fprev = stick[sIn + 2u];
281+ acc.z += Fprev * (1.0 / P.mass + nodeY(j) * P.patchCY / P.Ix + nodeX(i) * P.patchCX / P.Iy);
282+ }
283+ if (inContactPatch(i, j)) {
284+ let sx = stick[sIn]; let sv = stick[sIn + 1u]; let sMax = stick[sIn + 3u];
285+ var released = stick[sIn + 4u];
286+ var withdrawn = stick[sIn + 5u];
287+ var Fc = 0.0;
288+ if (withdrawn == 0.0) {
289+ var wc = 0.0;
290+ for (var pj = i32(P.patchJ0); pj <= i32(P.patchJ1); pj++) {
291+ for (var pi = i32(P.patchI0); pi <= i32(P.patchI1); pi++) {
292+ if (inContactPatch(pi, pj)) { wc += nodeMass(pi, pj) * posIn[2u * n + id(pi, pj)]; }
293+ }
294+ }
295+ wc /= P.patchMass;
296+ let pen = wc - sx; // the stick sits on the +z side and moves toward -z
297+ if (pen > 0.0) { Fc = P.K * pow(pen, P.alpha); released = 0.0; }
298+ else if (sMax > 0.0) {
299+ released += P.dt;
300+ if (released > STICK_RELEASE) { withdrawn = 1.0; }
301+ }
302+ }
303+ acc.z -= Fc / P.patchMass;
304+ if (k == P.strikeNode) {
305+ let sOut = 8u * (1u - SP.parity);
306+ let svn = sv + P.dt * Fc / P.stickMass;
307+ stick[sOut] = sx + P.dt * svn;
308+ stick[sOut + 1u] = svn;
309+ stick[sOut + 2u] = Fc;
310+ stick[sOut + 3u] = max(sMax, Fc);
311+ stick[sOut + 4u] = released;
312+ stick[sOut + 5u] = withdrawn;
313+ }
314+ }
315+ }
316+
317+ let vNew = vel + P.dt * acc;
318+ if (SP.audioSlot >= 0) { hist[u32(SP.audioSlot) * n + k] = vNew.z; }
319+ var pNew = pOld + P.dt * vNew;
320+ // Compensated update of w: the increment dt * v can be far below the
321+ // float32 resolution of w, so the rounding error is carried separately.
322+ let y = P.dt * vNew.z + posIn[3u * n + k];
323+ let t = pOld.z + y;
324+ let lo = (pOld.z - t) + y;
325+ pNew.z = t;
326+ posOut[k] = pNew.x; posOut[n + k] = pNew.y; posOut[2u * n + k] = pNew.z; posOut[3u * n + k] = lo;
327+ velOut[k] = vNew.x; velOut[n + k] = vNew.y; velOut[2u * n + k] = vNew.z;
328+}
src/sim/simulation.tsadded+442−0View file
@@ -0,0 +1,442 @@
1+// WebGPU time-stepping driver of the full (geometrically nonlinear)
2+// simulation. The run advances in batches of one movie frame (1/fps s). A
3+// batch encodes one compute dispatch per time step (with a per-step uniform
4+// addressed by dynamic offset), one retarded-time listener sum over the
5+// batch's audio samples, and one extraction of the transverse displacement
6+// at the end. Audio, frame, and stick state are read back after every batch;
7+// one batch is kept encoded ahead of the one being read back so that the
8+// GPU never waits for JavaScript. The recording starts at the impact, with
9+// the plate at rest in its stress-free shape.
10+
11+import {
12+ type Plate,
13+ type SimParams,
14+ type StandRates,
15+ CFL,
16+ HIST_MARGIN,
17+ massProperties,
18+ standRates,
19+ strikePatch,
20+ strikePoint,
21+ thicknessRange,
22+ nodeWeights,
23+ listenerTerms,
24+} from "../params.ts";
25+import { type FullLoss, maxAngularFrequency } from "../model/reference.ts";
26+import commonWgsl from "./shaders/common.wgsl?raw";
27+import stepWgsl from "./shaders/step.wgsl?raw";
28+import audioWgsl from "./shaders/audio.wgsl?raw";
29+import extractWgsl from "./shaders/extract.wgsl?raw";
30+
31+export type Phase = "recording" | "done";
32+
33+export interface StickState {
34+ x: number;
35+ v: number;
36+ force: number;
37+ maxForce: number;
38+ withdrawn: boolean;
39+}
40+
41+export interface SimStatus {
42+ phase: Phase;
43+ /** Simulation time since the impact of the state read back so far [s]. */
44+ t: number;
45+ totalSteps: number;
46+ samples: number;
47+ frames: number;
48+ peakPressure: number;
49+ /** Largest transverse displacement seen in the frames [m]. */
50+ wmax: number;
51+ delayClamped: boolean;
52+ stick: StickState;
53+}
54+
55+const PARAMS_LEN = 40;
56+const STEP_LEN = 12;
57+const STICK_LEN = 16;
58+/** Nodes per workgroup along each axis in step.wgsl. */
59+export const TILE = 12;
60+
61+interface Pending {
62+ staging: GPUBuffer;
63+ steps: number;
64+ nAudio: number;
65+ stickParity: number;
66+}
67+
68+export class Simulation {
69+ readonly plate: Plate;
70+ readonly dt: number;
71+ readonly stepsPerSample: number;
72+ readonly samplesPerBatch: number;
73+ readonly stepsPerBatch: number;
74+ readonly omegaMax: number;
75+ readonly histLen: number;
76+ readonly mass: number;
77+ readonly stand: StandRates | null;
78+
79+ audio: Float32Array = new Float32Array(0);
80+ samples = 0;
81+ /** Movie frames of w [m]; frame k is at t = k / fps. */
82+ frames: Float32Array[] = [];
83+ phase: Phase = "recording";
84+ totalSteps = 0;
85+ peakPressure = 0;
86+ wmax = 0;
87+ delayClamped = false;
88+
89+ private stick: StickState;
90+ private inFlight: Pending | null = null;
91+ private device: GPUDevice;
92+ private params: SimParams;
93+ private loss: FullLoss;
94+ private paramsBuf: GPUBuffer;
95+ private stepBuf: GPUBuffer;
96+ private stepStride: number;
97+ private stepData: ArrayBuffer;
98+ private pos: GPUBuffer[];
99+ private vel: GPUBuffer[];
100+ private hist: GPUBuffer;
101+ private stickBuf: GPUBuffer;
102+ private nd: GPUBuffer;
103+ private lw: GPUBuffer;
104+ private audioOut: GPUBuffer;
105+ private frameBuf: GPUBuffer;
106+ private staging: GPUBuffer[];
107+ private stagingIdx = 0;
108+ private cur = 0;
109+ private stepPipe: GPUComputePipeline;
110+ private audioPipe: GPUComputePipeline;
111+ private extractPipe: GPUComputePipeline;
112+ private stepBG: GPUBindGroup[];
113+ private audioBG: GPUBindGroup;
114+ private extractBG: GPUBindGroup[];
115+
116+ constructor(device: GPUDevice, plate: Plate, params: SimParams, loss: FullLoss) {
117+ this.device = device;
118+ this.plate = plate;
119+ this.params = { ...params };
120+ this.loss = loss;
121+ const g = plate.grid, m = plate.mat;
122+
123+ // Stable time step, rounded so that an integer number of steps make one audio sample.
124+ this.omegaMax = maxAngularFrequency(g, m, thicknessRange(plate).max);
125+ const dtMax = (CFL * 2) / this.omegaMax;
126+ this.stepsPerSample = Math.ceil(1 / (params.fs * dtMax));
127+ this.dt = 1 / (params.fs * this.stepsPerSample);
128+ this.samplesPerBatch = Math.max(1, Math.round(params.fs / params.fps));
129+ this.stepsPerBatch = this.samplesPerBatch * this.stepsPerSample;
130+ this.histLen = this.samplesPerBatch + HIST_MARGIN;
131+
132+ const n = g.n;
133+ const f32 = (size: number, usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST) =>
134+ device.createBuffer({ size: Math.max(16, size * 4), usage });
135+ this.pos = [f32(4 * n), f32(4 * n)];
136+ this.vel = [f32(3 * n), f32(3 * n)];
137+ this.hist = f32(this.histLen * n);
138+ this.stickBuf = f32(STICK_LEN);
139+ this.stand = standRates(plate);
140+ const weights = nodeWeights(g);
141+ const packed = new Float32Array(4 * n);
142+ for (let k = 0; k < n; k++) {
143+ packed[4 * k] = plate.w0[k]; packed[4 * k + 1] = weights[k]; packed[4 * k + 2] = plate.h[k];
144+ packed[4 * k + 3] = this.stand ? this.stand.kwBottom[k] : 0;
145+ }
146+ this.nd = f32(4 * n);
147+ device.queue.writeBuffer(this.nd, 0, packed);
148+ this.lw = f32(2 * n);
149+ this.audioOut = f32(this.samplesPerBatch);
150+ this.frameBuf = f32(n);
151+ const stagingSize = (this.samplesPerBatch + n + STICK_LEN) * 4;
152+ this.staging = [0, 1].map(() => device.createBuffer({ size: stagingSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }));
153+
154+ const { mass, Ix, Iy } = massProperties(plate);
155+ this.mass = mass;
156+ const cp = strikePatch(plate, params);
157+ let i0 = g.nx, i1 = 0, j0 = g.ny, j1 = 0;
158+ for (const k of cp.nodes) {
159+ const i = k % g.nx, j = Math.floor(k / g.nx);
160+ i0 = Math.min(i0, i); i1 = Math.max(i1, i); j0 = Math.min(j0, j); j1 = Math.max(j1, j);
161+ }
162+ const stick = new Float32Array(STICK_LEN);
163+ stick[1] = -params.strikeVelocity; stick[9] = -params.strikeVelocity;
164+ device.queue.writeBuffer(this.stickBuf, 0, stick);
165+ this.stick = { x: 0, v: -params.strikeVelocity, force: 0, maxForce: 0, withdrawn: false };
166+
167+ const sp = strikePoint(plate, params);
168+ const pf = new Float32Array(PARAMS_LEN);
169+ const pu = new Uint32Array(pf.buffer);
170+ pu[0] = g.nx; pu[1] = g.ny; pu[2] = n; pu[3] = this.histLen;
171+ pf[4] = g.hx; pf[5] = g.hy; pf[6] = this.dt; pf[7] = m.nu;
172+ pf[8] = m.E / (12 * (1 - m.nu * m.nu)); pf[9] = m.E / (1 - m.nu * m.nu); pf[10] = m.E / (2 * (1 + m.nu)); pf[11] = m.rho;
173+ pf[12] = mass; pf[13] = Ix; pf[14] = Iy; pf[15] = params.fs;
174+ pf[16] = sp.x; pf[17] = sp.y; pf[18] = params.contactRadius ** 2; pf[19] = cp.mass;
175+ pu[20] = cp.nearest; pf[21] = params.contactStiffness; pf[22] = params.contactExponent; pf[23] = params.stickMass;
176+ pu[24] = i0; pu[25] = i1; pu[26] = j0; pu[27] = j1;
177+ pf[28] = cp.cx; pf[29] = cp.cy;
178+ const st = this.stand;
179+ pu[30] = st && st.nodes.length > 0 ? 1 : 0;
180+ pu[31] = params.padLiftoff ? 1 : 0;
181+ pf[32] = st ? st.preloadTop : 0; pf[33] = st ? st.preloadBottom : 0;
182+ pf[34] = loss.padDamping;
183+ pu[35] = plate.stand?.topPad ? 1 : 0;
184+ this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
185+ device.queue.writeBuffer(this.paramsBuf, 0, pf);
186+
187+ this.stepStride = Math.max(STEP_LEN * 4, device.limits.minUniformBufferOffsetAlignment);
188+ this.stepData = new ArrayBuffer(this.stepStride * this.stepsPerBatch);
189+ this.stepBuf = device.createBuffer({ size: this.stepData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
190+
191+ const shader = (code: string) => device.createShaderModule({ code: commonWgsl + code });
192+ const entry = (binding: number, type: GPUBufferBindingType, hasDynamicOffset = false): GPUBindGroupLayoutEntry => ({
193+ binding, visibility: GPUShaderStage.COMPUTE, buffer: { type, hasDynamicOffset },
194+ });
195+ const pipeline = (module: GPUShaderModule, layout: GPUBindGroupLayout) =>
196+ device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }), compute: { module, entryPoint: "main" } });
197+ const stepLayout = device.createBindGroupLayout({
198+ entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage"), entry(5, "storage"), entry(6, "storage"), entry(7, "storage"), entry(8, "read-only-storage")],
199+ });
200+ this.stepPipe = pipeline(shader(stepWgsl), stepLayout);
201+ const audioLayout = device.createBindGroupLayout({
202+ entries: [entry(0, "uniform"), entry(1, "uniform", true), entry(2, "read-only-storage"), entry(3, "read-only-storage"), entry(4, "storage"), entry(8, "read-only-storage")],
203+ });
204+ this.audioPipe = pipeline(shader(audioWgsl), audioLayout);
205+ const extractLayout = device.createBindGroupLayout({ entries: [entry(0, "uniform"), entry(1, "read-only-storage"), entry(2, "storage"), entry(8, "read-only-storage")] });
206+ this.extractPipe = pipeline(shader(extractWgsl), extractLayout);
207+
208+ const spb = { buffer: this.stepBuf, size: STEP_LEN * 4 };
209+ this.stepBG = [0, 1].map((p) =>
210+ device.createBindGroup({
211+ layout: stepLayout,
212+ entries: [
213+ { binding: 0, resource: { buffer: this.paramsBuf } },
214+ { binding: 1, resource: spb },
215+ { binding: 2, resource: { buffer: this.pos[p] } },
216+ { binding: 3, resource: { buffer: this.vel[p] } },
217+ { binding: 4, resource: { buffer: this.pos[1 - p] } },
218+ { binding: 5, resource: { buffer: this.vel[1 - p] } },
219+ { binding: 6, resource: { buffer: this.hist } },
220+ { binding: 7, resource: { buffer: this.stickBuf } },
221+ { binding: 8, resource: { buffer: this.nd } },
222+ ],
223+ }),
224+ );
225+ this.audioBG = device.createBindGroup({
226+ layout: audioLayout,
227+ entries: [
228+ { binding: 0, resource: { buffer: this.paramsBuf } },
229+ { binding: 1, resource: spb },
230+ { binding: 2, resource: { buffer: this.hist } },
231+ { binding: 3, resource: { buffer: this.lw } },
232+ { binding: 4, resource: { buffer: this.audioOut } },
233+ { binding: 8, resource: { buffer: this.nd } },
234+ ],
235+ });
236+ this.extractBG = [0, 1].map((p) =>
237+ device.createBindGroup({
238+ layout: extractLayout,
239+ entries: [
240+ { binding: 0, resource: { buffer: this.paramsBuf } },
241+ { binding: 1, resource: { buffer: this.pos[p] } },
242+ { binding: 2, resource: { buffer: this.frameBuf } },
243+ { binding: 8, resource: { buffer: this.nd } },
244+ ],
245+ }),
246+ );
247+ this.setListener(params);
248+ this.frames.push(new Float32Array(n));
249+ }
250+
251+ /** Recomputes the per-node weights and delays of the listener sum; takes effect on later samples. */
252+ setListener(p: SimParams): void {
253+ const g = this.plate.grid;
254+ this.params.listenerDist = p.listenerDist; this.params.listenerAngle = p.listenerAngle; this.params.listenerAzimuth = p.listenerAzimuth;
255+ const { weight, delay } = listenerTerms(this.plate, this.params);
256+ const data = new Float32Array(2 * g.n);
257+ let dmin = Infinity;
258+ for (let k = 0; k < g.n; k++) if (weight[k] > 0) dmin = Math.min(dmin, delay[k]);
259+ this.delayClamped = false;
260+ for (let k = 0; k < g.n; k++) {
261+ data[2 * k] = weight[k];
262+ let d = weight[k] > 0 ? (delay[k] - dmin) * this.params.fs : 0;
263+ if (d > HIST_MARGIN - 3) { d = HIST_MARGIN - 3; this.delayClamped = true; }
264+ data[2 * k + 1] = d;
265+ }
266+ this.device.queue.writeBuffer(this.lw, 0, data);
267+ }
268+
269+ setDuration(duration: number): void {
270+ this.params.duration = duration;
271+ if (this.canContinue()) this.phase = "recording";
272+ }
273+ canContinue(): boolean {
274+ return this.phase === "done" && this.samples < this.targetSamples();
275+ }
276+ stop(): void {
277+ this.phase = "done";
278+ }
279+ targetSamples(): number {
280+ return Math.round(this.params.duration * this.params.fs);
281+ }
282+ status(): SimStatus {
283+ const ahead = this.inFlight ? this.inFlight.steps * this.dt : 0;
284+ return { phase: this.phase, t: this.totalSteps * this.dt - ahead, totalSteps: this.totalSteps, samples: this.samples, frames: this.frames.length, peakPressure: this.peakPressure, wmax: this.wmax, delayClamped: this.delayClamped, stick: this.stick };
285+ }
286+ recordedAudio(): Float32Array {
287+ return this.audio.subarray(0, this.samples);
288+ }
289+ private stepWorkgroups(): [number, number] {
290+ return [Math.ceil(this.plate.grid.nx / TILE), Math.ceil(this.plate.grid.ny / TILE)];
291+ }
292+ private appendAudio(raw: Float32Array): void {
293+ const need = this.samples + raw.length;
294+ if (this.audio.length < need) {
295+ const a = new Float32Array(Math.max(need, 2 * this.audio.length, this.params.fs));
296+ a.set(this.audio.subarray(0, this.samples));
297+ this.audio = a;
298+ }
299+ this.audio.set(raw, this.samples);
300+ for (let k = 0; k < raw.length; k++) this.peakPressure = Math.max(this.peakPressure, Math.abs(raw[k]));
301+ this.samples = need;
302+ }
303+ private writeStepParams(s: number, fields: { flags: number; slot: number; outIdx: number }): void {
304+ const o = (s * this.stepStride) / 4;
305+ const u32 = new Uint32Array(this.stepData), i32 = new Int32Array(this.stepData), f32 = new Float32Array(this.stepData);
306+ u32[o] = this.totalSteps + s;
307+ u32[o + 1] = fields.flags;
308+ i32[o + 2] = fields.slot;
309+ i32[o + 3] = fields.outIdx;
310+ f32[o + 4] = this.loss.sigma0;
311+ f32[o + 5] = this.loss.sigma1;
312+ f32[o + 6] = this.loss.sigmaIn;
313+ u32[o + 7] = (this.totalSteps + s) & 1;
314+ }
315+
316+ /** Runs one batch (one movie frame of time); resolves when its results have been read back. */
317+ async runBatch(): Promise<void> {
318+ if (this.phase === "done") return;
319+ const cur = this.inFlight ?? this.encodeBatch();
320+ this.inFlight = null;
321+ if (!cur) { this.phase = "done"; return; }
322+ if (this.stepsLeft() > 0) this.inFlight = this.encodeBatch();
323+ await this.collect(cur);
324+ }
325+ private stepsLeft(): number {
326+ return this.targetSamples() * this.stepsPerSample - this.totalSteps;
327+ }
328+ private encodeBatch(): Pending | null {
329+ const steps = Math.min(this.stepsPerBatch, Math.max(0, this.stepsLeft()));
330+ if (steps === 0) return null;
331+ const audioSteps: number[] = [];
332+ for (let s = 0; s < steps; s++) {
333+ const local = this.totalSteps + s;
334+ let slot = -1, outIdx = -1;
335+ if (local % this.stepsPerSample === 0) {
336+ slot = (local / this.stepsPerSample) % this.histLen;
337+ outIdx = audioSteps.length;
338+ audioSteps.push(s);
339+ }
340+ this.writeStepParams(s, { flags: 1, slot, outIdx });
341+ }
342+ this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, steps * this.stepStride);
343+ const CHUNK = 1024;
344+ let enc = this.device.createCommandEncoder();
345+ let pass = enc.beginComputePass();
346+ const [wgX, wgY] = this.stepWorkgroups();
347+ const n = this.plate.grid.n;
348+ let cur = this.cur;
349+ pass.setPipeline(this.stepPipe);
350+ for (let s = 0; s < steps; s++) {
351+ if (s > 0 && s % CHUNK === 0) {
352+ pass.end();
353+ this.device.queue.submit([enc.finish()]);
354+ enc = this.device.createCommandEncoder();
355+ pass = enc.beginComputePass();
356+ pass.setPipeline(this.stepPipe);
357+ }
358+ pass.setBindGroup(0, this.stepBG[cur], [s * this.stepStride]);
359+ pass.dispatchWorkgroups(wgX, wgY);
360+ cur = 1 - cur;
361+ }
362+ const nAudio = audioSteps.length;
363+ if (nAudio > 0) {
364+ pass.setPipeline(this.audioPipe);
365+ pass.setBindGroup(0, this.audioBG, [audioSteps[0] * this.stepStride]);
366+ pass.dispatchWorkgroups(nAudio);
367+ }
368+ pass.setPipeline(this.extractPipe);
369+ pass.setBindGroup(0, this.extractBG[cur]);
370+ pass.dispatchWorkgroups(Math.ceil(n / 256));
371+ pass.end();
372+ const staging = this.staging[this.stagingIdx];
373+ this.stagingIdx = 1 - this.stagingIdx;
374+ enc.copyBufferToBuffer(this.audioOut, 0, staging, 0, Math.max(4, nAudio * 4));
375+ enc.copyBufferToBuffer(this.frameBuf, 0, staging, this.samplesPerBatch * 4, n * 4);
376+ enc.copyBufferToBuffer(this.stickBuf, 0, staging, (this.samplesPerBatch + n) * 4, STICK_LEN * 4);
377+ this.device.queue.submit([enc.finish()]);
378+ this.cur = cur;
379+ this.totalSteps += steps;
380+ return { staging, steps, nAudio, stickParity: this.totalSteps & 1 };
381+ }
382+ private async collect(b: Pending): Promise<void> {
383+ const n = this.plate.grid.n;
384+ await b.staging.mapAsync(GPUMapMode.READ);
385+ const view = new Float32Array(b.staging.getMappedRange());
386+ const base = this.samplesPerBatch + n + 8 * b.stickParity;
387+ this.stick = { x: view[base], v: view[base + 1], force: view[base + 2], maxForce: view[base + 3], withdrawn: view[base + 5] !== 0 };
388+ this.appendAudio(view.slice(0, b.nAudio));
389+ const frame = view.slice(this.samplesPerBatch, this.samplesPerBatch + n);
390+ for (let k = 0; k < n; k++) this.wmax = Math.max(this.wmax, Math.abs(frame[k]));
391+ this.frames.push(frame);
392+ b.staging.unmap();
393+ if (this.samples >= this.targetSamples()) this.phase = "done";
394+ }
395+
396+ // ---------- Testing support ----------
397+
398+ /** Advances the state by exactly one free step (no drumstick, no audio) with the losses and the stand. */
399+ async stepOnce(): Promise<void> {
400+ this.writeStepParams(0, { flags: 0, slot: -1, outIdx: -1 });
401+ this.device.queue.writeBuffer(this.stepBuf, 0, this.stepData, 0, this.stepStride);
402+ const enc = this.device.createCommandEncoder();
403+ const pass = enc.beginComputePass();
404+ pass.setPipeline(this.stepPipe);
405+ pass.setBindGroup(0, this.stepBG[this.cur], [0]);
406+ pass.dispatchWorkgroups(...this.stepWorkgroups());
407+ pass.end();
408+ this.device.queue.submit([enc.finish()]);
409+ this.cur = 1 - this.cur;
410+ this.totalSteps++;
411+ await this.device.queue.onSubmittedWorkDone();
412+ }
413+ /** Overwrites the state: pos as [u | v | w | wLo], vel as [du | dv | dw]. */
414+ writeState(pos: Float32Array<ArrayBuffer>, vel: Float32Array<ArrayBuffer>): void {
415+ const n = this.plate.grid.n;
416+ if (pos.length !== 4 * n || vel.length !== 3 * n) throw new Error("state arrays have the wrong length");
417+ this.device.queue.writeBuffer(this.pos[this.cur], 0, pos);
418+ this.device.queue.writeBuffer(this.vel[this.cur], 0, vel);
419+ }
420+ /** Reads back the full state as [u | v | w | wLo] and [du | dv | dw]. */
421+ async readState(): Promise<{ pos: Float32Array; vel: Float32Array }> {
422+ const n = this.plate.grid.n;
423+ const stg = this.device.createBuffer({ size: 7 * n * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
424+ const enc = this.device.createCommandEncoder();
425+ enc.copyBufferToBuffer(this.pos[this.cur], 0, stg, 0, 4 * n * 4);
426+ enc.copyBufferToBuffer(this.vel[this.cur], 0, stg, 4 * n * 4, 3 * n * 4);
427+ this.device.queue.submit([enc.finish()]);
428+ await stg.mapAsync(GPUMapMode.READ);
429+ const all = new Float32Array(stg.getMappedRange()).slice();
430+ stg.unmap();
431+ stg.destroy();
432+ return { pos: all.subarray(0, 4 * n), vel: all.subarray(4 * n) };
433+ }
434+
435+ destroy(): void {
436+ this.inFlight = null;
437+ for (const b of [...this.pos, ...this.vel, this.hist, this.stickBuf, this.nd, this.lw, this.audioOut, this.frameBuf, this.paramsBuf, this.stepBuf, ...this.staging]) b.destroy();
438+ }
439+ get fs(): number { return this.params.fs; }
440+ get fps(): number { return this.params.fps; }
441+ get simParams(): SimParams { return this.params; }
442+}
src/style.cssadded+85−0View file
@@ -0,0 +1,85 @@
1+:root {
2+ --bg: #16181d;
3+ --bg2: #1e2128;
4+ --bg3: #262a33;
5+ --fg: #d8dbe2;
6+ --fg2: #9aa0ad;
7+ --accent: #5aa9ff;
8+ --warn: #ffb454;
9+ --err: #ff6b6b;
10+ --ok: #7fd58a;
11+ --border: #30343e;
12+ --sidebar: 440px;
13+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
14+ font-size: 13px;
15+ color-scheme: dark;
16+}
17+* { box-sizing: border-box; }
18+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); }
19+a { color: var(--accent); }
20+#app { display: flex; height: 100vh; overflow: hidden; }
21+#sidebar { width: var(--sidebar); min-width: 280px; display: flex; flex-direction: column; background: var(--bg2); border-right: 1px solid var(--border); }
22+#sidebarHead { padding: 8px 12px; border-bottom: 1px solid var(--border); }
23+#sidebarHead h1 { font-size: 15px; margin: 0 0 6px 0; font-weight: 600; letter-spacing: 0.02em; }
24+#sidebarBody { flex: 1; overflow-y: auto; padding: 6px 12px 24px 12px; }
25+#splitter { width: 5px; cursor: col-resize; background: var(--bg); }
26+#splitter:hover { background: var(--border); }
27+#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
28+#viewWrap { flex: 1; position: relative; min-height: 200px; }
29+#view { width: 100%; height: 100%; display: block; }
30+#viewOverlay { position: absolute; left: 8px; top: 6px; color: var(--fg2); font-size: 12px; pointer-events: none; white-space: pre-line; }
31+#spec { height: 220px; width: 100%; display: block; background: #000; }
32+.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--bg2); font-size: 12px; }
33+.toolbar label { display: inline-flex; align-items: center; gap: 4px; color: var(--fg2); }
34+.toolbar input[type=range] { width: 120px; }
35+#movieToolbar input[type=range].scrub { flex: 1; min-width: 120px; }
36+button, select, input { font: inherit; color: var(--fg); background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 3px 8px; }
37+button { cursor: pointer; }
38+button:hover:not(:disabled) { border-color: var(--accent); }
39+button:disabled { opacity: 0.45; cursor: default; }
40+button.primary { background: #2b4a75; border-color: #3a6db0; }
41+input[type=number] { width: 84px; padding: 2px 6px; text-align: right; }
42+input[type=checkbox] { padding: 0; }
43+details > summary { cursor: pointer; font-weight: 600; padding: 6px 0; color: var(--fg); user-select: none; }
44+details { border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 4px; }
45+#about p { color: var(--fg2); margin: 4px 0; line-height: 1.4; }
46+#controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
47+#controls .grow { flex: 1; }
48+.info { color: var(--fg2); font-size: 12px; line-height: 1.45; white-space: pre-line; margin-top: 6px; }
49+.info .warn { color: var(--warn); }
50+.info .err { color: var(--err); }
51+.status { font-size: 12px; margin-top: 4px; }
52+/* Editor */
53+.design-editor .editor-head { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
54+.design-editor .editor-head select { flex: 1; }
55+.design-editor .editor-script { border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
56+.design-editor .editor-status { margin-top: 4px; font-size: 12px; white-space: pre-wrap; }
57+.design-editor .editor-status-error { color: var(--err); }
58+.design-editor .editor-status-ok { color: var(--ok); }
59+.design-editor .editor-status-info { color: var(--fg2); }
60+/* Form */
61+.pform-group summary { font-weight: 600; }
62+.pform-rows { display: grid; grid-template-columns: 1fr auto; gap: 3px 8px; align-items: center; }
63+.pform-row { display: contents; }
64+.pform-row label { color: var(--fg2); }
65+.pform-control { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
66+.pform-unit { color: var(--fg2); min-width: 28px; font-size: 12px; }
67+.pform-notes { border: none; margin: 4px 0 0 0; padding: 0; }
68+.pform-notes summary { font-weight: normal; color: var(--fg2); padding: 2px 0; font-size: 12px; }
69+.pform-notes p { color: var(--fg2); font-size: 12px; margin: 2px 0; line-height: 1.45; white-space: pre-line; }
70+/* Mode browser */
71+.modes-head { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; flex-wrap: wrap; }
72+.modes-head input { width: 70px; }
73+.modes-info { color: var(--fg2); font-size: 12px; margin-bottom: 4px; white-space: pre-line; }
74+.modes-table { max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; }
75+.modes-table table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
76+.modes-table th { position: sticky; top: 0; background: var(--bg3); text-align: right; padding: 3px 6px; font-weight: 600; }
77+.modes-table th:first-child, .modes-table td:first-child { text-align: left; }
78+.modes-table td { text-align: right; padding: 2px 6px; cursor: pointer; }
79+.modes-table tr:hover td { background: var(--bg3); }
80+.modes-table tr.selected td { background: #2b4a75; }
81+.progress { height: 6px; background: var(--bg3); border-radius: 3px; overflow: hidden; margin: 4px 0; }
82+.progress > div { height: 100%; background: var(--accent); width: 0; transition: width 0.15s; }
83+.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--bg3); color: var(--fg2); font-size: 11px; }
84+.badge.warn { color: var(--warn); }
85+#nogpu { color: var(--warn); }
src/ui/editor.tsadded+241−0View file
@@ -0,0 +1,241 @@
1+// The design editor: a preset selector with a "Custom (edited)" entry, a
2+// Revert button, the script in a CodeMirror view with MATLAB (Octave)
3+// highlighting, and a status line. The editor does not compile anything
4+// itself: it hands the source to the host (debounced while typing, at once
5+// on Ctrl/Cmd-Enter and on preset selection), and the host reports the
6+// outcome back through setStatus.
7+
8+import { Compartment, EditorState } from "@codemirror/state";
9+import { EditorView, drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers } from "@codemirror/view";
10+import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
11+import { HighlightStyle, StreamLanguage, bracketMatching, syntaxHighlighting } from "@codemirror/language";
12+import { octave } from "@codemirror/legacy-modes/mode/octave";
13+import { tags } from "@lezer/highlight";
14+import { DEFAULT_PRESET, PRESETS, presetById } from "../presets.ts";
15+
16+export interface EditorCallbacks {
17+ /** Called (debounced 400 ms after typing, or at once on Ctrl/Cmd-Enter and on preset selection) with the current source. The host compiles it and reports back through setStatus. */
18+ onSource: (source: string, presetId: string | null) => void;
19+}
20+
21+export type StatusKind = "ok" | "error" | "info";
22+
23+const CUSTOM = "custom";
24+const DEBOUNCE_MS = 400;
25+
26+const highlight = HighlightStyle.define([
27+ { tag: tags.comment, color: "#7d8893", fontStyle: "italic" },
28+ { tag: tags.keyword, color: "#8ab4f8" },
29+ { tag: tags.string, color: "#e3b26f" },
30+ { tag: tags.number, color: "#b5cea8" },
31+ { tag: tags.operator, color: "#c8cdd3" },
32+ { tag: tags.variableName, color: "#dde1e6" },
33+ { tag: tags.atom, color: "#c586c0" },
34+ { tag: tags.bracket, color: "#c8cdd3" },
35+]);
36+
37+const theme = EditorView.theme(
38+ {
39+ "&": { backgroundColor: "#0f1113", color: "#dde1e6", border: "1px solid #2b2f35", borderRadius: "4px", fontSize: "12px", height: "360px" },
40+ "&.cm-focused": { outline: "1px solid #3b8ce6" },
41+ ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", lineHeight: "1.4", overflow: "auto" },
42+ ".cm-content": { caretColor: "#f0b429" },
43+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#f0b429" },
44+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "#2b4a70 !important" },
45+ ".cm-activeLine": { backgroundColor: "#191c20" },
46+ ".cm-gutters": { backgroundColor: "#15171a", color: "#5b636c", border: "none", borderRight: "1px solid #2b2f35" },
47+ ".cm-activeLineGutter": { backgroundColor: "#191c20", color: "#9aa0a8" },
48+ ".cm-matchingBracket": { backgroundColor: "#2b4a70", outline: "none" },
49+ "&.cm-locked": { opacity: "0.65" },
50+ },
51+ { dark: true },
52+);
53+
54+export class DesignEditor {
55+ private readonly callbacks: EditorCallbacks;
56+ private readonly select = document.createElement("select");
57+ private readonly revert = document.createElement("button");
58+ private readonly status = document.createElement("div");
59+ private readonly view: EditorView;
60+ private readonly editable = new Compartment();
61+ private readonly readOnly = new Compartment();
62+ private timer: number | null = null;
63+ /** Set while the text is replaced programmatically, so the change is not treated as typing. */
64+ private replacing = false;
65+ /** The source last handed to the host, so a debounce that changed nothing is skipped. */
66+ private lastSent: string | null = null;
67+
68+ /**
69+ * Builds the editor inside `container`. The default preset's text is loaded
70+ * but onSource is not fired; the host calls selectPreset or setSource to
71+ * start (and compiles on its own if it used setSource).
72+ */
73+ constructor(container: HTMLElement, callbacks: EditorCallbacks) {
74+ this.callbacks = callbacks;
75+ container.classList.add("design-editor");
76+
77+ const head = document.createElement("div");
78+ head.className = "editor-head";
79+ const label = document.createElement("label");
80+ label.textContent = "Preset";
81+ for (const p of PRESETS) {
82+ const o = document.createElement("option");
83+ o.value = p.id;
84+ o.textContent = p.label;
85+ this.select.appendChild(o);
86+ }
87+ const custom = document.createElement("option");
88+ custom.value = CUSTOM;
89+ custom.textContent = "Custom (edited)";
90+ this.select.appendChild(custom);
91+ this.select.title = "Load a predefined design script";
92+ this.select.addEventListener("change", () => {
93+ const id = this.select.value;
94+ if (id === CUSTOM) {
95+ // Nothing to load: keep the text as it is and stay on the custom entry.
96+ return;
97+ }
98+ this.selectPreset(id);
99+ });
100+ this.revert.type = "button";
101+ this.revert.textContent = "Revert";
102+ this.revert.title = "Restore the selected preset's script (the default preset when the script is custom)";
103+ this.revert.addEventListener("click", () => {
104+ const id = presetById(this.select.value) ? this.select.value : DEFAULT_PRESET;
105+ this.selectPreset(id);
106+ });
107+ head.append(label, this.select, this.revert);
108+
109+ const wrap = document.createElement("div");
110+ wrap.className = "editor-script";
111+ const initial = presetById(DEFAULT_PRESET) ?? PRESETS[0];
112+ this.view = new EditorView({
113+ parent: wrap,
114+ state: EditorState.create({
115+ doc: initial?.source ?? "",
116+ extensions: [
117+ lineNumbers(),
118+ highlightActiveLineGutter(),
119+ highlightActiveLine(),
120+ drawSelection(),
121+ history(),
122+ bracketMatching(),
123+ StreamLanguage.define(octave),
124+ syntaxHighlighting(highlight),
125+ theme,
126+ this.editable.of(EditorView.editable.of(true)),
127+ this.readOnly.of(EditorState.readOnly.of(false)),
128+ keymap.of([
129+ { key: "Mod-Enter", run: () => { this.fireNow(); return true; } },
130+ indentWithTab,
131+ ...defaultKeymap,
132+ ...historyKeymap,
133+ ]),
134+ EditorView.updateListener.of((u) => {
135+ if (!u.docChanged || this.replacing) return;
136+ this.syncSelect();
137+ this.schedule();
138+ }),
139+ ],
140+ }),
141+ });
142+ this.select.value = initial?.id ?? CUSTOM;
143+
144+ this.status.className = "editor-status";
145+ this.setStatus("", "info");
146+
147+ container.append(head, wrap, this.status);
148+ }
149+
150+ /** Loads the preset's text, selects it, and fires onSource at once. Unknown ids are ignored. */
151+ selectPreset(id: string): void {
152+ const p = presetById(id);
153+ if (!p) return;
154+ this.replaceText(p.source);
155+ this.select.value = p.id;
156+ this.fireNow();
157+ }
158+
159+ /** Replaces the text without firing onSource. The select shows the matching preset if the text is one, otherwise "Custom (edited)". */
160+ setSource(source: string): void {
161+ this.cancel();
162+ this.replaceText(source);
163+ this.syncSelect();
164+ }
165+
166+ getSource(): string {
167+ return this.view.state.doc.toString();
168+ }
169+
170+ /** The id of the selected preset, or null when the text is custom. */
171+ presetId(): string | null {
172+ return this.select.value === CUSTOM ? null : this.select.value;
173+ }
174+
175+ /** Writes the status line under the editor; the kind selects the class (errors are tinted red by the host's stylesheet). */
176+ setStatus(text: string, kind: StatusKind): void {
177+ this.status.textContent = text;
178+ this.status.className = `editor-status editor-status-${kind}`;
179+ this.status.hidden = text === "";
180+ }
181+
182+ /** Makes the text read-only (and the preset controls inert) while a simulation runs. */
183+ setLocked(locked: boolean): void {
184+ if (locked) this.cancel();
185+ this.view.dispatch({
186+ effects: [
187+ this.editable.reconfigure(EditorView.editable.of(!locked)),
188+ this.readOnly.reconfigure(EditorState.readOnly.of(locked)),
189+ ],
190+ });
191+ this.view.dom.classList.toggle("cm-locked", locked);
192+ this.select.disabled = locked;
193+ this.revert.disabled = locked;
194+ }
195+
196+ /** Focuses the text. */
197+ focus(): void {
198+ this.view.focus();
199+ }
200+
201+ private replaceText(source: string): void {
202+ if (source === this.getSource()) return;
203+ this.replacing = true;
204+ try {
205+ this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: source } });
206+ } finally {
207+ this.replacing = false;
208+ }
209+ }
210+
211+ /** Points the select at the preset whose text matches, preferring the one already selected, else at the custom entry. */
212+ private syncSelect(): void {
213+ const src = this.getSource();
214+ const current = presetById(this.select.value);
215+ if (current && current.source === src) return;
216+ const match = PRESETS.find((p) => p.source === src);
217+ this.select.value = match ? match.id : CUSTOM;
218+ }
219+
220+ private schedule(): void {
221+ this.cancel();
222+ this.timer = window.setTimeout(() => {
223+ this.timer = null;
224+ if (this.getSource() !== this.lastSent) this.fireNow();
225+ }, DEBOUNCE_MS);
226+ }
227+
228+ private cancel(): void {
229+ if (this.timer !== null) {
230+ window.clearTimeout(this.timer);
231+ this.timer = null;
232+ }
233+ }
234+
235+ private fireNow(): void {
236+ this.cancel();
237+ const src = this.getSource();
238+ this.lastSent = src;
239+ this.callbacks.onSource(src, this.presetId());
240+ }
241+}
src/ui/form.tsadded+232−0View file
@@ -0,0 +1,232 @@
1+// A parameter form for SimParams: groups of numeric fields in convenient
2+// units, checkboxes, and selects, each group a folding <details> with an
3+// optional folded "Notes" paragraph that the host fills in. The values are
4+// kept in SI; the field specs say how they are displayed. No styling is done
5+// here beyond class names (.pform, .pform-group, .pform-row, .pform-notes).
6+
7+import { type SimParams } from "../params.ts";
8+
9+export interface FieldSpec {
10+ key: keyof SimParams;
11+ label: string;
12+ unit?: string; // shown after the input
13+ scale?: number; // displayed value = SI value / scale (e.g. mm: 1e-3, g: 1e-3); default 1
14+ step?: number; // input step in displayed units
15+ min?: number; // in displayed units
16+ max?: number; // in displayed units
17+ live?: boolean; // may be changed while a run is in progress (host decides what to do)
18+ kind?: "number" | "checkbox" | "select";
19+ options?: { value: number | boolean; label: string }[]; // for selects
20+}
21+
22+export interface GroupSpec {
23+ title: string;
24+ fields: FieldSpec[];
25+ /** Reserve a folded "Notes" paragraph under the group, filled by setNotes. */
26+ notes?: boolean;
27+}
28+
29+export type ParamValue = number | boolean;
30+export type ParamChange = (key: keyof SimParams, value: ParamValue, live: boolean) => void;
31+
32+type Values = Record<keyof SimParams, ParamValue>;
33+
34+interface Field {
35+ spec: FieldSpec;
36+ kind: "number" | "checkbox" | "select";
37+ scale: number;
38+ el: HTMLInputElement | HTMLSelectElement;
39+}
40+
41+/** A compact decimal rendering of a displayed value. */
42+export function formatNumber(v: number): string {
43+ if (!isFinite(v)) return "";
44+ if (v === 0) return "0";
45+ const a = Math.abs(v);
46+ if (a >= 1e6 || a < 1e-4) return v.toExponential(3).replace(/\.?0+e/, "e");
47+ return String(+v.toPrecision(7));
48+}
49+
50+export class ParamForm {
51+ private readonly values: Values;
52+ private readonly onChange: ParamChange;
53+ private readonly fields = new Map<keyof SimParams, Field>();
54+ private readonly notes = new Map<string, HTMLElement>();
55+
56+ constructor(container: HTMLElement, groups: GroupSpec[], initial: SimParams, onChange: ParamChange) {
57+ this.values = { ...initial } as Values;
58+ this.onChange = onChange;
59+ container.classList.add("pform");
60+ for (const g of groups) container.appendChild(this.buildGroup(g));
61+ }
62+
63+ /** The current values in SI (a copy). */
64+ get(): SimParams {
65+ return { ...this.values } as unknown as SimParams;
66+ }
67+
68+ /** Updates a value from the host and rewrites its input; onChange is not called. Unknown keys are ignored. */
69+ set(key: keyof SimParams, value: ParamValue): void {
70+ if (!(key in this.values)) return;
71+ this.values[key] = value;
72+ const f = this.fields.get(key);
73+ if (f) this.render(f);
74+ }
75+
76+ /** Fills the Notes paragraph of the group with that title (a group built with notes: true). */
77+ setNotes(groupTitle: string, html: string): void {
78+ const p = this.notes.get(groupTitle);
79+ if (p) p.innerHTML = html;
80+ }
81+
82+ /** Disables the fields that are not marked live while a run is in progress. */
83+ setLocked(locked: boolean): void {
84+ for (const f of this.fields.values()) if (!f.spec.live) f.el.disabled = locked;
85+ }
86+
87+ /** Applies URL query parameters: any SimParams key given in SI units (booleans as 0/1/true/false); returns the keys applied. */
88+ applyUrl(params: URLSearchParams): (keyof SimParams)[] {
89+ const applied: (keyof SimParams)[] = [];
90+ for (const [k, raw] of params) {
91+ if (!(k in this.values)) continue;
92+ const key = k as keyof SimParams;
93+ const v = raw.trim().toLowerCase();
94+ if (typeof this.values[key] === "boolean") {
95+ if (v === "1" || v === "true") this.values[key] = true;
96+ else if (v === "0" || v === "false") this.values[key] = false;
97+ else continue;
98+ } else {
99+ const num = Number(v);
100+ if (v === "" || !isFinite(num)) continue;
101+ this.values[key] = num;
102+ }
103+ applied.push(key);
104+ const f = this.fields.get(key);
105+ if (f) this.render(f);
106+ }
107+ return applied;
108+ }
109+
110+ private buildGroup(g: GroupSpec): HTMLElement {
111+ const det = document.createElement("details");
112+ det.className = "pform-group";
113+ det.open = true;
114+ const sum = document.createElement("summary");
115+ sum.textContent = g.title;
116+ det.appendChild(sum);
117+ const rows = document.createElement("div");
118+ rows.className = "pform-rows";
119+ for (const spec of g.fields) rows.appendChild(this.buildRow(spec));
120+ det.appendChild(rows);
121+ if (g.notes) {
122+ const fold = document.createElement("details");
123+ fold.className = "pform-notes";
124+ const s = document.createElement("summary");
125+ s.textContent = "Notes";
126+ const p = document.createElement("p");
127+ fold.append(s, p);
128+ det.appendChild(fold);
129+ this.notes.set(g.title, p);
130+ }
131+ return det;
132+ }
133+
134+ private buildRow(spec: FieldSpec): HTMLElement {
135+ const kind = spec.kind ?? (spec.options ? "select" : typeof this.values[spec.key] === "boolean" ? "checkbox" : "number");
136+ const row = document.createElement("div");
137+ row.className = `pform-row pform-row-${kind}`;
138+ const id = `pform-${String(spec.key)}`;
139+ const label = document.createElement("label");
140+ label.textContent = spec.label;
141+ label.htmlFor = id;
142+
143+ let el: HTMLInputElement | HTMLSelectElement;
144+ if (kind === "select") {
145+ const sel = document.createElement("select");
146+ (spec.options ?? []).forEach((opt, i) => {
147+ const o = document.createElement("option");
148+ o.value = String(i);
149+ o.textContent = opt.label;
150+ sel.appendChild(o);
151+ });
152+ el = sel;
153+ } else {
154+ const inp = document.createElement("input");
155+ if (kind === "checkbox") {
156+ inp.type = "checkbox";
157+ } else {
158+ inp.type = "number";
159+ inp.step = spec.step !== undefined ? String(spec.step) : "any";
160+ if (spec.min !== undefined) inp.min = String(spec.min);
161+ if (spec.max !== undefined) inp.max = String(spec.max);
162+ inp.inputMode = "decimal";
163+ }
164+ el = inp;
165+ }
166+ el.id = id;
167+ if (spec.live) el.classList.add("pform-live");
168+ const field: Field = { spec, kind, scale: spec.scale ?? 1, el };
169+ this.fields.set(spec.key, field);
170+ this.render(field);
171+
172+ el.addEventListener("change", () => this.commit(field));
173+ if (kind === "number") {
174+ (el as HTMLInputElement).addEventListener("keydown", (e: KeyboardEvent) => {
175+ if (e.key === "Enter") {
176+ e.preventDefault();
177+ this.commit(field);
178+ }
179+ });
180+ }
181+
182+ const control = document.createElement("div");
183+ control.className = "pform-control";
184+ control.appendChild(el);
185+ if (spec.unit && kind !== "checkbox") {
186+ const unit = document.createElement("span");
187+ unit.className = "pform-unit";
188+ unit.textContent = spec.unit;
189+ control.appendChild(unit);
190+ }
191+ row.append(label, control);
192+ return row;
193+ }
194+
195+ /** Writes the stored value into the field's control. */
196+ private render(f: Field): void {
197+ const v = this.values[f.spec.key];
198+ if (f.kind === "select") {
199+ const i = (f.spec.options ?? []).findIndex((o) => o.value === v);
200+ (f.el as HTMLSelectElement).value = i >= 0 ? String(i) : "";
201+ } else if (f.kind === "checkbox") {
202+ (f.el as HTMLInputElement).checked = v === true;
203+ } else {
204+ (f.el as HTMLInputElement).value = formatNumber((v as number) / f.scale);
205+ }
206+ }
207+
208+ /** Reads the control, stores the value (in SI) when valid, or restores the previous value, and tells the host. */
209+ private commit(f: Field): void {
210+ const key = f.spec.key;
211+ const prev = this.values[key];
212+ let next: ParamValue;
213+ if (f.kind === "select") {
214+ const opt = (f.spec.options ?? [])[Number((f.el as HTMLSelectElement).value)];
215+ if (!opt) { this.render(f); return; }
216+ next = opt.value;
217+ } else if (f.kind === "checkbox") {
218+ next = (f.el as HTMLInputElement).checked;
219+ } else {
220+ const inp = f.el as HTMLInputElement;
221+ let d = inp.value.trim() === "" ? NaN : Number(inp.value);
222+ if (!isFinite(d)) { this.render(f); return; }
223+ if (f.spec.min !== undefined) d = Math.max(f.spec.min, d);
224+ if (f.spec.max !== undefined) d = Math.min(f.spec.max, d);
225+ next = d * f.scale;
226+ }
227+ if (next === prev) { this.render(f); return; }
228+ this.values[key] = next;
229+ this.render(f);
230+ this.onChange(key, next, !!f.spec.live);
231+ }
232+}
src/ui/modes.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+// The mode browser: a table of the modes of the linear model (frequency and
2+// decay time), with a filter by frequency; clicking a row selects the mode,
3+// whose shape the host then draws on the plate.
4+
5+export interface ModeRow {
6+ freq: number; // [Hz]
7+ t60: number; // [s]
8+}
9+
10+export class ModeBrowser {
11+ private info: HTMLDivElement;
12+ private tableWrap: HTMLDivElement;
13+ private tbody: HTMLTableSectionElement;
14+ private minInput: HTMLInputElement;
15+ private maxInput: HTMLInputElement;
16+ private clearBtn: HTMLButtonElement;
17+ private progress: HTMLDivElement;
18+ private progressBar: HTMLDivElement;
19+ private rows: ModeRow[] = [];
20+ private sel: number | null = null;
21+
22+ constructor(container: HTMLElement, private onSelect: (m: number | null) => void) {
23+ container.classList.add("modes");
24+ const head = document.createElement("div");
25+ head.className = "modes-head";
26+ const lab = (t: string) => { const l = document.createElement("label"); l.textContent = t; return l; };
27+ this.minInput = document.createElement("input");
28+ this.minInput.type = "number"; this.minInput.placeholder = "min"; this.minInput.min = "0"; this.minInput.step = "10";
29+ this.maxInput = document.createElement("input");
30+ this.maxInput.type = "number"; this.maxInput.placeholder = "max"; this.maxInput.min = "0"; this.maxInput.step = "10";
31+ this.clearBtn = document.createElement("button");
32+ this.clearBtn.textContent = "Deselect";
33+ this.clearBtn.disabled = true;
34+ head.append(lab("Show"), this.minInput, lab("to"), this.maxInput, lab("Hz"), this.clearBtn);
35+ this.info = document.createElement("div");
36+ this.info.className = "modes-info";
37+ this.progress = document.createElement("div");
38+ this.progress.className = "progress";
39+ this.progress.hidden = true;
40+ this.progressBar = document.createElement("div");
41+ this.progress.append(this.progressBar);
42+ this.tableWrap = document.createElement("div");
43+ this.tableWrap.className = "modes-table";
44+ const table = document.createElement("table");
45+ const thead = document.createElement("thead");
46+ thead.innerHTML = "<tr><th>#</th><th>f (Hz)</th><th>T60 (s)</th></tr>";
47+ this.tbody = document.createElement("tbody");
48+ table.append(thead, this.tbody);
49+ this.tableWrap.append(table);
50+ container.append(head, this.info, this.progress, this.tableWrap);
51+ this.minInput.addEventListener("change", () => this.renderRows());
52+ this.maxInput.addEventListener("change", () => this.renderRows());
53+ this.clearBtn.addEventListener("click", () => this.select(null));
54+ }
55+
56+ /** Shows a progress bar (fraction 0..1) with a message; null hides it. */
57+ setProgress(fraction: number | null, message?: string): void {
58+ this.progress.hidden = fraction === null;
59+ if (fraction !== null) this.progressBar.style.width = `${Math.round(100 * fraction)}%`;
60+ if (message !== undefined) this.info.textContent = message;
61+ }
62+
63+ setModes(rows: ModeRow[], info: string): void {
64+ this.rows = rows;
65+ this.info.textContent = info;
66+ this.sel = null;
67+ this.clearBtn.disabled = true;
68+ this.renderRows();
69+ }
70+
71+ clear(message: string): void {
72+ this.rows = [];
73+ this.sel = null;
74+ this.clearBtn.disabled = true;
75+ this.info.textContent = message;
76+ this.tbody.innerHTML = "";
77+ }
78+
79+ selected(): number | null {
80+ return this.sel;
81+ }
82+
83+ select(m: number | null): void {
84+ this.sel = m;
85+ this.clearBtn.disabled = m === null;
86+ for (const tr of Array.from(this.tbody.rows)) tr.classList.toggle("selected", Number(tr.dataset.m) === m);
87+ this.onSelect(m);
88+ }
89+
90+ private renderRows(): void {
91+ const lo = Number(this.minInput.value) || 0;
92+ const hi = Number(this.maxInput.value) || Infinity;
93+ this.tbody.innerHTML = "";
94+ const frag = document.createDocumentFragment();
95+ this.rows.forEach((r, m) => {
96+ if (r.freq < lo || r.freq > hi) return;
97+ const tr = document.createElement("tr");
98+ tr.dataset.m = String(m);
99+ if (m === this.sel) tr.classList.add("selected");
100+ const f = r.freq < 1000 ? r.freq.toFixed(1) : r.freq.toFixed(0);
101+ tr.innerHTML = `<td>${m + 1}</td><td>${f}</td><td>${r.t60.toFixed(2)}</td>`;
102+ tr.addEventListener("click", () => this.select(this.sel === m ? null : m));
103+ frag.append(tr);
104+ });
105+ this.tbody.append(frag);
106+ }
107+}
tests/assemble.test.tsadded+59−0View file
@@ -0,0 +1,59 @@
1+// The assembled linear stiffness against the reference forces, on flat and
2+// curved plates, free and on a stand.
3+import { test } from "node:test";
4+import assert from "node:assert/strict";
5+import { makeGrid, withMask, type Plate, nodeMasses, nodeX, nodeY, standRates } from "../src/params.ts";
6+import { uniformPlate, accelerations, fields } from "../src/model/reference.ts";
7+import { assemble, stiffnessMatvec, toGrid, fromGrid, isFlatPlate } from "../src/model/assemble.ts";
8+
9+const bronze = { E: 110e9, nu: 0.34, rho: 8800 };
10+
11+function checkAgainstReference(pl: Plate, fieldsN: 1 | 3, seed = 3): number {
12+ const g = pl.grid;
13+ const sys = assemble(pl, fieldsN);
14+ const masses = nodeMasses(pl);
15+ let s = seed >>> 0;
16+ const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 - 0.5; };
17+ const x = new Float64Array(sys.n);
18+ for (let i = 0; i < sys.n; i++) x[i] = rnd();
19+ const y = new Float64Array(sys.n);
20+ stiffnessMatvec(sys, x, y);
21+ // The reference force at a tiny amplitude, scaled back: -M a(eps x) / eps.
22+ const eps = 1e-9;
23+ const fx = toGrid(sys, x.map((v) => v * eps), g.n);
24+ const out = fields(g.n);
25+ accelerations(pl, fx, out, false, standRates(pl));
26+ const fm = { u: out.u.map((a, k) => (-a * masses[k]) / eps), v: out.v.map((a, k) => (-a * masses[k]) / eps), w: out.w.map((a, k) => (-a * masses[k]) / eps) };
27+ const yref = fromGrid(sys, fm);
28+ let err = 0, nrm = 0;
29+ for (let i = 0; i < sys.n; i++) { err += (y[i] - yref[i]) ** 2; nrm += yref[i] ** 2; }
30+ return Math.sqrt(err / nrm);
31+}
32+
33+test("K x equals the linearized reference force on a flat rectangle (one and three fields)", () => {
34+ const pl = uniformPlate(makeGrid(0.2, 0.15, 0.02), bronze, 1e-3);
35+ assert.ok(isFlatPlate(pl));
36+ assert.ok(checkAgainstReference(pl, 1) < 1e-6);
37+ assert.ok(checkAgainstReference(pl, 3) < 1e-6);
38+});
39+
40+test("K x equals the linearized reference force on a bowed, tapered plate with a hole on a stand", () => {
41+ const g = withMask(makeGrid(0.2, 0.2, 0.0125), (x, y) => x * x + y * y <= 0.1 ** 2 && x * x + y * y >= 0.01 ** 2);
42+ const w0 = new Float64Array(g.n);
43+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) w0[j * g.nx + i] = 3e-3 * Math.sin(20 * nodeX(g, i)) * Math.cos(15 * nodeY(g, j));
44+ const pl: Plate = { ...uniformPlate(g, bronze, 1e-3, w0), stand: { x: 0, y: 0, pad: { innerRadius: 0.012, outerRadius: 0.03, thickness: 0.01, E: 1e6, lossFactor: 0.3 }, topPad: true, preload: 1e-3 } };
45+ for (let k = 0; k < g.n; k++) if (pl.h[k] > 0) pl.h[k] = 1e-3 * (1 + 0.4 * Math.abs(nodeX(g, k % g.nx)) / 0.1);
46+ assert.ok(!isFlatPlate(pl));
47+ const sys = assemble(pl);
48+ assert.equal(sys.fields, 3);
49+ assert.ok(sys.onStand);
50+ const rel = checkAgainstReference(pl, 3);
51+ assert.ok(rel < 1e-6, `relative error ${rel}`);
52+});
53+
54+test("the band is narrow: the shorter grid axis is innermost", () => {
55+ const wide = assemble(uniformPlate(makeGrid(0.4, 0.1, 0.01), bronze, 1e-3));
56+ const tall = assemble(uniformPlate(makeGrid(0.1, 0.4, 0.01), bronze, 1e-3));
57+ assert.equal(wide.bw, tall.bw);
58+ assert.ok(wide.bw <= 2 * 11 + 2, `bw ${wide.bw}`);
59+});
tests/design.test.tsadded+165−0View file
@@ -0,0 +1,165 @@
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+import { compileDesign, evaluatePlate, DesignError, removePlane, isFlat } from "../src/design.ts";
4+import { PRESETS } from "../src/presets.ts";
5+import { countInside, insideNode, nodeX, nodeY, nodeWeight, standRates, massProperties } from "../src/params.ts";
6+import { t60At, decayRate, fitSigma, fittedRate, LN1000, constantLoss, lossFromFunction, lossFromTable, LOSS_FREQS } from "../src/loss.ts";
7+
8+const base = `
9+mm = 1e-3;
10+design.name = 'test';
11+design.material.E = 200e9; design.material.nu = 0.3; design.material.rho = 7850;
12+design.bounds.width = 0.2; design.bounds.height = 0.16;
13+design.thickness = 1*mm;
14+`;
15+
16+test("a minimal script compiles with defaults", () => {
17+ const d = compileDesign(base);
18+ assert.equal(d.name, "test");
19+ assert.equal(d.thicknessConst, 1e-3);
20+ assert.equal(d.profileConst, 0);
21+ assert.equal(d.stand, null);
22+ assert.equal(d.hasInside, false);
23+ assert.ok(d.lossConst);
24+ assert.ok(Math.abs(t60At(d.lossConst!, 100) / (4 / 1.05) - 1) < 1e-3);
25+});
26+
27+test("missing fields and bad values are reported in the design's terms", () => {
28+ assert.throws(() => compileDesign("x = 1;"), (e: unknown) => e instanceof DesignError && /struct named design/.test((e as Error).message));
29+ assert.throws(() => compileDesign(base.replace("design.thickness = 1*mm;", "")), /design.thickness is required/);
30+ assert.throws(() => compileDesign(base.replace("design.thickness = 1*mm;", "design.thickness = -1;")), /positive/);
31+ assert.throws(() => compileDesign(base + "design.inside = 3;"), /design.inside must be a function handle/);
32+ assert.throws(() => compileDesign(base + "design.loss.T60 = -2;"), /positive decay time/);
33+ assert.throws(() => compileDesign(base + "design.material.nu = 0.6;"), /below 0.5/);
34+});
35+
36+test("script errors carry the line", () => {
37+ assert.throws(() => compileDesign(base + "\nfoo(3);"), /line 8/);
38+});
39+
40+test("functions defined at the bottom are hoisted and usable in handles", () => {
41+ const src = base + `
42+design.thickness = @(x, y, r, theta) 1*mm + 0.5*mm * bump(r);
43+function v = bump(r)
44+v = exp(-(r/0.05).^2);
45+end
46+`;
47+ const d = compileDesign(src);
48+ const pl = evaluatePlate(d, 0.01);
49+ const g = pl.grid;
50+ const center = pl.h[Math.floor(g.ny / 2) * g.nx + Math.floor(g.nx / 2)];
51+ assert.ok(center > 1.3e-3 && center <= 1.5e-3, `center thickness ${center}`);
52+ assert.ok(pl.h[0] < 1.01e-3);
53+});
54+
55+test("a non-vectorized handle is reported", () => {
56+ const d = compileDesign(base + "design.thickness = @(x, y, r, theta) [1 2 3]*mm;");
57+ assert.throws(() => evaluatePlate(d, 0.01), /elementwise/);
58+});
59+
60+test("a disc with a hole is masked at the cell centers", () => {
61+ const d = compileDesign(base + "design.bounds.width = 0.2; design.bounds.height = 0.2; design.inside = @(x, y, r, theta) (r <= 0.1) & (r >= 0.02);");
62+ const pl = evaluatePlate(d, 0.005);
63+ const g = pl.grid;
64+ const ci = Math.floor(g.nx / 2), cj = Math.floor(g.ny / 2);
65+ assert.equal(insideNode(g, ci, cj), false, "hole center is outside");
66+ assert.equal(insideNode(g, 1, 1), false, "bounding-box corner is outside");
67+ assert.equal(insideNode(g, ci, Math.round(cj + 0.06 / g.hy)), true);
68+ let area = 0;
69+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) area += nodeWeight(g, i, j) * g.hx * g.hy;
70+ const exact = Math.PI * (0.1 ** 2 - 0.02 ** 2);
71+ assert.ok(Math.abs(area / exact - 1) < 0.06, `area ratio ${area / exact}`);
72+});
73+
74+test("meshgrid orientation: x varies along i and y along j", () => {
75+ const d = compileDesign(base + "design.thickness = @(x, y, r, theta) 2*mm + x*mm*5 + y*mm*5;");
76+ const pl = evaluatePlate(d, 0.02);
77+ const g = pl.grid;
78+ const at = (i: number, j: number) => pl.h[j * g.nx + i];
79+ const expected = (i: number, j: number) => 2e-3 + nodeX(g, i) * 5e-3 + nodeY(g, j) * 5e-3;
80+ assert.ok(Math.abs(at(3, 2) - expected(3, 2)) < 1e-12);
81+ assert.ok(Math.abs(at(7, 5) - expected(7, 5)) < 1e-12);
82+});
83+
84+test("the profile's mean plane is removed", () => {
85+ const d = compileDesign(base + "design.profile = @(x, y, r, theta) 0.01 + 0.1*x - 0.05*y + 0.002*x.^2;");
86+ const pl = evaluatePlate(d, 0.01);
87+ const g = pl.grid;
88+ let sw = 0, sx = 0, sy = 0;
89+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) {
90+ if (!insideNode(g, i, j)) continue;
91+ const w = pl.w0[j * g.nx + i] * nodeWeight(g, i, j);
92+ sw += w; sx += w * nodeX(g, i); sy += w * nodeY(g, j);
93+ }
94+ assert.ok(Math.abs(sx) < 1e-9 && Math.abs(sy) < 1e-9 && Math.abs(sw) < 1e-9, `${sw} ${sx} ${sy}`);
95+ assert.equal(isFlat(pl), false);
96+ const f = new Float64Array(g.n);
97+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] = 1 + 2 * nodeX(g, i) - 3 * nodeY(g, j);
98+ removePlane(f, g);
99+ assert.ok(f.every((v) => Math.abs(v) < 1e-12));
100+});
101+
102+test("the stand: pads sum to the annulus stiffness, preloads carry the weight", () => {
103+ const d = compileDesign(base + `
104+design.bounds.width = 0.3; design.bounds.height = 0.3;
105+design.inside = @(x, y, r, theta) (r <= 0.15) & (r >= 0.0065);
106+design.stand.pad.innerRadius = 7*mm; design.stand.pad.outerRadius = 20*mm; design.stand.pad.thickness = 10*mm;
107+design.stand.pad.E = 1e6; design.stand.pad.lossFactor = 0.2; design.stand.preload = 0.5*mm;
108+`);
109+ assert.ok(d.stand && d.stand.topPad);
110+ const pl = evaluatePlate(d, 0.005);
111+ const sr = standRates(pl)!;
112+ const annulus = Math.PI * (0.02 ** 2 - 0.007 ** 2);
113+ const kPad = (1e6 / 0.01) * annulus;
114+ assert.ok(Math.abs(sr.totalKw / (2 * kPad) - 1) < 1e-9, `total ${sr.totalKw} vs ${2 * kPad}`);
115+ const { mass } = massProperties(pl);
116+ assert.ok(Math.abs(sr.preloadBottom - (0.5e-3 + (mass * 9.81) / kPad)) < 1e-12);
117+ assert.equal(sr.preloadTop, 0.5e-3);
118+ assert.ok(Math.abs(sr.bounceHz - Math.sqrt(sr.totalKw / mass) / (2 * Math.PI)) < 1e-9);
119+ const d2 = compileDesign(base + "design.stand.topPad = false;");
120+ const pl2 = evaluatePlate(d2, 0.01);
121+ const sr2 = standRates(pl2)!;
122+ assert.equal(sr2.preloadTop, 0);
123+ assert.ok(sr2.totalKw > 0);
124+});
125+
126+test("losses: constant, table, and handle forms", () => {
127+ const c = compileDesign(base + "design.loss.T60 = 2.5;");
128+ assert.ok(Math.abs(t60At(c.lossConst!, 3000) - 2.5) < 1e-12);
129+ const t = compileDesign(base + "design.loss.T60 = [100 4; 10000 1];");
130+ assert.ok(Math.abs(t60At(t.lossConst!, 100) / 4 - 1) < 1e-2);
131+ assert.ok(Math.abs(t60At(t.lossConst!, 1000) / 2 - 1) < 1e-3, "log-log midpoint");
132+ assert.ok(Math.abs(t60At(t.lossConst!, 20) / 4 - 1) < 1e-9, "clamped below");
133+ const h = compileDesign(base + "design.loss.T60 = @(f) 3 ./ (1 + f/1000);");
134+ assert.equal(h.lossConst, null);
135+ const pl = evaluatePlate(h, 0.02);
136+ assert.ok(Math.abs(t60At(pl.loss, 1000) / 1.5 - 1) < 1e-3);
137+ assert.equal(pl.loss.freqs.length, LOSS_FREQS.length);
138+ assert.ok(Math.abs(decayRate(pl.loss, 1000) / (LN1000 / 1.5) - 1) < 1e-3);
139+ const bad = compileDesign(base + "design.loss.T60 = @(f) f - 5000;");
140+ assert.throws(() => evaluatePlate(bad, 0.02), /must be positive/);
141+});
142+
143+test("the sigma fit reproduces a pure sigma0 + sigma1 k^2 curve", () => {
144+ const mat = { E: 200e9, nu: 0.3, rho: 7850 }, h = 1e-3;
145+ const kappa = Math.sqrt((mat.E * h ** 3) / (12 * (1 - mat.nu ** 2)) / (mat.rho * h));
146+ const s0 = 1.2, s1 = 4e-4;
147+ const loss = lossFromFunction((f) => LN1000 / (s0 + (s1 * 2 * Math.PI * f) / kappa));
148+ const fit = fitSigma(loss, mat, h);
149+ assert.ok(Math.abs(fit.sigma0 / s0 - 1) < 1e-3 && Math.abs(fit.sigma1 / s1 - 1) < 1e-3, `${fit.sigma0} ${fit.sigma1}`);
150+ assert.ok(Math.abs(fittedRate(fit, 2000) / (s0 + (s1 * 2 * Math.PI * 2000) / kappa) - 1) < 1e-3);
151+ const flat = fitSigma(constantLoss(2), mat, h);
152+ assert.ok(flat.sigma1 >= 0 && flat.sigma0 >= 0);
153+ const tab = lossFromTable([1000, 100], [1, 2]);
154+ // The kink of the table at 100 Hz is smoothed a little by the sampling on LOSS_FREQS.
155+ assert.ok(Math.abs(t60At(tab, 100) / 2 - 1) < 1e-2, "table sorted by frequency");
156+});
157+
158+test("every preset compiles and evaluates on the default grid", () => {
159+ for (const p of PRESETS) {
160+ const d = compileDesign(p.source);
161+ const pl = evaluatePlate(d, 0.008);
162+ assert.ok(countInside(pl.grid) > 100, p.id);
163+ assert.ok(massProperties(pl).mass > 0.1, p.id);
164+ }
165+});
tests/gpu.test.tsadded+184−0View file
@@ -0,0 +1,184 @@
1+// Browser tests: serve the app with Vite's dev server, open it in a headless
2+// Chromium with WebGPU through Playwright, and drive it through the hooks
3+// on window.cymbalsim: the in-page self-test of the WGSL kernel against the
4+// float64 reference, a linear synthesis, a short full run, and a light hit
5+// simulated both ways, whose signals must agree.
6+
7+import { test } from "node:test";
8+import assert from "node:assert/strict";
9+import { fileURLToPath } from "node:url";
10+import { dirname, join } from "node:path";
11+import { createServer } from "vite";
12+import { chromium, type Page } from "playwright";
13+
14+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
15+const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"];
16+
17+interface Status {
18+ design: string | null;
19+ modes: { count: number; missing: number; first: number[] } | null;
20+ synthesized: boolean;
21+ running: boolean;
22+ samples: number;
23+ frames: number;
24+ source: string | null;
25+ peakPressure: number;
26+ sim: { phase: string; t: number; totalSteps: number; stick: { maxForce: number; withdrawn: boolean }; wmax: number } | null;
27+ contact: { peakForce: number; contactTime: number } | null;
28+ editorStatus: string;
29+}
30+
31+async function withPage(query: string, body: (page: Page, errors: string[]) => Promise<void>): Promise<void> {
32+ const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } });
33+ await server.listen();
34+ const url = server.resolvedUrls?.local[0];
35+ assert.ok(url, "dev server URL");
36+ const browser = await chromium.launch({ headless: true, args: ARGS });
37+ try {
38+ const page = await browser.newPage();
39+ const errors: string[] = [];
40+ page.on("pageerror", (e) => errors.push(e.message));
41+ page.on("console", (msg) => { if (msg.type() === "error") errors.push(`console.error: ${msg.text()}`); });
42+ await page.goto(`${url}?${query}`);
43+ await page.waitForFunction(() => (window as unknown as { cymbalsim?: unknown }).cymbalsim !== undefined, null, { timeout: 30000 });
44+ await body(page, errors);
45+ } finally {
46+ await browser.close();
47+ await server.close();
48+ }
49+}
50+
51+const status = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { status: () => Status } }).cymbalsim.status());
52+const hasGpu = (page: Page) => page.evaluate(() => (window as unknown as { cymbalsim: { app: { device: unknown } } }).cymbalsim.app.device !== null);
53+
54+test("the WGSL step agrees with the float64 reference on several presets", async () => {
55+ await withPage("preset=flat-rectangle&nogpuview=0", async (page, errors) => {
56+ if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
57+ const text = await page.evaluate(() => (window as unknown as { cymbalsim: { selftest: () => Promise<string> } }).cymbalsim.selftest());
58+ console.log(text);
59+ for (const line of text.split("\n")) {
60+ const m = /max relative error ([0-9.e+-]+)/.exec(line);
61+ assert.ok(m, line);
62+ assert.ok(Number(m![1]) < 2e-4, line);
63+ }
64+ assert.deepEqual(errors, []);
65+ });
66+});
67+
68+test("the linear model computes the modes and synthesizes a strike", async () => {
69+ await withPage("preset=flat-disc&duration=1", async (page, errors) => {
70+ await page.evaluate(() => (window as unknown as { cymbalsim: { modesReady: () => Promise<void> } }).cymbalsim.modesReady());
71+ let st = await status(page);
72+ assert.equal(st.design, "Flat bronze disc");
73+ assert.ok(st.modes && st.modes.count > 100, `modes ${st.modes?.count}`);
74+ assert.equal(st.modes!.missing, 0);
75+ assert.ok(st.modes!.first[0] > 1 && st.modes!.first[0] < 200, `first mode ${st.modes!.first[0]} Hz (the rock or the bounce on the stand)`);
76+ await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
77+ st = await status(page);
78+ assert.ok(st.synthesized);
79+ assert.equal(st.source, "linear");
80+ assert.equal(st.samples, 48000);
81+ assert.equal(st.frames, 120);
82+ assert.ok(st.peakPressure > 0.1 && st.peakPressure < 1000, `peak ${st.peakPressure} Pa`);
83+ assert.ok(st.contact && st.contact.peakForce > 10);
84+ assert.deepEqual(errors, []);
85+ });
86+});
87+
88+test("a script with an error is reported", async () => {
89+ await withPage("preset=ellipse", async (page) => {
90+ await page.evaluate(() => {
91+ const app = (window as unknown as { cymbalsim: { app: { editor: { setSource: (s: string) => void }; onSource: (s: string) => void } } }).cymbalsim.app;
92+ app.editor.setSource("design.material.E = 1;");
93+ (app as unknown as { onSource: (s: string) => void }).onSource("design.material.E = 1;");
94+ });
95+ const st = await status(page);
96+ assert.match(st.editorStatus, /design.material.E/);
97+ });
98+});
99+
100+test("the full simulation runs a short strike on a free plate and on a stand", async () => {
101+ await withPage("preset=bowed-rectangle&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
102+ if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
103+ await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
104+ const st = await status(page);
105+ assert.equal(st.source, "full");
106+ assert.ok(st.sim && st.sim.phase === "done");
107+ assert.equal(st.samples, 2400);
108+ assert.ok(st.sim!.stick.maxForce > 10, `force ${st.sim!.stick.maxForce}`);
109+ assert.ok(st.sim!.wmax > 1e-5 && st.sim!.wmax < 0.05, `wmax ${st.sim!.wmax}`);
110+ assert.ok(st.peakPressure > 0.01 && isFinite(st.peakPressure));
111+ assert.deepEqual(errors, []);
112+ });
113+ await withPage("preset=crash-16&mode=full&duration=0.05&spacing=0.012", async (page, errors) => {
114+ if (!(await hasGpu(page))) return;
115+ await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
116+ const st = await status(page);
117+ assert.ok(st.sim && st.sim.phase === "done");
118+ assert.ok(st.sim!.stick.maxForce > 10);
119+ assert.ok(isFinite(st.peakPressure) && st.peakPressure > 0.01);
120+ assert.deepEqual(errors, []);
121+ });
122+});
123+
124+test("a light hit sounds the same in the linear and the full model", async () => {
125+ await withPage("preset=flat-disc&duration=0.25&strikeVelocity=0.05&spacing=0.012&fps=60", async (page, errors) => {
126+ if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
127+ const hooks = "(window as unknown as { cymbalsim: any }).cymbalsim";
128+ await page.evaluate(() => (window as unknown as { cymbalsim: { modesReady: () => Promise<void> } }).cymbalsim.modesReady());
129+ await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
130+ const linear = await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
131+ await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
132+ const full = await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
133+ void hooks;
134+ assert.equal(linear.length, full.length);
135+ // Compare the spectra over 50 to 250 ms (after the attack, which the delay interpolation renders slightly differently).
136+ const fs = 48000, n0 = Math.round(0.05 * fs), n1 = Math.round(0.25 * fs);
137+ const N = 8192;
138+ const spec = (x: number[]) => {
139+ const re = new Float64Array(N), im = new Float64Array(N);
140+ for (let i = 0; i < N && n0 + i < n1; i++) re[i] = x[n0 + i] * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N));
141+ // naive DFT on a subset of bins is too slow; use a simple radix-2 FFT here
142+ fftInPlace(re, im);
143+ const mag = new Float64Array(N / 2);
144+ for (let k = 0; k < N / 2; k++) mag[k] = Math.hypot(re[k], im[k]);
145+ return mag;
146+ };
147+ const a = spec(linear), b = spec(full);
148+ // Normalized correlation of the magnitude spectra below 6 kHz.
149+ const kmax = Math.round((6000 / fs) * N);
150+ let ab = 0, aa = 0, bb = 0;
151+ for (let k = 1; k < kmax; k++) { ab += a[k] * b[k]; aa += a[k] * a[k]; bb += b[k] * b[k]; }
152+ const corr = ab / Math.sqrt(aa * bb);
153+ // Level agreement: RMS over the window.
154+ const rms = (x: number[]) => Math.sqrt(x.slice(n0, n1).reduce((s, v) => s + v * v, 0) / (n1 - n0));
155+ const ratio = rms(linear) / rms(full);
156+ console.log(`spectral correlation ${corr.toFixed(3)}, rms ratio linear/full ${ratio.toFixed(3)}`);
157+ assert.ok(corr > 0.9, `spectral correlation ${corr}`);
158+ assert.ok(ratio > 0.7 && ratio < 1.4, `rms ratio ${ratio}`);
159+ assert.deepEqual(errors, []);
160+ });
161+});
162+
163+function fftInPlace(re: Float64Array, im: Float64Array): void {
164+ const n = re.length;
165+ for (let i = 1, j = 0; i < n; i++) {
166+ let bit = n >> 1;
167+ for (; j & bit; bit >>= 1) j ^= bit;
168+ j ^= bit;
169+ if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
170+ }
171+ for (let len = 2; len <= n; len <<= 1) {
172+ const ang = (-2 * Math.PI) / len;
173+ for (let i = 0; i < n; i += len) {
174+ for (let k = 0; k < len / 2; k++) {
175+ const c = Math.cos(ang * k), s = Math.sin(ang * k);
176+ const ur = re[i + k], ui = im[i + k];
177+ const vr = re[i + k + len / 2] * c - im[i + k + len / 2] * s;
178+ const vi = re[i + k + len / 2] * s + im[i + k + len / 2] * c;
179+ re[i + k] = ur + vr; im[i + k] = ui + vi;
180+ re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi;
181+ }
182+ }
183+ }
184+}
tests/modal.test.tsadded+180−0View file
@@ -0,0 +1,180 @@
1+// The eigen solver: banded factorization and inertia, Lanczos slices against
2+// a dense Jacobi solve on small grids (with degenerate pairs), and the
3+// free-plate frequencies against the reference values.
4+import { test } from "node:test";
5+import assert from "node:assert/strict";
6+import { makeGrid, withMask, type Plate } from "../src/params.ts";
7+import { uniformPlate } from "../src/model/reference.ts";
8+import { assemble, stiffnessEntry, type LinearSystem } from "../src/model/assemble.ts";
9+import { factorize, solve, inertia } from "../src/modal/banded.ts";
10+import { tridiagonalEigen, sliceModes } from "../src/modal/lanczos.ts";
11+import { computeModes, localRunner } from "../src/modal/modes.ts";
12+
13+const steel = { E: 200e9, nu: 0.3, rho: 7850 };
14+
15+/** All eigenvalues of M^{-1/2} K M^{-1/2} by cyclic Jacobi (small systems only). */
16+function denseEigenvalues(sys: LinearSystem): number[] {
17+ const n = sys.n;
18+ const A: number[][] = [];
19+ for (let i = 0; i < n; i++) {
20+ A.push([]);
21+ for (let j = 0; j < n; j++) A[i].push(stiffnessEntry(sys, i, j) / Math.sqrt(sys.mass[i] * sys.mass[j]));
22+ }
23+ for (let sweep = 0; sweep < 60; sweep++) {
24+ let off = 0;
25+ for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) off += A[i][j] ** 2;
26+ if (off < 1e-24 * n * n) break;
27+ for (let p = 0; p < n; p++) {
28+ for (let q = p + 1; q < n; q++) {
29+ if (Math.abs(A[p][q]) < 1e-300) continue;
30+ const theta = (A[q][q] - A[p][p]) / (2 * A[p][q]);
31+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
32+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
33+ for (let k = 0; k < n; k++) {
34+ const akp = A[k][p], akq = A[k][q];
35+ A[k][p] = c * akp - s * akq; A[k][q] = s * akp + c * akq;
36+ }
37+ for (let k = 0; k < n; k++) {
38+ const apk = A[p][k], aqk = A[q][k];
39+ A[p][k] = c * apk - s * aqk; A[q][k] = s * apk + c * aqk;
40+ }
41+ }
42+ }
43+ }
44+ return Array.from({ length: n }, (_, i) => A[i][i]).sort((a, b) => a - b);
45+}
46+
47+test("the banded LDL^T solves and counts eigenvalues", () => {
48+ const sys = assemble(uniformPlate(makeGrid(0.1, 0.08, 0.01), steel, 1e-3));
49+ const shift = -((2 * Math.PI * 50) ** 2);
50+ const f = factorize(sys.band, sys.mass, sys.n, sys.bw, shift);
51+ assert.equal(f.negative, 0, "K + 50 Hz shift is positive definite");
52+ const b = new Float64Array(sys.n).map((_, i) => Math.sin(i));
53+ const x = new Float64Array(sys.n);
54+ solve(f, b, x);
55+ // Check (K - shift M) x = b with the banded matvec.
56+ const { stiffnessMatvec } = require_assemble();
57+ const y = new Float64Array(sys.n);
58+ stiffnessMatvec(sys, x, y);
59+ let err = 0, nrm = 0;
60+ for (let i = 0; i < sys.n; i++) { const r = y[i] - shift * sys.mass[i] * x[i] - b[i]; err += r * r; nrm += b[i] * b[i]; }
61+ assert.ok(Math.sqrt(err / nrm) < 1e-9);
62+ const dense = denseEigenvalues(sys);
63+ for (const f of [30, 100, 300, 1000]) {
64+ const lam = (2 * Math.PI * f) ** 2;
65+ const expected = dense.filter((v) => v < lam).length;
66+ assert.equal(inertia(sys.band, sys.mass, sys.n, sys.bw, lam), expected, `count below ${f} Hz`);
67+ }
68+});
69+
70+import * as assembleModule from "../src/model/assemble.ts";
71+function require_assemble(): typeof assembleModule { return assembleModule; }
72+
73+test("the tridiagonal eigensolver agrees with a dense solve", () => {
74+ const d = [4, 3, 2, 5, 1, 3, 3], e = [1, 0.5, 2, 0.1, 1, 1];
75+ const { values, vectors } = tridiagonalEigen(d, e);
76+ const n = d.length;
77+ for (let k = 0; k < n; k++) {
78+ // T v = lambda v
79+ for (let i = 0; i < n; i++) {
80+ const tv = d[i] * vectors[k][i] + (i > 0 ? e[i - 1] * vectors[k][i - 1] : 0) + (i < n - 1 ? e[i] * vectors[k][i + 1] : 0);
81+ assert.ok(Math.abs(tv - values[k] * vectors[k][i]) < 1e-10);
82+ }
83+ }
84+ for (let k = 1; k < n; k++) assert.ok(values[k] >= values[k - 1]);
85+});
86+
87+test("all modes of a small free square and a small disc match a dense solve, degenerate pairs included", async () => {
88+ const cases: { name: string; pl: Plate }[] = [
89+ { name: "square", pl: uniformPlate(makeGrid(0.1, 0.1, 0.1 / 9), steel, 1e-3) },
90+ { name: "disc", pl: uniformPlate(withMask(makeGrid(0.12, 0.12, 0.01), (x, y) => x * x + y * y <= 0.06 ** 2), steel, 1e-3) },
91+ ];
92+ for (const c of cases) {
93+ const sys = assemble(c.pl);
94+ const dense = denseEigenvalues(sys).filter((v) => v > (2 * Math.PI) ** 2);
95+ const fMax = Math.sqrt(dense[dense.length - 1]) / (2 * Math.PI) * 1.01;
96+ const ms = await computeModes(sys, fMax, localRunner(sys), { sliceSize: 12 });
97+ assert.equal(ms.missing, 0, `${c.name}: missing modes`);
98+ assert.equal(ms.count, dense.length, `${c.name}: ${ms.count} modes vs ${dense.length} dense`);
99+ for (let m = 0; m < ms.count; m++) {
100+ const lam = (2 * Math.PI * ms.freqs[m]) ** 2;
101+ assert.ok(Math.abs(lam / dense[m] - 1) < 1e-7, `${c.name}: mode ${m} ${lam} vs ${dense[m]}`);
102+ }
103+ // Mass-normalized shapes: phi' M phi = 1, and K phi = lambda M phi.
104+ const { stiffnessMatvec } = require_assemble();
105+ const y = new Float64Array(sys.n);
106+ for (const m of [0, 1, Math.floor(ms.count / 2), ms.count - 1]) {
107+ const phi = Float64Array.from(ms.shapes.subarray(m * sys.n, (m + 1) * sys.n));
108+ let mn = 0;
109+ for (let i = 0; i < sys.n; i++) mn += sys.mass[i] * phi[i] * phi[i];
110+ assert.ok(Math.abs(mn - 1) < 1e-5, `${c.name}: mass norm ${mn}`);
111+ stiffnessMatvec(sys, phi, y);
112+ const lam = (2 * Math.PI * ms.freqs[m]) ** 2;
113+ let r = 0, s = 0;
114+ for (let i = 0; i < sys.n; i++) { r += (y[i] - lam * sys.mass[i] * phi[i]) ** 2; s += (lam * sys.mass[i] * phi[i]) ** 2; }
115+ // The stored shapes are float32, which the stiffness amplifies by omega_max^2 / omega^2.
116+ assert.ok(Math.sqrt(r / s) < 1e-3, `${c.name}: residual ${Math.sqrt(r / s)}`);
117+ }
118+ // The float64 shapes of one slice satisfy the eigen equation to rounding.
119+ const lo = (2 * Math.PI) ** 2, hi = dense[Math.min(9, dense.length - 1)] * 1.0001;
120+ const slice = sliceModes(sys.band, sys.mass, sys.n, sys.bw, lo, hi, inertia(sys.band, sys.mass, sys.n, sys.bw, lo), inertia(sys.band, sys.mass, sys.n, sys.bw, hi));
121+ assert.equal(slice.lambda.length, slice.expected);
122+ for (let m = 0; m < slice.lambda.length; m++) {
123+ stiffnessMatvec(sys, slice.shapes[m], y);
124+ let r = 0, s = 0;
125+ for (let i = 0; i < sys.n; i++) { r += (y[i] - slice.lambda[m] * sys.mass[i] * slice.shapes[m][i]) ** 2; s += (slice.lambda[m] * sys.mass[i] * slice.shapes[m][i]) ** 2; }
126+ assert.ok(Math.sqrt(r / s) < 1e-6, `${c.name}: float64 residual ${Math.sqrt(r / s)}`);
127+ }
128+ // The disc has degenerate pairs: the first two frequencies agree closely.
129+ if (c.name === "disc") assert.ok(Math.abs(ms.freqs[1] / ms.freqs[0] - 1) < 0.02, "degenerate pair");
130+ }
131+});
132+
133+test("free square plate frequencies converge to Gorman's values", async () => {
134+ // lambda = omega a^2 sqrt(rho h / D) for the completely free square (nu = 0.3).
135+ const gorman = [13.468, 19.596, 24.271, 34.801, 34.801, 61.093, 61.093];
136+ const a = 0.3, h = 1e-3;
137+ const D = (steel.E * h ** 3) / (12 * (1 - steel.nu ** 2));
138+ const scale = a * a * Math.sqrt((steel.rho * h) / D);
139+ const errors: number[] = [];
140+ for (const cells of [10, 20]) {
141+ const sys = assemble(uniformPlate(makeGrid(a, a, a / cells), steel, h));
142+ const ms = await computeModes(sys, (80 / scale) / (2 * Math.PI), localRunner(sys), { sliceSize: 20 });
143+ let worst = 0;
144+ for (let m = 0; m < 7; m++) worst = Math.max(worst, Math.abs((2 * Math.PI * ms.freqs[m] * scale) / gorman[m] - 1));
145+ errors.push(worst);
146+ }
147+ assert.ok(errors[0] < 0.1, `10 cells: ${errors[0]}`);
148+ assert.ok(errors[1] < 0.025, `20 cells: ${errors[1]}`);
149+ assert.ok(errors[1] < errors[0]);
150+});
151+
152+test("free circular plate frequencies are close to Leissa's values", async () => {
153+ // lambda^2 = omega a^2 sqrt(rho h / D), completely free disc, nu = 0.3: (2,0) 5.253, (0,1) 9.084, (3,0) 12.23, (1,1) 20.52, (4,0) 21.6.
154+ const leissa = [5.253, 5.253, 9.084, 12.23, 12.23, 20.52, 20.52, 21.6, 21.6];
155+ const a = 0.15, h = 1e-3;
156+ const D = (steel.E * h ** 3) / (12 * (1 - steel.nu ** 2));
157+ const scale = a * a * Math.sqrt((steel.rho * h) / D);
158+ const g = withMask(makeGrid(2 * a, 2 * a, (2 * a) / 40), (x, y) => x * x + y * y <= a * a);
159+ const sys = assemble(uniformPlate(g, steel, h));
160+ const ms = await computeModes(sys, (25 / scale) / (2 * Math.PI), localRunner(sys), { sliceSize: 20 });
161+ for (let m = 0; m < leissa.length; m++) {
162+ const lam = 2 * Math.PI * ms.freqs[m] * scale;
163+ assert.ok(Math.abs(lam / leissa[m] - 1) < 0.03, `mode ${m}: ${lam} vs ${leissa[m]}`);
164+ }
165+});
166+
167+test("a plate on a stand has no rigid modes and bounces at the pad frequency", async () => {
168+ const g = withMask(makeGrid(0.2, 0.2, 0.01), (x, y) => x * x + y * y <= 0.1 ** 2 && x * x + y * y >= 0.01 ** 2);
169+ const pl: Plate = { ...uniformPlate(g, steel, 2e-3), stand: { x: 0, y: 0, pad: { innerRadius: 0.012, outerRadius: 0.03, thickness: 0.01, E: 0.3e6, lossFactor: 0.3 }, topPad: true, preload: 1e-3 } };
170+ const sys = assemble(pl);
171+ const ms = await computeModes(sys, 400, localRunner(sys), { sliceSize: 20 });
172+ const { standRates, massProperties } = await import("../src/params.ts");
173+ const sr = standRates(pl)!;
174+ const { mass } = massProperties(pl);
175+ const bounce = Math.sqrt(sr.totalKw / mass) / (2 * Math.PI);
176+ // The lowest modes are the two rocking modes and the bounce; the bounce is close to sqrt(K/M).
177+ assert.ok(ms.freqs[0] > 1, "no zero-frequency mode");
178+ const near = Array.from(ms.freqs).some((f) => Math.abs(f / bounce - 1) < 0.15);
179+ assert.ok(near, `bounce ${bounce} Hz among ${Array.from(ms.freqs.slice(0, 5)).map((f) => f.toFixed(1)).join(", ")}`);
180+});
tests/reference.test.tsadded+178−0View file
@@ -0,0 +1,178 @@
1+// The float64 reference discretization: symmetry and rigid modes of the
2+// linear stiffness, forces as the gradient of the energy, dissipativity,
3+// thickness scaling, the stand, and the free-plate frequencies.
4+import { test } from "node:test";
5+import assert from "node:assert/strict";
6+import { makeGrid, withMask, type Grid, type Plate, nodeMasses, standRates, insideNode, nodeX, nodeY, bendingStiffness } from "../src/params.ts";
7+import { uniformPlate, accelerations, addDamping, potentialEnergy, fields, maxAngularFrequency, type Fields } from "../src/model/reference.ts";
8+import { assemble, stiffnessEntry } from "../src/model/assemble.ts";
9+import { fullLoss } from "../src/model/reference.ts";
10+
11+const steel = { E: 200e9, nu: 0.3, rho: 7850 };
12+
13+function rnd(seed: number): () => number {
14+ let s = seed >>> 0;
15+ return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 - 0.5; };
16+}
17+function randomFields(pl: Plate, amp: number, seed: number): Fields {
18+ const g = pl.grid, r = rnd(seed), f = fields(g.n);
19+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) {
20+ if (!insideNode(g, i, j)) continue;
21+ const k = j * g.nx + i;
22+ f.u[k] = 0.02 * amp * r(); f.v[k] = 0.02 * amp * r(); f.w[k] = amp * r();
23+ }
24+ return f;
25+}
26+function bowed(g: Grid, sag: number): Float64Array {
27+ const w0 = new Float64Array(g.n);
28+ const Lx = (g.nx - 1) * g.hx;
29+ for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) w0[j * g.nx + i] = sag * (1 - (2 * nodeX(g, i) / Lx) ** 2) + 0.3 * sag * Math.sin(3 * nodeY(g, j) / Lx);
30+ return w0;
31+}
32+function withStand(pl: Plate): Plate {
33+ return { ...pl, stand: { x: 0, y: 0, pad: { innerRadius: 0.005, outerRadius: 0.03, thickness: 0.01, E: 1e6, lossFactor: 0.3 }, topPad: true, preload: 1e-3 } };
34+}
35+
36+test("the linear stiffness is symmetric with exactly three rigid modes per block (uniform, tapered, disc with hole)", () => {
37+ const cases: { name: string; pl: Plate; fields: 1 | 3 }[] = [];
38+ const g1 = makeGrid(0.12, 0.09, 0.015);
39+ cases.push({ name: "uniform rectangle", pl: uniformPlate(g1, steel, 1e-3), fields: 3 });
40+ const tapered = uniformPlate(g1, steel, 1e-3);
41+ for (let k = 0; k < g1.n; k++) if (tapered.h[k] > 0) tapered.h[k] = 1e-3 * (1 + 0.5 * (k % g1.nx) / g1.nx);
42+ cases.push({ name: "tapered rectangle", pl: tapered, fields: 3 });
43+ const gd = withMask(makeGrid(0.16, 0.16, 0.01), (x, y) => x * x + y * y <= 0.08 ** 2 && x * x + y * y >= 0.015 ** 2);
44+ cases.push({ name: "disc with hole", pl: uniformPlate(gd, steel, 1e-3), fields: 1 });
45+ for (const c of cases) {
46+ const sys = assemble(c.pl, c.fields);
47+ // Symmetry is built into the banded storage; check the rigid modes: K x = 0 for translations and rotations.
48+ const g = c.pl.grid;
49+ const nullVectors: Float64Array[] = [];
50+ const mk = (fn: (i: number, j: number, comp: number) => number) => {
51+ const x = new Float64Array(sys.n);
52+ for (let d = 0; d < sys.n; d++) {
53+ const k = sys.dofNode[d];
54+ const comp = sys.fields === 1 ? 2 : d % 3;
55+ x[d] = fn(k % g.nx, Math.floor(k / g.nx), comp);
56+ }
57+ return x;
58+ };
59+ nullVectors.push(mk((_i, _j, c) => (c === 2 ? 1 : 0)));
60+ nullVectors.push(mk((i, _j, c) => (c === 2 ? nodeX(g, i) : 0)));
61+ nullVectors.push(mk((_i, j, c) => (c === 2 ? nodeY(g, j) : 0)));
62+ if (c.fields === 3) {
63+ nullVectors.push(mk((_i, _j, c) => (c === 0 ? 1 : 0)));
64+ nullVectors.push(mk((_i, _j, c) => (c === 1 ? 1 : 0)));
65+ nullVectors.push(mk((i, j, c) => (c === 0 ? -nodeY(g, j) : c === 1 ? nodeX(g, i) : 0)));
66+ }
67+ const y = new Float64Array(sys.n);
68+ for (const x of nullVectors) {
69+ const { stiffnessMatvec } = require_assemble();
70+ stiffnessMatvec(sys, x, y);
71+ let ny = 0, nx = 0;
72+ for (let d = 0; d < sys.n; d++) { ny += y[d] * y[d]; nx += x[d] * x[d]; }
73+ const scale = Math.abs(stiffnessEntry(sys, 0, 0)) * Math.sqrt(nx);
74+ assert.ok(Math.sqrt(ny) < 1e-9 * scale, `${c.name}: rigid mode residual ${Math.sqrt(ny) / scale}`);
75+ }
76+ // A non-rigid vector must not be in the null space.
77+ const z = mk((i, j, c) => (c === 2 ? Math.sin(i * 0.9) * Math.cos(j * 1.1) : 0));
78+ const { stiffnessMatvec } = require_assemble();
79+ stiffnessMatvec(sys, z, y);
80+ assert.ok(y.some((v) => Math.abs(v) > 1e-3), `${c.name}: bending is not trivial`);
81+ }
82+});
83+
84+import * as assembleModule from "../src/model/assemble.ts";
85+function require_assemble(): typeof assembleModule { return assembleModule; }
86+
87+test("forces are the exact gradient of the discrete energy (flat and bowed, tapered, free and on a stand)", () => {
88+ const g = makeGrid(0.1, 0.08, 0.01);
89+ const variants: { name: string; pl: Plate }[] = [];
90+ variants.push({ name: "flat free", pl: uniformPlate(g, steel, 1e-3) });
91+ variants.push({ name: "bowed free", pl: uniformPlate(g, steel, 1e-3, bowed(g, 3e-3)) });
92+ const tapered = uniformPlate(g, steel, 1e-3, bowed(g, 2e-3));
93+ for (let k = 0; k < g.n; k++) if (tapered.h[k] > 0) tapered.h[k] = 1e-3 * (1 + 0.4 * (k % g.nx) / g.nx);
94+ variants.push({ name: "bowed tapered", pl: tapered });
95+ variants.push({ name: "bowed on a stand", pl: withStand(uniformPlate(g, steel, 1e-3, bowed(g, 3e-3))) });
96+ for (const v of variants) {
97+ const pl = v.pl;
98+ const sr = standRates(pl);
99+ const f = randomFields(pl, 2.5e-3, 7);
100+ const masses = nodeMasses(pl);
101+ const acc = fields(g.n);
102+ accelerations(pl, f, acc, false, sr);
103+ const dir = randomFields(pl, 1, 11);
104+ // Directional derivative of U along dir versus -F . dir.
105+ const eps = 1e-7;
106+ const shift = (s: number): Fields => ({ u: f.u.map((x, k) => x + s * dir.u[k]), v: f.v.map((x, k) => x + s * dir.v[k]), w: f.w.map((x, k) => x + s * dir.w[k]) });
107+ const dU = (potentialEnergy(pl, shift(eps), sr) - potentialEnergy(pl, shift(-eps), sr)) / (2 * eps);
108+ let fd = 0;
109+ for (let k = 0; k < g.n; k++) fd -= masses[k] * (acc.u[k] * dir.u[k] + acc.v[k] * dir.v[k] + acc.w[k] * dir.w[k]);
110+ assert.ok(Math.abs(dU - fd) < 1e-6 * Math.abs(dU), `${v.name}: dU ${dU} vs -F.dir ${fd}`);
111+ }
112+});
113+
114+test("the loss terms and the pad dashpots only remove energy", () => {
115+ const g = makeGrid(0.1, 0.08, 0.01);
116+ const pl = withStand(uniformPlate(g, steel, 1e-3));
117+ const sr = standRates(pl);
118+ const loss = fullLoss({ sigma0: 1, sigma1: 5e-4, kappa: 1 }, sr, 20);
119+ const masses = nodeMasses(pl);
120+ for (const seed of [1, 2, 3]) {
121+ const vel = randomFields(pl, 1, seed);
122+ const acc = fields(g.n);
123+ addDamping(pl, loss, vel, acc, sr);
124+ let power = 0;
125+ for (let k = 0; k < g.n; k++) power += masses[k] * (acc.u[k] * vel.u[k] + acc.v[k] * vel.v[k] + acc.w[k] * vel.w[k]);
126+ assert.ok(power < 0, `power ${power}`);
127+ }
128+});
129+
130+test("doubling the thickness doubles the bending frequencies and leaves the membrane frequencies alone", () => {
131+ const g = makeGrid(0.1, 0.1, 0.01);
132+ const w1 = maxAngularFrequency(g, steel, 1e-3), w2 = maxAngularFrequency(g, steel, 2e-3);
133+ // The largest frequency is set by the in-plane waves, independent of h.
134+ assert.ok(Math.abs(w1 / w2 - 1) < 1e-6, `in-plane omega_max ${w1} vs ${w2}`);
135+ const { stiffnessEntry: ke } = assembleModule;
136+ const s1 = assemble(uniformPlate(g, steel, 1e-3)), s2 = assemble(uniformPlate(g, steel, 2e-3));
137+ const c = Math.floor(s1.n / 2);
138+ // Bending stiffness scales as h^3 and the mass as h: omega^2 scales as h^2.
139+ assert.ok(Math.abs(ke(s2, c, c) / ke(s1, c, c) - 8) < 1e-9);
140+ assert.ok(Math.abs(s2.mass[c] / s1.mass[c] - 2) < 1e-12);
141+ assert.ok(Math.abs(bendingStiffness(steel, 2e-3) / bendingStiffness(steel, 1e-3) - 8) < 1e-12);
142+});
143+
144+test("the stand resists a rigid translation with its total stiffness and a tilt with the pads' second moment", () => {
145+ const g = withMask(makeGrid(0.2, 0.2, 0.01), (x, y) => x * x + y * y <= 0.1 ** 2 && x * x + y * y >= 0.01 ** 2);
146+ const pl = withStand(uniformPlate(g, steel, 1e-3));
147+ const sr = standRates(pl)!;
148+ const masses = nodeMasses(pl);
149+ const f = fields(g.n), acc = fields(g.n);
150+ const dz = 1e-4;
151+ for (let k = 0; k < g.n; k++) f.w[k] = insideNode(g, k % g.nx, Math.floor(k / g.nx)) ? dz : 0;
152+ accelerations(pl, f, acc, false, sr);
153+ let F = 0;
154+ for (let k = 0; k < g.n; k++) F += masses[k] * acc.w[k];
155+ assert.ok(Math.abs(-F / dz / sr.totalKw - 1) < 1e-9, `translation stiffness ${-F / dz} vs ${sr.totalKw}`);
156+ // Tilt about y: w = theta x; restoring moment = theta * sum_k kw_k x_k^2 (bending of a plane is
157+ // zero; the von Karman membrane strain of a rotation is theta^2 / 2, so a tiny tilt keeps it negligible).
158+ const th = 1e-7;
159+ for (let k = 0; k < g.n; k++) f.w[k] = insideNode(g, k % g.nx, Math.floor(k / g.nx)) ? th * nodeX(g, k % g.nx) : 0;
160+ accelerations(pl, f, acc, false, sr);
161+ let M = 0, I2 = 0;
162+ for (let k = 0; k < g.n; k++) { M += masses[k] * acc.w[k] * nodeX(g, k % g.nx); I2 += sr.kw[k] * nodeX(g, k % g.nx) ** 2; }
163+ assert.ok(Math.abs(-M / th / I2 - 1) < 1e-6, `tilt stiffness ${-M / th} vs ${I2}`);
164+ // With lift-off, a small motion within the preload is the same linear spring; a large upward motion unloads the bottom pad.
165+ for (let k = 0; k < g.n; k++) f.w[k] = insideNode(g, k % g.nx, Math.floor(k / g.nx)) ? 1e-5 : 0;
166+ const lin = fields(g.n), lo = fields(g.n);
167+ accelerations(pl, f, lin, false, sr);
168+ accelerations(pl, f, lo, true, sr);
169+ for (let k = 0; k < g.n; k++) assert.ok(Math.abs(lin.w[k] - lo.w[k]) < 1e-9 * Math.max(1, Math.abs(lin.w[k])));
170+ for (let k = 0; k < g.n; k++) f.w[k] = insideNode(g, k % g.nx, Math.floor(k / g.nx)) ? 0.01 : 0;
171+ accelerations(pl, f, lo, true, sr);
172+ let Flo = 0;
173+ for (let k = 0; k < g.n; k++) Flo += masses[k] * lo.w[k];
174+ // Bottom pad unloaded: only the top pad (compressed by preload + 10 mm) pushes down, minus the rest balance.
175+ const kb = sr.totalKw / 2;
176+ const expected = -(kb * (sr.preloadTop + 0.01) - kb * sr.preloadTop) - kb * sr.preloadBottom;
177+ assert.ok(Math.abs(Flo / expected - 1) < 1e-6, `lift-off force ${Flo} vs ${expected}`);
178+});
tests/synth.test.tsadded+138−0View file
@@ -0,0 +1,138 @@
1+// The modal synthesis: a single mode struck by an impulse gives the closed
2+// form damped sinusoid; the radiation gain of a uniform piston equals the
3+// analytic value; the attack and the tail join; the sliced parts sum to the
4+// whole.
5+import { test } from "node:test";
6+import assert from "node:assert/strict";
7+import { makeGrid, defaultParams, type SimParams, listenerTerms, RHO_AIR, SPEED_OF_SOUND, nodeWeight } from "../src/params.ts";
8+import { uniformPlate } from "../src/model/reference.ts";
9+import { assemble } from "../src/model/assemble.ts";
10+import { computeModes, localRunner } from "../src/modal/modes.ts";
11+import { constantLoss, LN1000 } from "../src/loss.ts";
12+import {
13+ modalModel, modalRates, strikeGains, simulateContact, listenerWeights, radiationGains, tailStart, nodalAccelerationTable, attackPressure, modalTail, movieFrames, synthesize, sliceModel, sliceContact, type ModalModel,
14+} from "../src/modal/synth.ts";
15+
16+const steel = { E: 200e9, nu: 0.3, rho: 7850 };
17+
18+async function smallModel(): Promise<{ model: ModalModel; pl: ReturnType<typeof uniformPlate> }> {
19+ const g = makeGrid(0.2, 0.15, 0.0125);
20+ const pl = uniformPlate(g, steel, 1e-3);
21+ pl.loss = constantLoss(1.0);
22+ const sys = assemble(pl);
23+ const modes = await computeModes(sys, 4000, localRunner(sys), { sliceSize: 20 });
24+ return { model: modalModel(pl, sys, modes), pl };
25+}
26+
27+test("a single mode under an impulse follows the closed-form damped sinusoid", async () => {
28+ const { model, pl } = await smallModel();
29+ const p: SimParams = { ...defaultParams, fs: 48000, duration: 0.1, fps: 100 };
30+ // Keep only mode 0 by slicing the model.
31+ const one = sliceModel(model, 0, 1);
32+ const sigma = modalRates(one, pl.loss);
33+ const g = strikeGains(one, pl, p);
34+ const c = simulateContact(one, sigma, g, p, p.fps);
35+ assert.ok(c.peakForce > 0 && c.contactTime > 0);
36+ // After tEnd the mode is q(t) = Re[A exp(s (t - tEnd))]; check the movie frames against it.
37+ const nFrames = 10;
38+ const frames = movieFrames(one, sigma, c, p.fps, nFrames);
39+ const w = 2 * Math.PI * one.freqs[0], s = sigma[0], wd = Math.sqrt(w * w - s * s);
40+ for (let f = 1; f < nFrames; f++) {
41+ const t = f / p.fps - c.tEnd;
42+ if (t < 0) continue;
43+ const q = Math.exp(-s * t) * (c.aRe[0] * Math.cos(wd * t) - c.aIm[0] * Math.sin(wd * t));
44+ // Compare at a node away from the edge.
45+ const k = Math.floor(pl.grid.ny / 2) * pl.grid.nx + Math.floor(pl.grid.nx / 3);
46+ const phi = one.wShapes[one.wRow[k] * one.count];
47+ assert.ok(Math.abs(frames[f * one.nGrid + k] - phi * q) < 1e-9 + 1e-6 * Math.abs(phi * q));
48+ }
49+ assert.ok(Math.abs(sigma[0] - LN1000 / 1.0) < 1e-9, "decay rate from T60 = 1 s");
50+});
51+
52+test("the radiation gain of a uniform piston equals the analytic Rayleigh sum", async () => {
53+ const { model, pl } = await smallModel();
54+ const p: SimParams = { ...defaultParams, listenerDist: 1.0, listenerAngle: 0 };
55+ // A fake model with one mode of uniform shape (mass-normalized to 1/sqrt(M)).
56+ const uniform: ModalModel = { ...sliceModel(model, 0, 1), freqs: new Float64Array([1000]), padSigma: new Float64Array([0]) };
57+ let M = 0;
58+ for (let k = 0; k < uniform.nGrid; k++) M += uniform.masses[k];
59+ const phi = 1 / Math.sqrt(M);
60+ for (let r = 0; r < uniform.nRows; r++) uniform.wShapes[r] = phi;
61+ const { weight, delay } = listenerWeights(pl, p);
62+ const sigma = new Float64Array([0]);
63+ const gains = radiationGains(uniform, sigma, weight, delay);
64+ // Analytic: G = sum_k rho dA / (2 pi r_k) phi exp(-i omega tau_k).
65+ const lt = listenerTerms(pl, p);
66+ let dmin = Infinity;
67+ for (let k = 0; k < lt.weight.length; k++) if (lt.weight[k] > 0) dmin = Math.min(dmin, lt.delay[k]);
68+ let re = 0, im = 0;
69+ const w = 2 * Math.PI * 1000;
70+ for (let k = 0; k < lt.weight.length; k++) {
71+ if (lt.weight[k] === 0) continue;
72+ const tau = lt.delay[k] - dmin;
73+ re += lt.weight[k] * phi * Math.cos(w * tau);
74+ im -= lt.weight[k] * phi * Math.sin(w * tau);
75+ }
76+ // The shapes are float32, so agreement is to about 1e-7.
77+ assert.ok(Math.abs(gains.re[0] - re) < 1e-6 * Math.abs(re) && Math.abs(gains.im[0] - im) < 1e-6 * Math.max(Math.abs(im), 1e-6));
78+ // On axis at 1 m the weights sum to rho A / (2 pi r) within the delay spread.
79+ let area = 0;
80+ for (let j = 0; j < pl.grid.ny; j++) for (let i = 0; i < pl.grid.nx; i++) area += nodeWeight(pl.grid, i, j) * pl.grid.hx * pl.grid.hy;
81+ const approx = (RHO_AIR * area) / (2 * Math.PI * 1.0) * phi;
82+ assert.ok(Math.abs(Math.hypot(re, im) / approx - 1) < 0.05);
83+ void SPEED_OF_SOUND;
84+});
85+
86+test("the attack and the tail join, and the sliced parts sum to the whole", async () => {
87+ const { model, pl } = await smallModel();
88+ const p: SimParams = { ...defaultParams, fs: 48000, duration: 0.2, fps: 120, strikeVelocity: 2 };
89+ const whole = synthesize(model, pl, pl.loss, p, true);
90+ const nSamples = Math.round(p.duration * p.fs);
91+ assert.equal(whole.audio.length, nSamples);
92+ // No discontinuity at the tail start beyond the local sample-to-sample variation.
93+ const { maxDelay, weight, delay } = listenerWeights(pl, p);
94+ const n0 = Math.ceil(tailStart(whole.contact, maxDelay) * p.fs - 1e-9);
95+ let maxStep = 0, jump = Math.abs(whole.audio[n0] - whole.audio[n0 - 1]);
96+ for (let n = n0 + 2; n < n0 + 200; n++) maxStep = Math.max(maxStep, Math.abs(whole.audio[n] - whole.audio[n - 1]));
97+ assert.ok(jump < 3 * maxStep, `jump ${jump} vs typical step ${maxStep}`);
98+ // The tail decays: the last 20 ms is quieter than the first 20 ms.
99+ const rms = (a: Float32Array) => Math.sqrt(a.reduce((s, v) => s + v * v, 0) / a.length);
100+ assert.ok(rms(whole.audio.subarray(nSamples - 960)) < rms(whole.audio.subarray(0, 960)));
101+ // Split the modes into three slices and sum the parts.
102+ const sigma = modalRates(model, pl.loss);
103+ const g = strikeGains(model, pl, p);
104+ const c = simulateContact(model, sigma, g, p, p.fps);
105+ const tStart = tailStart(c, maxDelay);
106+ const len = Math.min(c.tableLen, Math.ceil(tStart * c.tableRate) + 2);
107+ const audio = new Float32Array(nSamples);
108+ const table = new Float32Array(model.nRows * len);
109+ const nFrames = Math.round(p.duration * p.fps);
110+ const frames = new Float32Array(nFrames * model.nGrid);
111+ const cuts = [0, Math.floor(model.count / 3), Math.floor((2 * model.count) / 3), model.count];
112+ for (let s = 0; s < 3; s++) {
113+ const sub = sliceModel(model, cuts[s], cuts[s + 1]);
114+ const cc = sliceContact(c, cuts[s], cuts[s + 1]);
115+ const sg = sigma.slice(cuts[s], cuts[s + 1]);
116+ nodalAccelerationTable(sub, cc, len, table);
117+ modalTail(sub, sg, cc, radiationGains(sub, sg, weight, delay), p.fs, tStart, nSamples, audio);
118+ movieFrames(sub, sg, cc, p.fps, nFrames, frames);
119+ }
120+ attackPressure(model, table, len, c.tableRate, weight, delay, p.fs, tStart, audio);
121+ let err = 0, nrm = 0;
122+ for (let n = 0; n < nSamples; n++) { err += (audio[n] - whole.audio[n]) ** 2; nrm += whole.audio[n] ** 2; }
123+ assert.ok(Math.sqrt(err / nrm) < 1e-5, `audio split error ${Math.sqrt(err / nrm)}`);
124+ err = 0; nrm = 0;
125+ for (let i = 0; i < frames.length; i++) { err += (frames[i] - whole.frames[i]) ** 2; nrm += whole.frames[i] ** 2; }
126+ assert.ok(Math.sqrt(err / nrm) < 1e-5, `frame split error ${Math.sqrt(err / nrm)}`);
127+});
128+
129+test("a lighter hit gives a proportionally smaller signal in the linear model", async () => {
130+ const { model, pl } = await smallModel();
131+ const p1: SimParams = { ...defaultParams, fs: 48000, duration: 0.05, strikeVelocity: 0.2 };
132+ const p2: SimParams = { ...p1, strikeVelocity: 0.1 };
133+ const a1 = synthesize(model, pl, pl.loss, p1, false).audio, a2 = synthesize(model, pl, pl.loss, p2, false).audio;
134+ const peak = (a: Float32Array) => a.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
135+ // The Hertz contact is nonlinear (a faster hit is shorter and brighter), so the ratio exceeds 2 somewhat.
136+ const ratio = peak(a1) / peak(a2);
137+ assert.ok(ratio > 1.8 && ratio < 3.5, `ratio ${ratio}`);
138+});
tsconfig.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "strict": true,
7+ "noUnusedLocals": true,
8+ "noUnusedParameters": true,
9+ "noFallthroughCasesInSwitch": true,
10+ "isolatedModules": true,
11+ "verbatimModuleSyntax": true,
12+ "skipLibCheck": true,
13+ "allowImportingTsExtensions": true,
14+ "noEmit": true,
15+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
16+ "types": ["@webgpu/types", "vite/client", "node"]
17+ },
18+ "include": ["src", "tests"]
19+}
vite.config.tsadded+3−0View file
@@ -0,0 +1,3 @@
1+import { defineConfig } from "vite";
2+
3+export default defineConfig({ base: "./", build: { target: "es2022" }, worker: { format: "es" } });
↑↓ move↵openescclose