/ proof-of-concept / instrawave
Sign in
proof-of-concept / instrawave
instrawave
Go to fileHistoryFork
.githubinstrawave: instruments as files over a precomputed room
authorinstrawave: instruments as files over a precomputed room
instrumentsinstrawave: instruments as files over a precomputed room
scriptsinstrawave: instruments as files over a precomputed room
srcinstrawave: instruments as files over a precomputed room
.gitignoreinstrawave: instruments as files over a precomputed room
index.htmlinstrawave: instruments as files over a precomputed room
package-lock.jsoninstrawave: instruments as files over a precomputed room
package.jsoninstrawave: instruments as files over a precomputed room
README.mdinstrawave: instruments as files over a precomputed room
tsconfig.jsoninstrawave: instruments as files over a precomputed room
vite.config.tsinstrawave: instruments as files over a precomputed room

instrawave#

Instruments as files. Each instrument is one JSON document carrying a parameter spec in physical units and the raw WebGPU programs for two operations: a precompute that runs when the instrument's shape changes, and a render that turns tuning parameters and a list of stimuli into audio. The host is a generic runner that knows nothing about drums or strings; it allocates buffers, feeds parameters to shaders as pipeline override constants, walks the file's dispatch schedules, and reads the results back.

This is a restructuring of the fast render path in soundbox, which demonstrated the physics: for a linear, one-way-coupled scene, the room's response to every mode of every vibrating object can be recorded in a single reciprocal air run, after which pitch, decay, brightness and excitation changes cost about a second of synthesis rather than a re-simulation. A limitation of that arrangement is that the knowledge of what to precompute, what invalidates it, and how to synthesize from it lives in the application's host code, one hard-coded path per concern. Here we move all of it into the instrument file, so that the cost structure is data:

  • geometry parameters (sizes, body shape, placement, and the microphone position, which is geometry like any other) feed the precompute; changing one invalidates the bake.
  • accuracy parameters (grid size, room response length, mode set margin) also feed the precompute; they trade fidelity against cost and bake size.
  • tuning parameters (pitch, decay, brightness, strike position and strength, coupling gains) feed only the render and are free to move.

Three instruments are included, one per object type: a drum (a struck membrane over a cylindrical shell), a dulcimer (a plucked stiff string over a box with a sound hole, radiating along its line and driving a bridge patch), and a chime (a struck stiff bar in open air).

What the precompute produces#

The bake is a set of impulse responses, one per coupling pattern. For the drum at the default settings (a 96 x 48 x 48 air grid, timestep 8.77 µs), the head's sine modes are recorded up to twice what the air grid can spatially resolve across it, 961 patterns, each with a half-second response at the microphone: a 219 MB file that depends only on geometry and accuracy. Discrete reciprocity collapses the recording to one air run, kicked at the microphone, with every pattern projected from the field after each step; on the development machine this takes about 8 s. The render then solves each mode of each stimulus in closed form (the leapfrog restricted to a sine eigenmode is a scalar two-term recursion) and filters each kernel with a complex one-pole per root, one GPU thread per (root, kernel) pair: 5 s of drum in 1.4 s, for any setting of the tuning parameters.

Stimuli are a list of events, [{"t": 0}, {"t": 0.6, "set": {"f0": 147}}], each an excitation at time t with any tuning parameter overridden for that event alone. The system is linear and time-invariant, so events superpose exactly (the test suite holds this to 4e-8) and cost no new precompute; playing a drum roll, or the same head retuned per strike, reuses one bake.

The file format#

An instrument file (see src/format.ts for the schema, instruments/ for the three instances) contains:

  • params: every parameter with unit, default, range, and its class.
  • derived: an ordered map of arithmetic expressions (src/expr.ts) for the discretization: grid sizes from stability bounds, the timestep from the CFL condition, mode counts, buffer sizes. The instrument, not the host, knows its numerics.
  • shaders: WGSL, with every grid size and parameter declared as a pipeline override constant, so the text is fixed and one file covers every setting.
  • precompute and render: dispatch schedules (buffers, setup passes, a loop with optional buffer rotation for leapfrog histories), with the precompute's outputs read back into the bake and the render's inputs uploaded from it.

