4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 1# toy-instruments
3760879Widget: Open the full app, the bundle's own instrument page in a closeable full-window overlay; unedited form fields keep their exact values, so the page and the widget hash the same cache keys; vault1 site in READMEJeremy Magland 3Live: https://magland.github.io/toy-instruments/ and https://toy-instruments--proof-of-concept.vault1-sites.magland.org/, both deployed from `main` by `.github/workflows/pages.yml` (on GitHub, and on vault1, whose site source is set to `actions`).
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 5A 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).
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 6
7```
8npm install
9npm run dev # http://localhost:5173
10npm test # vitest
11npm run build # static site in dist/
12```
14## String of beads (first instrument)
16- `n` point masses on springs with stiffness `k`, spacing `L` at equilibrium, pre-tension `T` (rest length `L0 = 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.
17- 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.
18- 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.
19- Damping from two decay times: `sigma(omega) = sigma0 + sigma1 omega^2`, fitted to T60 at two frequencies for transverse modes. `sigma0` is mass-proportional; `sigma1` is a dashpot on each spring's relative velocity.
ae42884Anti-aliased pickup: solve at R fs and low-pass filter the pickup down to fs (both solvers); CACHE_VERSION 3; chaos-check analysis scriptsJeremy Magland 20- Audio: pickup acceleration (forces / mass, at the full-step velocity), summed over all beads (the default; on a uniform string clamped at both ends this cancels the even modes) or from one bead, along y, z or x. The `.wav` is normalized to -1 dBFS. The pickup is anti-aliased (see Solvers).
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 21- Solver: velocity Verlet (explicit, symplectic without damping). `dt = cfl * 2 / omega_max`, rounded down to a whole number of steps per audio sample; `omega_max` is the highest frequency of the system linearized at rest (power iteration, axial `k` and transverse `T/l` per spring, bending, plus a mallet's contact stiffness). See Solvers.
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 22- 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.
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 23- 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.
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 25String 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.
27## Bead drum
29- 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.
30- 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.
31- 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).
32- Damping as for the string (dashpots calibrated to the springs' transverse stiffness f / a).
33- Extra views: top-down displacement map and cross-section along the diameter through the strike point; mallet contact force.
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 34- 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).
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 35- 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.
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 36
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 37## Cymbal
a9ca16bCymbal defaults to bow and bell with a 1 m/s stick; Run button at the top of the panelJeremy Magland 39- 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.
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 40- 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.
41- 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).
591834dCymbal: free plate with rigid-body motion removed (no support); lighter default dampingJeremy Magland 42- 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).
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 43- 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.
44- 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).
45- 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.
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 46- 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.
48## Solvers
50Both 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.
ae42884Anti-aliased pickup: solve at R fs and low-pass filter the pickup down to fs (both solvers); CACHE_VERSION 3; chaos-check analysis scriptsJeremy Magland 52- Anti-aliased pickup (`src/sim/antialias.ts`, applied in the worker to both solvers): the solvers record the pickup once per sample, without a filter, so motion above fs/2 folded back into the audible band (a 10 mm pluck of a 5-bead string put 28% of its pickup power above 24 kHz). A run is now solved at R fs, with R = ceil((2 f_max + fs/2) / fs) capped at the steps per sample (f_max = omega_max / 2 pi, the factor 2 leaving room for nonlinear combination tones), and the pickup is low-pass filtered (Kaiser-windowed sinc, flat to 0.45 fs, at least 86 dB down from 0.5 fs) and kept every R-th sample. The contact force is subsampled. Presets get R = 1 (low f_max) to 6; the steps per sample round up to a multiple of R (11 to 12 for the steel string). The cost is the per-sample work done R times as often: about 25% for the strings, and 68% on the CPU and 36% with WebGPU for the default cymbal (R = 2), where recomputing the velocity-dependent forces at every internal sample dominates. Recording the extra pickup samples inside the solvers without that recomputation would recover most of it. CPU and WebGPU audio still agree to 0.11% RMS over the first 50 ms of the default cymbal. Tests: `test/antialias.test.ts`.
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 53- Time step: `omega_max` comes 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).
54- 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.
55- 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.
56- 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.
87d896eShow partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial resultJeremy Magland 57- 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_MS` in `solver.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.ts` checks 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.
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 58- `bench.html` (dev server only, `npm run dev` then `/bench.html?dur=0.05&only=cymbal&rings=20`) runs every preset on both solvers and prints the differences and timings.
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 59
b51a77aShared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is setJeremy Magland 60## Shared result cache
62Runs are cached on [artifact-cache](https://artifact-cache.tempory.net) (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.
64- 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; bump `CACHE_VERSION` when 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.
ae42884Anti-aliased pickup: solve at R fs and low-pass filter the pickup down to fs (both solvers); CACHE_VERSION 3; chaos-check analysis scriptsJeremy Magland 65- Value: the whole `SimResult` in 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; version 3 has the anti-aliased pickup.
b51a77aShared result cache on artifact-cache: offer a cached result for the current settings next to Run; store runs when a passcode is setJeremy Magland 66- 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.
67- Because the key hashes floating-point inputs, a browser whose `Math` functions round differently in the last bit (possible in Safari) builds a different key and misses entries computed elsewhere. It never gets a wrong result.
decd1b2Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the addressJeremy Magland 69## Shared links
3760879Widget: Open the full app, the bundle's own instrument page in a closeable full-window overlay; unedited form fields keep their exact values, so the page and the widget hash the same cache keys; vault1 site in READMEJeremy Magland 71"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). The form shows numbers in display units rounded to 10 digits, so a field the user has not edited keeps its exact value rather than being read back from the text (which can change the last bit, and with it the cache key). 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.
decd1b2Copy link to these settings: a shareable link with every parameter, applied on load and then removed from the addressJeremy Magland 72
e01df49Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widgetJeremy Magland 73## Embedding
4e56865Embedded widget for readers: Play sound and Play motion buttons that download on first press, no setup/replay tabs or view controls; exaggeration attribute (default 5, not in the cache key)Jeremy Magland 75`src/embed.ts` defines a web component for other pages (posts on jeremy.magland.org): one simulation, with the 3D model, its motion and its sound.
e01df49Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widgetJeremy Magland 76
77```html
4e56865Embedded widget for readers: Play sound and Play motion buttons that download on first press, no setup/replay tabs or view controls; exaggeration attribute (default 5, not in the cache key)Jeremy Magland 78<toy-instrument-sim name="crash" exaggeration="5" settings="https://magland.github.io/toy-instruments/#/bead-cymbal?…&preset=0&gpu=1"></toy-instrument-sim>
e01df49Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widgetJeremy Magland 79```
c110c8cWidget: spectrogram up to 24 kHz by defaultJeremy Magland 81- `settings` is a link from "Copy link to these settings" (or its `#/<instrument>?…` part). `exaggeration` magnifies the drawn motion (default 5; the reader can change it in the motion bar, from ×1 to ×100); it is not part of the cache key. `fmax` sets the top of the spectrogram's frequency axis (default 24000 Hz, capped at half the sample rate; the reader can change it). `name` names the downloaded `.wav`.
bc03839Widget: draw the initial pluck/press shape at the widget's exaggeration, so amplitudes compareJeremy Magland 82- The reader sees the model at rest (the 3D view without its tabs, view buttons or exaggeration input; drag to rotate; an initial pluck or press shape is drawn at the widget's exaggeration, not scaled to a fixed size, so different amplitudes look different), the spectrogram of the sound, and two buttons. The light entry is downloaded when the widget comes within 400 px of the viewport, for the spectrogram. "Play sound" plays it in an audio player; "Play motion (N MB)" downloads the frames and plays the replay in slow motion, with pause, a time slider, an exaggeration choice and a speed choice. Each row expands in place, so the other button does not move. On load the widget only sends HEAD requests (for the link's backend first, then the other); the frames are downloaded only for the motion.
4e56865Embedded widget for readers: Play sound and Play motion buttons that download on first press, no setup/replay tabs or view controls; exaggeration attribute (default 5, not in the cache key)Jeremy Magland 83- If the cache has no result, or cannot be reached, the widget offers to run the simulation in the browser, with a rough time estimate, and then shows both players. With the cache passcode saved in that browser (under "Shared cache" in the widget), the run is stored under the same key.
3760879Widget: Open the full app, the bundle's own instrument page in a closeable full-window overlay; unedited form fields keep their exact values, so the page and the widget hash the same cache keys; vault1 site in READMEJeremy Magland 84- "Open the full app" shows the whole instrument page (every parameter, the plots, Run and Load cached) over the host page, starting from the widget's settings; Close or Escape returns to the post, and reopening keeps its state. It is the bundle's own copy of the page (not the live app in a frame), so it computes the same cache keys as the widget. It has no back link or "Copy link", and leaves the address alone.
e01df49Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widgetJeremy Magland 85- 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.
86- 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.
87- Build: `npm run build:embed` writes `embed-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. The `prepare` script runs the same build, so the package can be installed as a git dependency (`import 'toy-instruments/embed'`); `embed-dist/` is not committed.
88- `embed.html` (dev server only, `npm run dev` then `/embed.html`) shows two widgets on a page styled like jeremy.magland.org, with a theme toggle. `/embed.html?cache=local` sends the cache's requests to a local artifact-cache (`wrangler dev` on port 8797), to test storing without the real passcode.
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 90## Layout
a78afe0Add WebGPU solver with a toggle; faster CPU solverJeremy Magland 92- `src/sim/` generic 3D bead-spring system, CPU solver (`solver.ts`), WebGPU solver (`gpu.ts`), worker.
e01df49Embeddable <toy-instrument-sim> widget (src/embed.ts): 3D model, replay and audio from the shared cache, or run in the browser and stored with the passcode; cache results as light and frames entries (CACHE_VERSION 2); instrument configs and link settings shared by the page and the widgetJeremy Magland 93- `src/instruments/<id>/` model (parameters to system), presets, `config.ts` (controls, setup summary, built system, extra views). `src/instruments/configs.ts` lists the configs by id; `src/instruments/registry.ts` lists the gallery. Register new instruments in both.
94- `src/ui/instrumentPage.ts` the shared page (controls, 3D model, setup, run, audio, plots); an instrument's config supplies setup rows and extra cards. `src/ui/settings.ts` reads and writes the settings of shared links.
95- `src/embed.ts` the embeddable widget (see Embedding).
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 96- `src/ui/` reusable controls form, line plot, spectrogram, chain displacement viewer, 3D scene, shared playback timeline, FFT, WAV.