# 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](../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](https://numbl.org). 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).