| .github | |
| bench | |
| src | |
| test | |
| .gitignore | |
| bench.html | |
| embed.html | |
| index.html | |
| package-lock.json | |
| package.json | |
| README.md | |
| tsconfig.json | |
| vite.config.ts | |
| vite.embed.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.
- Partial results: while a run is in progress, the worker sends the result so far after 1 s and then every 2 s (
PARTIAL_FIRST_MS,PARTIAL_EVERY_MSinsolver.ts), and the page shows it like a finished run: plots on the axis of the whole run, the 3D replay, and audio to play or download. The WebGPU solver copies just the finished prefix of each output (audio, contact force, frame scalars, frames) to a staging buffer while later chunks run; on the default cymbal this costs about 1% of the loop time, and the partial results match the final one exactly (test/partial.test.tschecks this for the CPU). Updates keep the replay position and exaggeration; the audio player keeps playing the older sound until it is paused or ends, then switches (keeping a paused position). A cancelled or failed run leaves its last partial result on the page. Only finished runs go to the shared cache. 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.
Shared result cache#
Runs are cached on artifact-cache (app toy-instruments, src/sim/cache.ts). Reads are public, so anyone opening the site gets a result someone has already computed; writes need the app's passcode, entered once per browser under "Shared cache" in the panel.
- Key:
v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256>, hashed over everything the solver receives (bead system, excitation, options), not the UI parameters. A model change that alters the built system therefore misses old entries on its own; bumpCACHE_VERSIONwhen the solver's output changes for the same inputs. CPU and WebGPU results are cached separately; a WebGPU run that fell back to the CPU is stored as a CPU result. - Value: the whole
SimResultin a small binary format, so a cached result shows everything a fresh run does, split into two entries:<key>/light(audio, energy, contact force, scalars; a few hundred kB) and<key>/frames(the replay frames; the default cymbal's are about 35 MB). The frames are stored first, so a light entry always has its frames. Results whose frames are over the 100 MB entry limit are not stored. Version 1 kept both in one entry. - As the settings (or the WebGPU toggle) change, the page checks whether the cache has a result for them (a HEAD request, debounced) and, if so, shows its date and size with a Load cached button next to Run. Run always simulates; when the passcode is set, every completed run is stored, replacing any existing entry.
- Because the key hashes floating-point inputs, a browser whose
Mathfunctions round differently in the last bit (possible in Safari) builds a different key and misses entries computed elsewhere. It never gets a wrong result.
Shared links#
"Copy link to these settings" (under the preset) copies …/#/<instrument>?<param>=<value>&…&preset=<index>&gpu=<0|1>: every parameter in SI units, the preset the settings started from (its description is shown) and the WebGPU toggle (CPU and WebGPU results are cached separately). Opening the link applies the settings and then removes them from the address bar (history.replaceState), so reloading or sharing the plain address shows the page as usual; the WebGPU setting from a link is not saved as this browser's preference. Parameters missing from a link, invalid or not among a select's options keep the values of the link's preset, and unknown ones are ignored, so links survive added or removed parameters. When the shared cache has a result for the linked settings, the usual Load cached button appears.
Embedding#
src/embed.ts defines a web component for other pages (posts on jeremy.magland.org): one simulation, with the 3D model and its replay and the sound.
<toy-instrument-sim name="crash" settings="https://magland.github.io/toy-instruments/#/bead-cymbal?…&preset=0&gpu=1"></toy-instrument-sim>
settingsis a link from "Copy link to these settings" (or its#/<instrument>?…part);namenames the downloaded.wav.- The widget builds the instrument from the settings and shows the setup at once. It then looks in the shared cache, for the link's backend first and then the other: if there is a result, it loads the light entry (the audio player appears) and offers to load the motion (the frames, for the replay). If there is none, or the cache cannot be reached, it offers to run the simulation in the browser, with a rough time estimate. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
- The key hashes what the bundle's own model code builds, so a page that keeps one build of the bundle asks for, and can always refill, the same entries, whatever later versions of the app do. jeremy.magland.org uploads each build under a content hash and never replaces it.
- It takes the host page's colors and font from jeremy.magland.org's CSS variables (
--bg,--text,--link, ...), with this app's colors otherwise, and redraws when the page changes theme. - Build:
npm run build:embedwritesembed-dist/toy-instruments-embed.js(one ES module, about 1 MB, 230 kB gzipped), with three.js, the styles and the solver worker inlined; a worker must come from the page's origin, so it starts from a blob URL. Thepreparescript runs the same build, so the package can be installed as a git dependency (import 'toy-instruments/embed');embed-dist/is not committed. embed.html(dev server only,npm run devthen/embed.html) shows two widgets on a page styled like jeremy.magland.org, with a theme toggle./embed.html?cache=localsends the cache's requests to a local artifact-cache (wrangler devon port 8797), to test storing without the real passcode.
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,config.ts(controls, setup summary, built system, extra views).src/instruments/configs.tslists the configs by id;src/instruments/registry.tslists the gallery. Register new instruments in both.src/ui/instrumentPage.tsthe shared page (controls, 3D model, setup, run, audio, plots); an instrument's config supplies setup rows and extra cards.src/ui/settings.tsreads and writes the settings of shared links.src/embed.tsthe embeddable widget (see Embedding).src/ui/reusable controls form, line plot, spectrogram, chain displacement viewer, 3D scene, shared playback timeline, FFT, WAV.