An optional visual section describes how to draw the instrument, and it deliberately contains almost no geometry of its own. It names the passes to run (the same medium shader the precompute uses) and which of the resulting fields to raymarch: the body mask as a shaded solid, each coupling profile as a translucent cloud. What the viewer shows is therefore what the solver sees, grid effects included; coarsen nx and the sound hole staircases, thin a wall below what the grid can carry and it draws as a haze instead of a surface, which is exactly the leak the solver has. The only declarative part is a short list of markers (the object's outline, the microphone) whose coordinates are expressions over the parameters, drawn as crisp overlays. A partials entry tells the spectrum view where each rendered mode's pole sits in the slots buffer.

The bake sidecar (.bake) is keyed by a SHA-256 over the instrument's name, its precompute definition, and the geometry and accuracy values, so an edited instrument or a moved microphone invalidates it while tuning edits do not.

The three files are generated by author/build.ts (npm run author), and the physics they encode is soundbox's, where it is compiled from MATLAB by numbl. numbl remains the source of truth in a concrete sense: the test suite renders each instrument from its bake and compares against the numbl-compiled timestepped solver for the equivalent scene, run on the same GPU.

The page#

The page shows the three altitudes at once. The parameter sliders are split by class, with the 3D view updating immediately on any geometry or accuracy edit (the medium pass costs milliseconds) while the bake indicator goes stale; Precompute, with progress and a cancel, is the price of the change. After a render the trace is drawn with its spectrum on a log-frequency axis: the measured curve, tick marks at every rendered mode's discrete frequency (read back from the slots buffer, so they are the scheme's own partials, dispersion included), and shading over the band the grid does not resolve. For the default chime the ticks sit at 741, 2548 and 4415 Hz where the continuous bar would put 780, 3120 and 7020; the measured peaks land on the ticks, and the gap is the numerical dispersion made visible.

Accuracy, measured#

At nx = 32 over 6000 steps, against the timestepped solver: chime 3.1e-4, dulcimer 1.4e-4, drum 2.4e-3 relative L2 error, the drum retuned and restruck from the same bake 3.5e-4. The drum's floor of 2.4e-3 is f32 rounding, shared with the solver itself. The one deliberate approximation is the membrane's mode set: modes are recorded up to mode_margin times the air grid's spatial resolution across the head, and the error against the full solver falls from 5.3e-2 at margin 1.25 to 4.0e-3 at the default margin 2, saturating at the f32 floor from 2.5 up. Line objects have few modes and record all of them. The room response length is the other truncation; the CLI reports how far down the kernels are where it cuts.

Honest limitations#

The physics inherits soundbox's simplifications: one-way coupling (no sympathetic resonance, no air loading on the membrane), rigid bodies with an absorbing skin rather than modes of their own, a bar pinned rather than free at its ends, and excitations that are initial conditions, so there is no bowing or blowing. The fast path produces the microphone trace and nothing else; there is no pressure field to draw. Multi-event renders cost one synthesis pass per (event, mode, root) over the whole trace, so a long event list scales linearly even where events are sparse. And the closed form holds only because the generated air is uniform; an instrument with a spatially varying sound speed would break the reciprocity the precompute rests on.

Running it#

npm install
npm run render -- instruments/drum.instrument.json --seconds 5
npm run render -- instruments/drum.instrument.json --set f0=90 --set shellr=0.2
npm run render -- instruments/drum.instrument.json --events hits.json
npm run dev        # the page: sliders, bake, render, listen
npm test           # host checks + validation against soundbox's solver

The CLI needs the optional webgpu package (Google Dawn) and, on a headless Linux box, working Vulkan drivers. The tests additionally assume the vault layout: a soundbox checkout at ../soundbox with its own node_modules installed (numbl arrives through it).

↑↓ move↵openescclose