3A small studio for instruments made of wave equations, running in the
4browser. You compose a scene out of vibrating objects (strings, stiff bars,
5membranes), a rigid body for them to sound over (a box with a sound hole, a
6drum shell, or nothing), and a microphone in the air. The app writes the
7coupled PDEs out as two MATLAB files, [numbl](https://numbl.org) compiles the
8solver to WebGPU compute shaders on the spot, and the microphone records the
9simulated pressure at one grid point every timestep, which you then listen
10to at its real pitch.
12This is the next step after
13[dulcimer](https://github.com/concept-collection/dulcimer), which simulates
14exactly one scene: a plucked string over a box. A limitation of that project
15is that the scene is the program; hearing a drum instead of a dulcimer means
16rewriting the model by hand, and the interface exposes every physical
17constant at once, which is flexible but confusing. Here we introduce a scene
18description layer above the same compiler pipeline: the dulcimer becomes one
19preset among several, objects can be added and combined freely, and the
20interface is split into three altitudes so that each kind of user meets only
21the controls they came for.
23## Three altitudes
25**Play** is presets and musical knobs. Pick a scene (dulcimer, drum, chime,
26a duet of strings, a drum-and-chime pair), excite it, render a clip, listen,
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 27save a .wav. The sliders come in two groups, split by what they cost. The
28tuning group is the handful a player would reach for — pitch, decay,
29brightness, where the pluck lands — and is free to move, even while the
30scene plays. The shape group is the instrument itself: each object's size
31and the body's main dimensions. Those change geometry, so a size recompiles
32and either kind re-records a Fast render room; the group is set apart so
33the two costs are never mixed.
35**Build** is the studio. Add strings, bars and membranes; move each one in
36the air; choose and shape the body; place the microphone; set the air grid.
37Two costs are worth knowing. Pitches, decays and gains are GPU uniforms and
38free to move. Positions and body shape re-evaluate the scene file on the CPU
39and re-upload a few arrays, with no recompile. Only structure (objects added
40or removed, a length changed, the grid) triggers a fresh compile.
42**Lab** is the truth. The two generated MATLAB files are shown and editable;
43an edit compiles through exactly the same pipeline the generator feeds, so
44the generated code is a starting point rather than an internal detail. Next
45to the code, the compiled dispatch list, and a spectrum of the recording
46with every object's predicted partials overlaid.
48## What is simulated
50Every object is advanced by an explicit leapfrog scheme on its own small
51grid, all sharing the timestep the air's CFL condition fixes:
53- a **string** carries `u_tt = cs² u_xx − kap² u_xxxx − 2 sig0 u_t + 2 sig1 (u_xx)_t`
54 (the standard stiff damped string; Bilbao, *Numerical Sound Synthesis*,
55 ch. 7), pinned at both ends, plucked by a triangular initial displacement;
56- a **bar** is the tension-free case of the same equation, struck with a
57 mallet-shaped initial velocity, its partials at n²·f1;
58- a **membrane** carries the 2D wave equation with the same two damping
59 terms, pinned at its rim, struck like the bar, with the inharmonic mode
60 set that makes a drum thud rather than sound a chord.
62The air carries `p_tt + 2 sig p_t = c² lapw(p, wall) + s` on a 3D grid.
63`lapw` is a wall-masked Laplacian: each stencil face is scaled by the mask at
64the neighbour it reads, which is the discrete sound-hard (Neumann) condition
65in divergence form, so a body is rigid at no cost to the timestep. The
66source `s` collects each object's contribution: its acceleration, spread
67over a profile the scene builds around wherever the object sits (a tube
68around a string, a sheet at a membrane's plane), plus, for strings, the pull
69at the bridge end driving a patch of air above the plate. Because the
70profiles carry the placement, moving an object costs a scene re-evaluation
71rather than a recompile.
73The coupling is one-way: the air does not push back on the objects. At
74these amplitudes the back-reaction is far below everything else the model
75already neglects, but it does mean there is no sympathetic resonance between
76objects, and no air loading on the membrane.
78## The generated files
80A scene description (a list of typed objects plus a body) is turned by
81`src/studio/codegen.ts` into a model file, with an `init` and a `step` in
82which every object keeps its own suffixed variables (`u_s1`, `cs_s1`), and a
83scene file defining `medium(x, y, z, …)`, which paints the body's mask, the
84absorbing boundary layer, and one coupling profile per object. The stencil
85and coupling operations each object needs (`dxx_s1`, `lap_m1`, `rad_m1`,
86`brg_s1`) are instantiated per object with the grid geometry baked into
87their WGSL; everything else in the model compiles to element-wise kernels,
88one per source line, exactly as in dulcimer. The model is compiled by
89numbl's JIT front end and this project's WGSL backend; the scene runs once
90through numbl's CPU interpreter, so it has the full MATLAB subset available.
92## Hearing the numerics
94The spectrum view draws two predictions per object. Dashed markers are the
95continuous equations' partials. Solid markers are the discrete scheme's own:
96the compiled operators have sine eigenmodes with eigenvalue
97`lam_n = (4/h²) sin²(nπh/2L)` per axis, so mode n of a string rings at
98`sqrt(cs² lam_n + kap² lam_n²)/2π` exactly, and likewise for bars and
99membranes. The measured peaks land on the solid markers; the gap between
100solid and dashed is the numerical dispersion. On a string's fine grid the
101two nearly coincide. On a bar's deliberately coarse grid (an explicit stiff
102scheme is only stable on a coarse grid) the upper partials land well flat of
103n²·f1, and the difference is plainly audible. Making that error visible and
104audible, rather than hiding it, is part of the point of the project.
106One consequence of taking this seriously is in the fourth-difference
107operator: it uses antisymmetric ghost points, so it is exactly the square of
108the pinned second difference and the modes are exact sines. With zero ghost
109points instead (the obvious implementation) the ends are effectively
110clamped, and a bar tuned to 780 Hz sounds near 1150 Hz.
112## Honest limitations
114The air grid resolves sound to roughly 4 kHz at the default size, so the top
115of every timbre is absent. Bodies are rigid masks with a tunable absorbing
116skin; they have no modes of their own, so a "wood" box colours the sound
117only through its cavity and hole. The bar is pinned at its ends, whereas a
118real glockenspiel bar is free at both, which changes the partial ratios (a
119free-free bar's lowest modes are not n² multiples); the free boundary needs
120a different ghost-point treatment that this project does not yet carry. A
121membrane does not seal the cavity below it, since it enters the air only as
122a source, so a drum here is a head sounding over an open-topped vessel. The
123coupling gains are physically arbitrary; the equations are linear, so they
124set balance, not absolute loudness. And each excitation is an initial
125condition, so there is no bowing, blowing, or any other sustained drive, and
126no event timeline yet: one excitation per recording.
128## Running it
130```
131npm install
132npm run dev # local dev server
133npm test # solver checks against desktop WebGPU (Google Dawn)
134npm run smoke # headless-browser check of the built page (environment permitting)
135```
139A scene built in the page can be rendered on another machine, without a
140browser, on whatever GPU that machine has. The Build tab's **Save scene**
141button downloads a small .json carrying the scene description, every
142parameter value, the microphone, the grid size, and any hand-edited .m
143sources (generated sources are not stored; the generator is deterministic).
144The same file loads back into the page with **Load scene**, and renders
145headless with:
147```
148npm run render -- scene.json --seconds 20 --nx 192 --out clip.wav
149npm run render -- --preset drum --seconds 5
150npm run render -- --list-presets
151```
153The renderer builds the same StudioSession the page builds — same generator,
154same compiler, same solver, through desktop WebGPU (the optional `webgpu`
155package, Google Dawn) — so a scene sounds the same wherever it runs; on a
156headless Linux box Dawn needs working Vulkan drivers. What a bigger GPU
157buys, besides speed, is bandwidth: the simulation is honest to about
158c/(8h), so `--nx 256` reaches ~8 kHz where the browser default's ~4 kHz
159stops, at 16 times the work per second of audio (the solver is
160memory-bandwidth-bound, which is exactly what a discrete card is good at).
161Memory on the host grows as nx³ during scene evaluation; beyond nx = 384
162expect several gigabytes.
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 164## The fast path: instruments from precomputation
166A limitation of the renderer above is that its cost is the air's: about
167114,000 timesteps per second of audio at the default grid, each a handful of
168passes over the whole box, whatever is playing. Almost all of that stepping
169is avoidable. Three facts about the models as compiled make it so.
170Everything is linear and each excitation is an initial condition; the
171coupling is one-way, so the air never pushes back on the objects; and every
172object's discrete operator has exact sine eigenmodes (the same fact the
173spectrum overlay already draws on, and for a bar the reason the
174fourth-difference operator carries antisymmetric ghosts), so each modal
175amplitude follows a scalar two-term recursion with a closed-form solution.
176The source the air sees is then a sum of fixed spatial patterns times known
177scalar series — one pattern per mode for an object radiating along its line
178or plane, and for a string one more, the bridge patch its modes' arriving
179slopes drive — and the microphone's trace is those series convolved with
180per-pattern impulse responses of the room. The responses depend only on
181geometry: the body, every object's size and placement, the grid, and the
182microphone. Pitches, decays, brightness, the excitations and the gains
183never touch them.
185One further fact collapses the precomputation to a single run. The air
186update's transfer matrix is symmetric (the masked Laplacian in divergence
187form has w_i w_j / h^2 off-diagonal entries, and everything else is
188diagonal), so discrete reciprocity holds exactly: the mic's response to any
189source pattern equals the projection of that pattern onto the field an
190impulse at the mic produces. So
192```
193npm run render -- --preset drum --seconds 20 --fast
194```
196runs the air once, kicked at the microphone, through the same compiler and
197the same lapw op as everything else, and projects the field onto every
198pattern after each step (src/studio/room.ts, src/studio/modal.ts). The
199projections are separable and ride the step's own submission. The trace is
200then synthesized on the CPU, one complex one-pole per (mode, kernel) pair,
201in about a second for any duration and any setting of the musical
202parameters. Strings and bars have a few dozen modes each and keep all of
203them; a membrane's set is bounded by `--modal-eps`, which with the kernel
204length is the fast path's whole approximation. On the machine this was developed on, two seconds of drum at
205the default grid took 96 s the ordinary way and 26 s with `--fast`; twenty
206seconds took 25 s against a projected 16 minutes, since only the room
207recording touches the air. Re-synthesizing from a kept room (see
208`resynthesize` in src/studio/fast.ts) costs about a second, which is what
209makes the head effectively playable.
211In the page, the transport bar's **Fast render** button is the same
212machinery. The first press records the room, with a progress bar and a
213cancel; every later press reuses it, so pitches, decays, brightness and the
214excitations re-render a clip in about a second. The recording is kept until
215something it depends on changes (the body, an object's size or placement,
216the grid, the microphone), and a membrane strike moved far enough to want
217modes outside the recording re-records by itself. Listen, Save .wav and the
218spectrum all take the synthesized clip; the volume view stays with the live
219solver, since the fast path produces no field to draw.
221In the app's cost language this is a fourth tier: model parameters stay
222free, and it is geometry or microphone edits that re-record the room. The
223approximations are stated, small, and reported: modes weaker than
224`--modal-eps` times the strongest are dropped (default 1e-6; tightening it
225to 1e-10 changes the output by parts in 1e6), and the room response is
226truncated at `--ir-seconds` (default 0.5 s; the render prints how many dB
227down the kernels are at that point). Within those, the fast path reproduces
228the timestepped solver to its own f32 rounding noise — about 2e-3 relative
229over two seconds of drum, rather less for the string and bar scenes — and
230the test suite holds it there for the drum, the dulcimer and the kit,
231retuned and restruck from a single recording. What it does not produce is
232the pressure field itself, so the volume view still needs the live solver;
233it needs the generated model .m (a hand-edited scene .m is fine, since a
234scene is data either way); and it needs the uniform sound speed every
235generated scene has, since a spatially varying c breaks the symmetry that
236reciprocity rests on. The derivation is checked to machine precision in
237scripts/modal-poc.ts.
761af16soundbox: a scene-composition studio over the dulcimer engineJeremy Magland 239numbl is a `file:../../numbl` dependency, expected as a sibling checkout two
240directories up (the layout of the concept-collection vault).
242`npm test` compiles generated scenes to actual shaders and checks physics:
243the string's measured pitch and decay time, the bar landing on its discrete
244dispersion relation, the membrane's (1,1) mode, that a sealed box keeps
245sound out and a sound hole lets the cavity speak, that both strings of a
246duet reach the microphone, that moving an object moves its coupling profile
a97bec5Render any scene from precomputation, and split the Play knobs by costJeremy Magland 247without a recompile, that the planner's kernel splitting changes nothing
248but the dispatch count, and that the fast path's modal synthesis matches the
249timestepped drum, dulcimer and kit from one room recording per scene,
250before and after retuning.