/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox
Go to fileHistoryFork
.githubsoundbox: a scene-composition studio over the dulcimer engine
scriptsRender scenes outside the browser
srcPut Save/Load scene in the transport bar and show the render command on save
testRender scenes outside the browser
toolssoundbox: a scene-composition studio over the dulcimer engine
.gitignoresoundbox: a scene-composition studio over the dulcimer engine
index.htmlPut Save/Load scene in the transport bar and show the render command on save
package-lock.jsonupdate package-lock.json
package.jsonRender scenes outside the browser
README.mdRender scenes outside the browser
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)

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.

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