| .github | |
| bench | |
| src | |
| test | |
| .gitignore | |
| bench.html | |
| index.html | |
| package-lock.json | |
| package.json | |
| README.md | |
| tsconfig.json | |
| vite.config.ts |
toy-instruments#
Live: https://magland.github.io/toy-instruments/ (deployed from main by .github/workflows/pages.yml).
A browser gallery of toy physical-model instruments. Pick an instrument, set its geometry, properties and excitation, run the simulation, and get the pickup waveform, a spectrogram, a playable/downloadable .wav, and the bead displacements over time. Static site (Vite + TypeScript); the solver runs in a Web Worker, on the CPU or with WebGPU (see Solvers).
npm install
npm run dev # http://localhost:5173
npm test # vitest
npm run build # static site in dist/
String of beads (first instrument)#
npoint masses on springs with stiffnessk, spacingLat equilibrium, pre-tensionT(rest lengthL0 = L - T/k). Beads move in 3D and the springs are real (exact length), so geometric nonlinearity is kept: pitch glide at large amplitude, transverse-longitudinal coupling, whirling.- Ends: clamped (fixed point), sliding (massless ring on a rod: x fixed, free across, holds tension), or free (no support; only consistent with
T = 0). Any bead can be pinned. - Excitation: pluck (triangle between the nearest supports, released at rest), strike (Gaussian initial velocity), hammer (raised-cosine force pulse). Direction is an angle in the y-z plane.
- Damping from two decay times:
sigma(omega) = sigma0 + sigma1 omega^2, fitted to T60 at two frequencies for transverse modes.sigma0is mass-proportional;sigma1is a dashpot on each spring's relative velocity. - Audio: pickup acceleration (forces / mass, at the full-step velocity), from one bead or summed over all beads, along y, z or x. The
.wavis normalized to -1 dBFS. - Solver: velocity Verlet (explicit, symplectic without damping).
dt = cfl * 2 / omega_max, rounded down to a whole number of steps per audio sample;omega_maxis the highest frequency of the system linearized at rest (power iteration, axialkand transverseT/lper spring, bending, plus a mallet's contact stiffness). See Solvers. - 3D model view (three.js): Setup shows the beads at rest with supports (wall = clamped, translucent plane + ring = sliding), pinned beads (cubes), excitation (pluck shape, strike velocity arrows, hammer force arrows) and pickup (bead + axis arrow). Replay animates the saved frames ("Replay frames" per second, default 2000) with adjustable exaggeration, on a timeline shared with the 2D side/top views and space-time image.
- Checks shown per run: energy balance (energy + dissipated - work in - initial) relative to peak energy, largest strain. Linear transverse and longitudinal modes (Sturm bisection on the tridiagonal stiffness) are marked on the spectrum.
String tests (test/string.test.ts): modes vs the discrete-string formula; small mode-1 motion vs cos(omega_1 t); energy balance with and without damping/forcing; decay at the requested T60.
Bead drum#
- Round head: beads on a triangular (hex) lattice in the x-z plane, cut to a circle; every missing neighbour of an edge bead is a fixed rim point. Vibration along y.
- Inputs are continuum values: diameter, rings (spacing a = R / (rings + 1)), surface density, tension tau (N/m), in-plane stiffness E t (N/m). Lattice: spring tension f = tau a / sqrt(3), stiffness k = sqrt(3) E t / 2 (Poisson ratio 1/3), bead mass = density * sqrt(3)/2 a^2.
- Excitation: felt mallet (point mass, contact force K delta^p on a Gaussian patch, integrated with the beads; contact time and rebounds come out of the simulation), strike, force pulse, press (initial dent).
- Damping as for the string (dashpots calibrated to the springs' transverse stiffness f / a).
- Extra views: top-down displacement map and cross-section along the diameter through the strike point; mallet contact force.
- Presets: a fast tom with in-plane stiffness softened ~70x (1.5 s of sound in 9 s on the CPU and 5.6 s with WebGPU, 571 beads, Chromium on an Intel Iris Xe laptop) and a realistic Mylar one (~5x more steps).
- Tests (
test/drum.test.ts): every bead has six springs; a small centre press rings within 1.3% of the ideal membrane fundamental 2.405 c / (2 pi R); the mallet bounces off and energy balances.
Cymbal#
- Thin bronze plate (E = 100 GPa, 8600 kg/m^3, 0.8 mm by default), round, free rim, centre hole; flat, or with a bow (dome, y = h (1 - (r/R)^2)) and a bell (raised cosine); the default preset has a 12 mm bow and bell and a stick at 1 m/s. Beads on a triangular lattice lifted to the profile; the mesh is scaled so its area equals the disk's.
- In-plane: springs with k = sqrt(3)/2 E t / softening (an edge with one triangle gets half). Softening (default 30x in the fast preset) speeds the run; bending is unaffected.
- Bending: dihedral angles across interior edges (
src/sim/hinge.ts, Bridson et al. 2003), sign-normalized so a positive change folds toward the upward normal. Each triangle builds a shape operator S = sum_e phi_e l_e / (2A) m_e m_e^T and stores the plate energy A D/2 [(tr S)^2 - 2 (1 - nu) det S] (nu = 1/3) as a 3x3 matrix in its hinge angles, condensed over free edges so they carry no moment. Plain hinge springs get the twisting term wrong on a regular mesh (effective nu = -1/3), which matters for a free rim. Damping uses the same matrices (Rayleigh, sigma1 omega^2). - Support: none. The plate is free; its rigid-body motion (translation and rotation, mass-weighted about the current centre of mass) is removed from the accelerations at every force evaluation and from the velocities after every step (
BeadSystem.freeRigid). The elastic modes are mass-orthogonal to the rigid ones, so they are unchanged; nothing stiffens or damps them. Pinning only the centre bead was tried: an off-centre hit sets the plate tumbling (tilts past 89 degrees within a second). - Excitation: drumstick (Hertz, K delta^1.5) or felt mallet as a 3D point mass with contact along the surface normal, regularized Coulomb friction and a contact reach; hit angle tilts the stroke toward the rim. Also strike and force pulse.
- Sound: far-field microphone via the Rayleigh integral, p = rho / (2 pi) sum A_i a_n,i(t - R_i / c) / R_i (baffled-plate assumption: no diffraction around the rim).
- Tests (
test/cymbal.test.ts,test/hinge.test.ts): hinge gradient vs finite differences; bending energy matches the plate for cylinder, sphere, saddle and twist to 0.1%; a free flat plate's (2,0) mode within 3.4% of Leissa's value on a 9-ring lattice; energy balance with a glancing stick, friction, felts and damping. - Limits: about 30x slower than real time on the CPU and 14x with WebGPU for ~370 beads (Chromium, Intel Iris Xe laptop); bending is resolved only to ~1.3 kHz at the default resolution, so it sounds like a small gong, not a crash. A single-bead stick contact chatters on this lattice, so presets spread the contact over ~1.5 bead spacings.
Solvers#
Both solvers integrate the same scheme (src/sim/solver.ts on the CPU in double precision, src/sim/gpu.ts with WebGPU in single precision); the WebGPU toggle next to Run picks one, and a failed GPU run falls back to the CPU with the reason in the run summary. The toggle is on by default for the drum and cymbal and off for the string, whose few dozen beads run faster on the CPU.
- Time step:
omega_maxcomes from power iteration on the linearized stiffness rather than a Gershgorin bound, which overestimated it by 1.4 to 1.5x on the cymbal (so steps were that much shorter than needed). - CPU: at each audio sample the solver recomputes only the velocity-dependent forces (damping, friction) at the full-step velocity; spring and hinge elasticity depend on positions, which have not changed. Together with the time step, this made the cymbal presets 1.4 to 1.6x faster.
- WebGPU: each phase of a time step (kick and drift, hinge angles and contact, hinge moments, bead forces, kick with rigid-motion removal) is a small kernel; a command buffer holds the dispatches for many samples, and step and sample counters live on the GPU. Forces are gathered per bead from fixed-width adjacency lists, so there are no atomics. To keep small vibrations from vanishing in f32, the state is the displacement from rest, and spring extensions and hinge angles are computed from precomputed rest differences. Sums over beads (rigid motion, energy, audio) go through per-workgroup partials. On the first 50 ms of every preset, the audio agrees with the CPU to within 0.5% RMS (0.1% for most), and the energy-balance check and largest strain match.
- Speed, WebGPU vs CPU on an Intel Iris Xe laptop (Chromium): the default cymbal (366 beads) about 2x, a 20-ring cymbal (1444 beads) 4x, 30 rings (3188 beads) 6x; strings (3 to 50 beads) 4 to 80x slower in 50 ms test runs, where the GPU's start-up (about half a second) dominates. A single persistent workgroup running whole time steps per dispatch was tried first; on this GPU its large kernel made every barrier cost several microseconds, and it used only one of the GPU's subslices.
bench.html(dev server only,npm run devthen/bench.html?dur=0.05&only=cymbal&rings=20) runs every preset on both solvers and prints the differences and timings.
Layout#
src/sim/generic 3D bead-spring system, CPU solver (solver.ts), WebGPU solver (gpu.ts), worker.src/instruments/<id>/model (parameters to system), presets, page. Register new instruments insrc/instruments/registry.ts.src/ui/instrumentPage.tsthe shared page (controls, 3D model, setup, run, audio, plots); an instrument supplies setup rows and extra cards.src/ui/reusable controls form, line plot, spectrogram, chain displacement viewer, 3D scene, shared playback timeline, FFT, WAV.