/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / README.md
168 lines · 9.0 KBPreviewCodeBlameHistoryRaw
1# soundbox
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,
27save a .wav. The sliders here are the handful a player would reach for:
28pitch, decay, brightness, where the pluck lands.
30**Build** is the studio. Add strings, bars and membranes; move each one in
31the air; choose and shape the body; place the microphone; set the air grid.
32Two costs are worth knowing. Pitches, decays and gains are GPU uniforms and
33free to move. Positions and body shape re-evaluate the scene file on the CPU
34and re-upload a few arrays, with no recompile. Only structure (objects added
35or removed, a length changed, the grid) triggers a fresh compile.
37**Lab** is the truth. The two generated MATLAB files are shown and editable;
38an edit compiles through exactly the same pipeline the generator feeds, so
39the generated code is a starting point rather than an internal detail. Next
40to the code, the compiled dispatch list, and a spectrum of the recording
41with every object's predicted partials overlaid.
43## What is simulated
45Every object is advanced by an explicit leapfrog scheme on its own small
46grid, all sharing the timestep the air's CFL condition fixes:
48- a **string** carries `u_tt = cs² u_xx − kap² u_xxxx − 2 sig0 u_t + 2 sig1 (u_xx)_t`
49 (the standard stiff damped string; Bilbao, *Numerical Sound Synthesis*,
50 ch. 7), pinned at both ends, plucked by a triangular initial displacement;
51- a **bar** is the tension-free case of the same equation, struck with a
52 mallet-shaped initial velocity, its partials at n²·f1;
53- a **membrane** carries the 2D wave equation with the same two damping
54 terms, pinned at its rim, struck like the bar, with the inharmonic mode
55 set that makes a drum thud rather than sound a chord.
57The air carries `p_tt + 2 sig p_t = c² lapw(p, wall) + s` on a 3D grid.
58`lapw` is a wall-masked Laplacian: each stencil face is scaled by the mask at
59the neighbour it reads, which is the discrete sound-hard (Neumann) condition
60in divergence form, so a body is rigid at no cost to the timestep. The
61source `s` collects each object's contribution: its acceleration, spread
62over a profile the scene builds around wherever the object sits (a tube
63around a string, a sheet at a membrane's plane), plus, for strings, the pull
64at the bridge end driving a patch of air above the plate. Because the
65profiles carry the placement, moving an object costs a scene re-evaluation
66rather than a recompile.
68The coupling is one-way: the air does not push back on the objects. At
69these amplitudes the back-reaction is far below everything else the model
70already neglects, but it does mean there is no sympathetic resonance between
71objects, and no air loading on the membrane.
73## The generated files
75A scene description (a list of typed objects plus a body) is turned by
76`src/studio/codegen.ts` into a model file, with an `init` and a `step` in
77which every object keeps its own suffixed variables (`u_s1`, `cs_s1`), and a
78scene file defining `medium(x, y, z, …)`, which paints the body's mask, the
79absorbing boundary layer, and one coupling profile per object. The stencil
80and coupling operations each object needs (`dxx_s1`, `lap_m1`, `rad_m1`,
81`brg_s1`) are instantiated per object with the grid geometry baked into
82their WGSL; everything else in the model compiles to element-wise kernels,
83one per source line, exactly as in dulcimer. The model is compiled by
84numbl's JIT front end and this project's WGSL backend; the scene runs once
85through numbl's CPU interpreter, so it has the full MATLAB subset available.
87## Hearing the numerics
89The spectrum view draws two predictions per object. Dashed markers are the
90continuous equations' partials. Solid markers are the discrete scheme's own:
91the compiled operators have sine eigenmodes with eigenvalue
92`lam_n = (4/h²) sin²(nπh/2L)` per axis, so mode n of a string rings at
93`sqrt(cs² lam_n + kap² lam_n²)/2π` exactly, and likewise for bars and
94membranes. The measured peaks land on the solid markers; the gap between
95solid and dashed is the numerical dispersion. On a string's fine grid the
96two nearly coincide. On a bar's deliberately coarse grid (an explicit stiff
97scheme is only stable on a coarse grid) the upper partials land well flat of
98n²·f1, and the difference is plainly audible. Making that error visible and
99audible, rather than hiding it, is part of the point of the project.
101One consequence of taking this seriously is in the fourth-difference
102operator: it uses antisymmetric ghost points, so it is exactly the square of
103the pinned second difference and the modes are exact sines. With zero ghost
104points instead (the obvious implementation) the ends are effectively
105clamped, and a bar tuned to 780 Hz sounds near 1150 Hz.
107## Honest limitations
109The air grid resolves sound to roughly 4 kHz at the default size, so the top
110of every timbre is absent. Bodies are rigid masks with a tunable absorbing
111skin; they have no modes of their own, so a "wood" box colours the sound
112only through its cavity and hole. The bar is pinned at its ends, whereas a
113real glockenspiel bar is free at both, which changes the partial ratios (a
114free-free bar's lowest modes are not n² multiples); the free boundary needs
115a different ghost-point treatment that this project does not yet carry. A
116membrane does not seal the cavity below it, since it enters the air only as
117a source, so a drum here is a head sounding over an open-topped vessel. The
118coupling gains are physically arbitrary; the equations are linear, so they
119set balance, not absolute loudness. And each excitation is an initial
120condition, so there is no bowing, blowing, or any other sustained drive, and
121no event timeline yet: one excitation per recording.
123## Running it
125```
126npm install
127npm run dev # local dev server
128npm test # solver checks against desktop WebGPU (Google Dawn)
129npm run smoke # headless-browser check of the built page (environment permitting)
130```
132## Rendering outside the browser
134A scene built in the page can be rendered on another machine, without a
135browser, on whatever GPU that machine has. The Build tab's **Save scene**
136button downloads a small .json carrying the scene description, every
137parameter value, the microphone, the grid size, and any hand-edited .m
138sources (generated sources are not stored; the generator is deterministic).
139The same file loads back into the page with **Load scene**, and renders
140headless with:
142```
143npm run render -- scene.json --seconds 20 --nx 192 --out clip.wav
144npm run render -- --preset drum --seconds 5
145npm run render -- --list-presets
146```
148The renderer builds the same StudioSession the page builds — same generator,
149same compiler, same solver, through desktop WebGPU (the optional `webgpu`
150package, Google Dawn) — so a scene sounds the same wherever it runs; on a
151headless Linux box Dawn needs working Vulkan drivers. What a bigger GPU
152buys, besides speed, is bandwidth: the simulation is honest to about
153c/(8h), so `--nx 256` reaches ~8 kHz where the browser default's ~4 kHz
154stops, at 16 times the work per second of audio (the solver is
155memory-bandwidth-bound, which is exactly what a discrete card is good at).
156Memory on the host grows as nx³ during scene evaluation; beyond nx = 384
157expect several gigabytes.
159numbl is a `file:../../numbl` dependency, expected as a sibling checkout two
160directories up (the layout of the concept-collection vault).
162`npm test` compiles generated scenes to actual shaders and checks physics:
163the string's measured pitch and decay time, the bar landing on its discrete
164dispersion relation, the membrane's (1,1) mode, that a sealed box keeps
165sound out and a sound hole lets the cavity speak, that both strings of a
166duet reach the microphone, that moving an object moves its coupling profile
167without a recompile, and that the planner's kernel splitting changes nothing
168but the dispatch count.
↑↓ move↵openescclose