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