/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox
Go to fileHistoryFork
.githubsoundbox: a scene-composition studio over the dulcimer engine
scriptssoundbox: a scene-composition studio over the dulcimer engine
srcHighlight each scene's own op names in the Lab editor; add browser smoke test
testsoundbox: a scene-composition studio over the dulcimer engine
toolssoundbox: a scene-composition studio over the dulcimer engine
.gitignoresoundbox: a scene-composition studio over the dulcimer engine
index.htmlsoundbox: a scene-composition studio over the dulcimer engine
package-lock.jsonsoundbox: a scene-composition studio over the dulcimer engine
package.jsonsoundbox: a scene-composition studio over the dulcimer engine
README.mdsoundbox: a scene-composition studio over the dulcimer engine
tsconfig.jsonsoundbox: a scene-composition studio over the dulcimer engine
vite.config.tssoundbox: a scene-composition studio over the dulcimer engine

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 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, 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 here are the handful a player would reach for: pitch, decay, brightness, where the pluck lands.

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)

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, and that the planner's kernel splitting changes nothing but the dispatch count.

↑↓ move↵openescclose