instrawave: instruments as files over a precomputed room
Each instrument is one JSON document: a parameter spec in SI units
(geometry and accuracy feed the precompute, tuning is free, the
microphone is geometry), derived-quantity expressions for the
discretization, and raw WGSL for both the reciprocal room recording
and the GPU modal synthesis that renders audio from it. The host is
a generic runner. Bakes live in a keyed sidecar; stimuli are an
event list that superposes exactly. Three instruments (drum,
dulcimer, chime), validated against soundbox's numbl-compiled
timestepped solver, plus a page with a raymarched scene view of the
medium fields and a trace/spectrum display with predicted partials.
26 changed files+8595−0
.github/workflows/deploy.ymladded+40−0View file
@@ -0,0 +1,40 @@
1+name: deploy
2+on:
3+ push:
4+ branches: [main]
5+ workflow_dispatch:
6+
7+permissions:
8+ contents: read
9+ pages: write
10+ id-token: write
11+
12+concurrency:
13+ group: pages
14+ cancel-in-progress: true
15+
16+jobs:
17+ build-deploy:
18+ runs-on: ubuntu-latest
19+ steps:
20+ - uses: actions/checkout@v4
21+ - uses: actions/setup-node@v4
22+ with:
23+ node-version: 24
24+ cache: npm
25+ # --omit=optional skips the `webgpu` package (prebuilt Google Dawn),
26+ # which only the CLI renderer and the GPU test suite use; the page
27+ # build needs nothing from it. The full suite (which also wants a
28+ # soundbox checkout and a GPU) runs locally, not here.
29+ - run: npm ci --omit=optional
30+ # The checked-in instrument files must be exactly the builder's
31+ # output; regenerate and refuse to deploy drift.
32+ - run: npm run author
33+ - run: git diff --exit-code instruments/
34+ - run: npm run build
35+ - uses: actions/configure-pages@v5
36+ - uses: actions/upload-pages-artifact@v3
37+ with:
38+ path: dist
39+ - id: deployment
40+ uses: actions/deploy-pages@v4
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+node_modules/
2+dist/
3+*.bake
4+*.wav
README.mdadded+155−0View file
@@ -0,0 +1,155 @@
1+# instrawave
2+
3+Instruments as files. Each instrument is one JSON document carrying a
4+parameter spec in physical units and the raw WebGPU programs for two
5+operations: a precompute that runs when the instrument's shape changes, and
6+a render that turns tuning parameters and a list of stimuli into audio. The
7+host is a generic runner that knows nothing about drums or strings; it
8+allocates buffers, feeds parameters to shaders as pipeline override
9+constants, walks the file's dispatch schedules, and reads the results back.
10+
11+This is a restructuring of the fast render path in
12+[soundbox](../soundbox), which demonstrated the physics: for a linear,
13+one-way-coupled scene, the room's response to every mode of every vibrating
14+object can be recorded in a single reciprocal air run, after which pitch,
15+decay, brightness and excitation changes cost about a second of synthesis
16+rather than a re-simulation. A limitation of that arrangement is that the
17+knowledge of what to precompute, what invalidates it, and how to synthesize
18+from it lives in the application's host code, one hard-coded path per
19+concern. Here we move all of it into the instrument file, so that the cost
20+structure is data:
21+
22+- **geometry** parameters (sizes, body shape, placement, and the
23+ microphone position, which is geometry like any other) feed the
24+ precompute; changing one invalidates the bake.
25+- **accuracy** parameters (grid size, room response length, mode set
26+ margin) also feed the precompute; they trade fidelity against cost and
27+ bake size.
28+- **tuning** parameters (pitch, decay, brightness, strike position and
29+ strength, coupling gains) feed only the render and are free to move.
30+
31+Three instruments are included, one per object type: a **drum** (a struck
32+membrane over a cylindrical shell), a **dulcimer** (a plucked stiff string
33+over a box with a sound hole, radiating along its line and driving a bridge
34+patch), and a **chime** (a struck stiff bar in open air).
35+
36+## What the precompute produces
37+
38+The bake is a set of impulse responses, one per coupling pattern. For the
39+drum at the default settings (a 96 x 48 x 48 air grid, timestep 8.77 µs),
40+the head's sine modes are recorded up to twice what the air grid can
41+spatially resolve across it, 961 patterns, each with a half-second response
42+at the microphone: a 219 MB file that depends only on geometry and
43+accuracy. Discrete reciprocity collapses the recording to one air run,
44+kicked at the microphone, with every pattern projected from the field after
45+each step; on the development machine this takes about 8 s. The render then
46+solves each mode of each stimulus in closed form (the leapfrog restricted
47+to a sine eigenmode is a scalar two-term recursion) and filters each kernel
48+with a complex one-pole per root, one GPU thread per (root, kernel) pair:
49+5 s of drum in 1.4 s, for any setting of the tuning parameters.
50+
51+Stimuli are a list of events, `[{"t": 0}, {"t": 0.6, "set": {"f0": 147}}]`,
52+each an excitation at time t with any tuning parameter overridden for that
53+event alone. The system is linear and time-invariant, so events superpose
54+exactly (the test suite holds this to 4e-8) and cost no new precompute;
55+playing a drum roll, or the same head retuned per strike, reuses one bake.
56+
57+## The file format
58+
59+An instrument file (see `src/format.ts` for the schema, `instruments/` for
60+the three instances) contains:
61+
62+- `params`: every parameter with unit, default, range, and its class.
63+- `derived`: an ordered map of arithmetic expressions (`src/expr.ts`) for
64+ the discretization: grid sizes from stability bounds, the timestep from
65+ the CFL condition, mode counts, buffer sizes. The instrument, not the
66+ host, knows its numerics.
67+- `shaders`: WGSL, with every grid size and parameter declared as a
68+ pipeline `override` constant, so the text is fixed and one file covers
69+ every setting.
70+- `precompute` and `render`: dispatch schedules (buffers, setup passes, a
71+ loop with optional buffer rotation for leapfrog histories), with the
72+ precompute's outputs read back into the bake and the render's inputs
73+ uploaded from it.
74+
75+An optional `visual` section describes how to draw the instrument, and it
76+deliberately contains almost no geometry of its own. It names the passes to
77+run (the same medium shader the precompute uses) and which of the resulting
78+fields to raymarch: the body mask as a shaded solid, each coupling profile
79+as a translucent cloud. What the viewer shows is therefore what the solver
80+sees, grid effects included; coarsen `nx` and the sound hole staircases,
81+thin a wall below what the grid can carry and it draws as a haze instead of
82+a surface, which is exactly the leak the solver has. The only declarative
83+part is a short list of markers (the object's outline, the microphone)
84+whose coordinates are expressions over the parameters, drawn as crisp
85+overlays. A `partials` entry tells the spectrum view where each rendered
86+mode's pole sits in the slots buffer.
87+
88+The bake sidecar (`.bake`) is keyed by a SHA-256 over the instrument's
89+name, its precompute definition, and the geometry and accuracy values, so
90+an edited instrument or a moved microphone invalidates it while tuning
91+edits do not.
92+
93+The three files are generated by `author/build.ts` (`npm run author`), and
94+the physics they encode is soundbox's, where it is compiled from MATLAB by
95+[numbl](https://numbl.org). numbl remains the source of truth in a concrete
96+sense: the test suite renders each instrument from its bake and compares
97+against the numbl-compiled timestepped solver for the equivalent scene,
98+run on the same GPU.
99+
100+## The page
101+
102+The page shows the three altitudes at once. The parameter sliders are split
103+by class, with the 3D view updating immediately on any geometry or accuracy
104+edit (the medium pass costs milliseconds) while the bake indicator goes
105+stale; Precompute, with progress and a cancel, is the price of the change.
106+After a render the trace is drawn with its spectrum on a log-frequency
107+axis: the measured curve, tick marks at every rendered mode's discrete
108+frequency (read back from the slots buffer, so they are the scheme's own
109+partials, dispersion included), and shading over the band the grid does not
110+resolve. For the default chime the ticks sit at 741, 2548 and 4415 Hz where
111+the continuous bar would put 780, 3120 and 7020; the measured peaks land on
112+the ticks, and the gap is the numerical dispersion made visible.
113+
114+## Accuracy, measured
115+
116+At nx = 32 over 6000 steps, against the timestepped solver: chime 3.1e-4,
117+dulcimer 1.4e-4, drum 2.4e-3 relative L2 error, the drum retuned and
118+restruck from the same bake 3.5e-4. The drum's floor of 2.4e-3 is f32
119+rounding, shared with the solver itself. The one deliberate approximation
120+is the membrane's mode set: modes are recorded up to `mode_margin` times
121+the air grid's spatial resolution across the head, and the error against
122+the full solver falls from 5.3e-2 at margin 1.25 to 4.0e-3 at the default
123+margin 2, saturating at the f32 floor from 2.5 up. Line objects have few
124+modes and record all of them. The room response length is the other
125+truncation; the CLI reports how far down the kernels are where it cuts.
126+
127+## Honest limitations
128+
129+The physics inherits soundbox's simplifications: one-way coupling (no
130+sympathetic resonance, no air loading on the membrane), rigid bodies with
131+an absorbing skin rather than modes of their own, a bar pinned rather than
132+free at its ends, and excitations that are initial conditions, so there is
133+no bowing or blowing. The fast path produces the microphone trace and
134+nothing else; there is no pressure field to draw. Multi-event renders cost
135+one synthesis pass per (event, mode, root) over the whole trace, so a long
136+event list scales linearly even where events are sparse. And the closed
137+form holds only because the generated air is uniform; an instrument with a
138+spatially varying sound speed would break the reciprocity the precompute
139+rests on.
140+
141+## Running it
142+
143+```
144+npm install
145+npm run render -- instruments/drum.instrument.json --seconds 5
146+npm run render -- instruments/drum.instrument.json --set f0=90 --set shellr=0.2
147+npm run render -- instruments/drum.instrument.json --events hits.json
148+npm run dev # the page: sliders, bake, render, listen
149+npm test # host checks + validation against soundbox's solver
150+```
151+
152+The CLI needs the optional `webgpu` package (Google Dawn) and, on a
153+headless Linux box, working Vulkan drivers. The tests additionally assume
154+the vault layout: a soundbox checkout at `../soundbox` with its own
155+`node_modules` installed (numbl arrives through it).
author/build.tsadded+1023−0View file
@@ -0,0 +1,1023 @@
1+/**
2+ * The instrument author: writes complete instrument files for the three v1
3+ * instruments (drum, dulcimer, chime) from one parameterized builder.
4+ *
5+ * The physics and its numbers are soundbox's, taken from the same equations
6+ * the numbl pipeline compiles there (the test suite holds the two projects
7+ * together; see scripts/test-node.ts). What this builder adds is the form:
8+ * every grid size and parameter reaches the WGSL as a pipeline override
9+ * constant, so one file covers every setting of every parameter, and the
10+ * whole precompute-vs-tuning split is carried by the parameter classes in
11+ * the file rather than by host code that knows what a drum is.
12+ *
13+ * The precompute is the reciprocal room recording: run the air alone,
14+ * kicked at the microphone, and after every step project the field onto
15+ * each mode's coupling pattern (a product of interpolated sines under the
16+ * object's radiation profile; for a string, one more pattern for its
17+ * bridge patch). Discrete reciprocity makes each projection row that
18+ * pattern's impulse response at the microphone. The render solves every
19+ * object mode in closed form (the leapfrog restricted to a sine eigenmode
20+ * is a two-term scalar recursion) and filters each kernel with a complex
21+ * one-pole per root, one GPU thread per (root, kernel) pair, tiled over
22+ * time with a reduction per tile.
23+ *
24+ * The recorded mode set is geometric: sine modes up to `mode_margin` times
25+ * what the air grid can spatially resolve across the object
26+ * (m <= mode_margin * L / (2h)). Nothing about the strike or the tuning
27+ * enters, so the bake depends on geometry and accuracy parameters alone.
28+ */
29+import type { InstrumentFile, MarkerSpec, ParamSpec, PassSpec, VisualSpec } from '../src/format.ts';
30+
31+const WG = 64;
32+
33+/** Thread index linearized over a possibly two-dimensional workgroup grid
34+ * (the x dimension caps at 65535 workgroups). */
35+const IDX = 'let i = gid.x + gid.y * 4194240u;';
36+
37+const MAIN = `@compute @workgroup_size(${WG})
38+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
39+ ${IDX}`;
40+
41+const ov = (names: [string, 'u32' | 'f32'][]): string =>
42+ names.map(([n, t]) => `override ${n}: ${t};`).join('\n');
43+
44+const buf = (bindings: [string, 'read' | 'read_write'][]): string =>
45+ bindings
46+ .map(
47+ ([n, m], i) =>
48+ `@group(0) @binding(${i}) var<storage, ${m}> ${n === '__tick' ? 'tick' : n === '__events' ? 'ev' : n}: array<${n === '__tick' ? 'u32' : 'f32'}>;`,
49+ )
50+ .join('\n');
51+
52+export type ObjectKind = 'membrane' | 'string' | 'bar';
53+export type BodyKind = 'shell' | 'box' | 'none';
54+
55+export interface BuildOptions {
56+ name: string;
57+ description: string;
58+ object: ObjectKind;
59+ body: BodyKind;
60+ /** Geometry parameter defaults overriding the built-in ones. */
61+ defaults?: Record<string, number>;
62+}
63+
64+/* ------------------------------------------------------------- params --- */
65+
66+const P = (
67+ key: string,
68+ label: string,
69+ unit: string,
70+ value: number,
71+ min: number,
72+ max: number,
73+ cls: 'geometry' | 'accuracy' | 'tuning',
74+ hint?: string,
75+): ParamSpec => ({ key, label, unit, value, min, max, class: cls, hint });
76+
77+const bodyParams: Record<BodyKind, ParamSpec[]> = {
78+ none: [],
79+ shell: [
80+ P('shellr', 'shell radius', 'm', 0.17, 0.08, 0.3, 'geometry', 'A hair larger than the head over it.'),
81+ P('shelld', 'shell depth', 'm', 0.3, 0.05, 0.4, 'geometry', 'The cavity under the head; deeper is a lower air resonance.'),
82+ P('thick', 'wall thickness', 'm', 0.016, 0.008, 0.05, 'geometry'),
83+ P('closed', 'closed bottom', '1', 1, 0, 1, 'geometry', '1 puts a plate across the bottom; 0 leaves it open.'),
84+ P('absorb', 'surface absorption', '1/s', 800, 0, 4000, 'geometry', 'A lossy skin over the shell surfaces; 0 lets the cavity ring hard.'),
85+ ],
86+ box: [
87+ P('boxl', 'box length', 'm', 0.66, 0.3, 0.9, 'geometry'),
88+ P('boxw', 'box width', 'm', 0.16, 0.08, 0.4, 'geometry'),
89+ P('boxd', 'box depth', 'm', 0.08, 0.03, 0.25, 'geometry', 'How far the box hangs below its top plate at z = 0.'),
90+ P('thick', 'wall thickness', 'm', 0.016, 0.008, 0.05, 'geometry'),
91+ P('holer', 'sound hole radius', 'm', 0.035, 0, 0.12, 'geometry', '0 seals the box; the hole is what lets the cavity speak.'),
92+ P('holex', 'sound hole x', 'm', -0.05, -0.3, 0.3, 'geometry'),
93+ P('absorb', 'surface absorption', '1/s', 800, 0, 4000, 'geometry'),
94+ ],
95+};
96+
97+const objectParams: Record<ObjectKind, { geometry: ParamSpec[]; tuning: ParamSpec[] }> = {
98+ membrane: {
99+ geometry: [
100+ P('L1', 'head width', 'm', 0.32, 0.15, 0.5, 'geometry', 'Extent along x.'),
101+ P('L2', 'head depth', 'm', 0.32, 0.15, 0.5, 'geometry', 'Extent along y.'),
102+ P('oz', 'head height', 'm', 0.02, 0, 0.15, 'geometry', 'Height of the head plane above z = 0.'),
103+ ],
104+ tuning: [
105+ P('f0', 'pitch', 'Hz', 110, 60, 300, 'tuning', 'The (1,1) fundamental; the tension follows from it and the size.'),
106+ P('t60', 'decay t60', 's', 1.5, 0.2, 6, 'tuning', 'Seconds to decay 60 dB.'),
107+ P('sig1', 'brightness decay', 'm^2/s', 0.0005, 0, 0.01, 'tuning', 'Frequency-dependent damping; the slap of the attack fades first.'),
108+ P('strikex', 'strike x', '1', 0.35, 0.1, 0.9, 'tuning', 'Where the stick lands along x, as a fraction.'),
109+ P('strikey', 'strike y', '1', 0.5, 0.1, 0.9, 'tuning'),
110+ P('striker', 'stick width', 'm', 0.04, 0.01, 0.1, 'tuning', 'Radius of the struck patch.'),
111+ P('vel', 'strike speed', 'm/s', 1.5, 0.1, 5, 'tuning', 'Linear equations: sets scale only.'),
112+ P('gair', 'radiation gain', '1', 1, 0, 2, 'tuning'),
113+ ],
114+ },
115+ string: {
116+ geometry: [
117+ P('L1', 'speaking length', 'm', 0.6, 0.3, 0.9, 'geometry'),
118+ P('oy', 'across', 'm', 0, -0.15, 0.15, 'geometry'),
119+ P('oz', 'height', 'm', 0.02, 0.005, 0.15, 'geometry', 'Height of the string above z = 0.'),
120+ ],
121+ tuning: [
122+ P('f0', 'pitch', 'Hz', 294, 80, 880, 'tuning', 'The fundamental; the wave speed follows, cs = 2 L f0.'),
123+ P('B', 'inharmonicity', '1', 0.0001, 0, 0.002, 'tuning', 'Bending stiffness as the inharmonicity coefficient.'),
124+ P('t60', 'decay t60', 's', 4, 0.2, 8, 'tuning'),
125+ P('sig1', 'brightness decay', 'm^2/s', 0.005, 0, 0.05, 'tuning'),
126+ P('pluckpos', 'pluck position', '1', 0.22, 0.08, 0.92, 'tuning'),
127+ P('amp', 'pluck height', 'm', 0.002, 0.0002, 0.005, 'tuning'),
128+ P('gair', 'line radiation gain', '1', 0.3, 0, 2, 'tuning'),
129+ P('gbridge', 'bridge drive gain', '1', 1, 0, 2, 'tuning', 'The pull at the far end driving a patch above the plate.'),
130+ ],
131+ },
132+ bar: {
133+ geometry: [
134+ P('L1', 'length', 'm', 0.3, 0.15, 0.5, 'geometry'),
135+ P('oy', 'across', 'm', 0, -0.15, 0.15, 'geometry'),
136+ P('oz', 'height', 'm', 0.05, 0.005, 0.15, 'geometry'),
137+ ],
138+ tuning: [
139+ P('f1', 'pitch', 'Hz', 780, 200, 2000, 'tuning', 'The first partial; higher ones land at n^2 f1.'),
140+ P('t60', 'decay t60', 's', 6, 0.5, 12, 'tuning'),
141+ P('sig1', 'brightness decay', 'm^2/s', 0.001, 0, 0.02, 'tuning'),
142+ P('strikepos', 'strike position', '1', 0.3, 0.1, 0.9, 'tuning'),
143+ P('striker', 'mallet width', 'm', 0.025, 0.01, 0.08, 'tuning'),
144+ P('vel', 'strike speed', 'm/s', 1, 0.1, 5, 'tuning'),
145+ P('gair', 'radiation gain', '1', 1, 0, 2, 'tuning'),
146+ ],
147+ },
148+};
149+
150+/* ------------------------------------------------------------ derived --- */
151+
152+function derivedOf(object: ObjectKind): Record<string, string> {
153+ const air: Record<string, string> = {
154+ c0: '343',
155+ cfl: '0.5',
156+ ny: 'nx / 2',
157+ nz: 'nx / 2',
158+ npts: 'nx * ny * nz',
159+ h: 'Lx / nx',
160+ Ly: 'ny * h',
161+ Lz: 'nz * h',
162+ dt: 'cfl * h / (sqrt(3) * c0)',
163+ ir_steps: 'max(1, round(ir_seconds / dt))',
164+ mic_index:
165+ 'clamp(round((mic_x + Lx/2) / h - 0.5), 0, nx - 1)' +
166+ ' + nx * (clamp(round((mic_y + Ly/2) / h - 0.5), 0, ny - 1)' +
167+ ' + ny * clamp(round((mic_z + Lz/2) / h - 0.5), 0, nz - 1))',
168+ };
169+ if (object === 'membrane') {
170+ return {
171+ ...air,
172+ // The grid, sized for the worst case any tuning slider can reach, so
173+ // no tuning move ever invalidates it (soundbox's makeRectGrid).
174+ c_worst: '2 * f0__max / sqrt(1/(L1*L1) + 1/(L2*L2))',
175+ h_min: 'sqrt(2 * (c_worst*c_worst*dt*dt + 4*sig1__max*dt)) / 0.95',
176+ n1: 'max(5, min(256, floor(L1 / h_min) + 1))',
177+ n2: 'max(5, min(256, floor(L2 / h_min) + 1))',
178+ h1: 'L1 / (n1 - 1)',
179+ h2: 'L2 / (n2 - 1)',
180+ // The geometric mode set: what the air grid resolves, times a margin.
181+ mx: 'min(n1 - 2, ceil(mode_margin * L1 / (2 * h)))',
182+ my: 'min(n2 - 2, ceil(mode_margin * L2 / (2 * h)))',
183+ n_rows: 'mx * my',
184+ };
185+ }
186+ const stiff: Record<string, string> =
187+ object === 'string'
188+ ? {
189+ cs_worst: '2 * L1 * f0__max',
190+ kap2_worst: 'B__max * (cs_worst * cs_worst * L1 * L1) / (pi * pi)',
191+ }
192+ : {
193+ kap_worst: '2 * f1__max * (L1 * L1) / pi',
194+ kap2_worst: 'kap_worst * kap_worst',
195+ cs_worst: '0',
196+ };
197+ return {
198+ ...air,
199+ ...stiff,
200+ a_worst: 'cs_worst*cs_worst*dt*dt + 4*sig1__max*dt',
201+ h_min: 'sqrt((a_worst + sqrt(a_worst*a_worst + 16*kap2_worst*dt*dt)) / 2) / 0.95',
202+ n1: 'max(5, min(1024, floor(L1 / h_min) + 1))',
203+ h1: 'L1 / (n1 - 1)',
204+ mx: 'n1 - 2', // line objects have few modes; record all of them
205+ n_rows: object === 'string' ? 'mx + 1' : 'mx', // + the bridge patch row
206+ };
207+}
208+
209+/* ---------------------------------------------------- medium (bodies) --- */
210+
211+const bodyWgsl: Record<BodyKind, { code: string; consts: [string, 'f32'][] }> = {
212+ none: { code: ' let shell = 0.0;', consts: [] },
213+ shell: {
214+ code: ` // The shell: a cylindrical ring from z = -shelld to 0, optional bottom.
215+ let r = sqrt(x*x + y*y);
216+ let cyl_o = 0.5 * (1.0 - tanh((r - shellr) / sw));
217+ let cyl_i = 0.5 * (1.0 - tanh((r - (shellr - thick)) / sw));
218+ let cyl_z = 0.5 * (1.0 - tanh((abs(z + shelld/2.0) - shelld/2.0) / sw));
219+ let bot = 0.5 * (1.0 - tanh((abs(z + shelld - thick/2.0) - thick/2.0) / sw));
220+ let shell = min(1.0, max(0.0, cyl_o - cyl_i) * cyl_z + closed * (cyl_i * bot));`,
221+ consts: [['shellr', 'f32'], ['shelld', 'f32'], ['thick', 'f32'], ['closed', 'f32']],
222+ },
223+ box: {
224+ code: ` // The box: the outer solid minus its cavity, top plate at z = 0, with a
225+ // round sound hole cut out of the top plate only.
226+ let box_ox = 0.5 * (1.0 - tanh((abs(x) - boxl/2.0) / sw));
227+ let box_oy = 0.5 * (1.0 - tanh((abs(y) - boxw/2.0) / sw));
228+ let box_oz = 0.5 * (1.0 - tanh((abs(z + boxd/2.0) - boxd/2.0) / sw));
229+ let box_ix = 0.5 * (1.0 - tanh((abs(x) - (boxl/2.0 - thick)) / sw));
230+ let box_iy = 0.5 * (1.0 - tanh((abs(y) - (boxw/2.0 - thick)) / sw));
231+ let box_iz = 0.5 * (1.0 - tanh((abs(z + boxd/2.0) - (boxd/2.0 - thick)) / sw));
232+ var shell = max(0.0, box_ox * box_oy * box_oz - box_ix * box_iy * box_iz);
233+ let hole_r = sqrt((x - holex)*(x - holex) + y*y);
234+ let hole = 0.5 * (1.0 - tanh((hole_r - holer) / sw));
235+ let top = 0.5 * (1.0 - tanh((abs(z + thick/2.0) - thick/2.0) / sw));
236+ shell = max(0.0, shell - hole * top);`,
237+ consts: [
238+ ['boxl', 'f32'], ['boxw', 'f32'], ['boxd', 'f32'], ['thick', 'f32'],
239+ ['holer', 'f32'], ['holex', 'f32'],
240+ ],
241+ },
242+};
243+
244+function mediumShader(object: ObjectKind, body: BodyKind): { code: string; constants: string[] } {
245+ const b = bodyWgsl[body];
246+ const absorb = body === 'none' ? '' : ' + absorb * (4.0 * shell * (1.0 - shell))';
247+ const profConsts: [string, 'f32'][] =
248+ object === 'membrane'
249+ ? [['oz', 'f32'], ['L1', 'f32'], ['L2', 'f32']]
250+ : [['oy', 'f32'], ['oz', 'f32'], ['L1', 'f32']];
251+ const prof =
252+ object === 'membrane'
253+ ? ` // The head radiates from its plane: a thin sheet at z = oz over its
254+ // extent, cross-section normalized to one.
255+ let aw = 2.0 * h;
256+ let sk = exp(-((z - oz)*(z - oz)) / (aw*aw)) / (sqrt(PI) * aw);
257+ let exx = 0.5 * (1.0 + tanh((L1/2.0 - abs(x)) / (1.5*h)));
258+ let exy = 0.5 * (1.0 + tanh((L2/2.0 - abs(y)) / (1.5*h)));
259+ prof[i] = sk * exx * exy * w;`
260+ : ` // The object radiates from its line: a tube two cells wide around
261+ // (y = oy, z = oz), fading at its ends, cross-section normalized to one.
262+ let aw = 2.0 * h;
263+ let tube = exp(-((y - oy)*(y - oy) + (z - oz)*(z - oz)) / (aw*aw)) / (PI * aw * aw);
264+ let span = 0.5 * (1.0 + tanh((L1/2.0 - abs(x)) / (1.5*h)));
265+ prof[i] = tube * span * w;`;
266+ const bprof =
267+ object === 'string'
268+ ? ` // ...and its bridge force drives a patch of air just above the plate,
269+ // around the x = +L/2 end (a stand-in for a moving top plate).
270+ let pw = 1.5 * h;
271+ let pr = exp(-((x - L1/2.0)*(x - L1/2.0) + (y - oy)*(y - oy)) / (0.04*0.04)) / (PI * 0.04 * 0.04);
272+ let skin = exp(-(z - pw)*(z - pw) / (pw*pw)) / (sqrt(PI) * pw);
273+ bprof[i] = pr * skin * w;`
274+ : '';
275+ const buffers: [string, 'read_write'][] = [
276+ ['wall', 'read_write'], ['sig', 'read_write'], ['prof', 'read_write'],
277+ ];
278+ if (object === 'string') buffers.push(['bprof', 'read_write']);
279+ const consts: [string, 'u32' | 'f32'][] = [
280+ ['npts', 'u32'], ['nx', 'u32'], ['ny', 'u32'],
281+ ['h', 'f32'], ['Lx', 'f32'], ['Ly', 'f32'], ['Lz', 'f32'],
282+ ...b.consts,
283+ ...(body === 'none' ? [] : ([['absorb', 'f32']] as [string, 'f32'][])),
284+ ...profConsts,
285+ ];
286+ const code = `// The medium: the body's rigid mask, the absorption, and where the object
287+// couples into the air. One thread per air cell.
288+const PI = 3.14159265358979;
289+${ov(consts)}
290+${buf(buffers)}
291+${MAIN}
292+ if (i >= npts) { return; }
293+ let ix = i % nx;
294+ let iy = (i / nx) % ny;
295+ let iz = i / (nx * ny);
296+ let x = -Lx/2.0 + (f32(ix) + 0.5) * h;
297+ let y = -Ly/2.0 + (f32(iy) + 0.5) * h;
298+ let z = -Lz/2.0 + (f32(iz) + 0.5) * h;
299+ let sw = 0.45 * h;
300+${b.code}
301+ let w = 1.0 - shell;
302+ wall[i] = w;
303+ // Open boundary at the domain edge (a quadratic absorbing ramp over the
304+ // outer 0.15 Ly of each face), plus the body's lossy surface skin.
305+ let spw = 0.15 * Ly;
306+ let dx = max(0.0, spw - (Lx/2.0 - abs(x))) / spw;
307+ let dy = max(0.0, spw - (Ly/2.0 - abs(y))) / spw;
308+ let dz = max(0.0, spw - (Lz/2.0 - abs(z))) / spw;
309+ let d = max(max(dx, dy), dz);
310+ sig[i] = 9000.0 * d * d${absorb};
311+${prof}
312+${bprof}
313+}
314+`;
315+ return { code, constants: consts.map(([n]) => n) };
316+}
317+
318+/* ------------------------------------------- the rest of the precompute --- */
319+
320+/** Interpolated sine tables: the pattern the rad coupling op applies is the
321+ * node sine linearly interpolated to each air column, so the projection
322+ * must use exactly that, not the continuous sine. */
323+function sinesShader(axis: 'x' | 'y'): { code: string; constants: string[] } {
324+ const [n, m, L, hobj, nair, Lair] =
325+ axis === 'x' ? ['n1', 'mx', 'L1', 'h1', 'nx', 'Lx'] : ['n2', 'my', 'L2', 'h2', 'ny', 'Ly'];
326+ const consts: [string, 'u32' | 'f32'][] = [
327+ [n, 'u32'], [m, 'u32'], [nair, 'u32'],
328+ [L, 'f32'], [hobj, 'f32'], [Lair, 'f32'], ['h', 'f32'],
329+ ];
330+ const code = `// Sine mode ${axis === 'x' ? 'm' : 'n'} of the object grid, interpolated to air
331+// ${axis} centers: s${axis}[(mode-1) * ${nair} + i${axis}]. One thread per table entry.
332+const PI = 3.14159265358979;
333+${ov(consts)}
334+${buf([['table', 'read_write']])}
335+${MAIN}
336+ if (i >= ${m} * ${nair}) { return; }
337+ let mi = i % ${m};
338+ let ia = i / ${m};
339+ let xw = -${Lair}/2.0 + (f32(ia) + 0.5) * h;
340+ let u = (xw + ${L}/2.0) / ${hobj};
341+ let j = i32(floor(u));
342+ var v = 0.0;
343+ if (j >= 0 && j < i32(${n}) - 1) {
344+ let f = u - f32(j);
345+ let mm = f32(mi + 1u);
346+ let s0 = sin(mm * PI * f32(j) / f32(${n} - 1u));
347+ let s1 = sin(mm * PI * f32(j + 1) / f32(${n} - 1u));
348+ v = (1.0 - f) * s0 + f * s1;
349+ }
350+ table[mi * ${nair} + ia] = v;
351+}
352+`;
353+ return { code, constants: consts.map(([q]) => q) };
354+}
355+
356+const impulseShader = {
357+ code: `// The reciprocal kick: one unit impulse at the microphone cell, entering
358+// the way a source enters the first step. One thread.
359+${ov([['mic_index', 'u32'], ['dt', 'f32']])}
360+${buf([['p', 'read_write'], ['wall', 'read'], ['sig', 'read']])}
361+@compute @workgroup_size(1)
362+fn main() {
363+ p[mic_index] = dt * dt * wall[mic_index] / (1.0 + sig[mic_index] * dt);
364+}
365+`,
366+ constants: ['mic_index', 'dt'],
367+};
368+
369+const stepShader = {
370+ code: `// One leapfrog step of the air: the wall-masked Laplacian (each stencil
371+// face scaled by the mask at the neighbour it reads, the rigid condition in
372+// divergence form) fused with the update. One thread per air cell.
373+${ov([['npts', 'u32'], ['nx', 'u32'], ['ny', 'u32'], ['nz', 'u32'], ['h', 'f32'], ['dt', 'f32'], ['c0', 'f32']])}
374+${buf([['p', 'read'], ['pm', 'read'], ['pn', 'read_write'], ['wall', 'read'], ['sig', 'read']])}
375+fn face(ix: i32, iy: i32, iz: i32, pc: f32) -> f32 {
376+ if (ix < 0 || ix >= i32(nx) || iy < 0 || iy >= i32(ny) || iz < 0 || iz >= i32(nz)) {
377+ return -pc; // outside the domain: p = 0, open
378+ }
379+ let k = u32(ix) + nx * (u32(iy) + ny * u32(iz));
380+ return wall[k] * (p[k] - pc);
381+}
382+${MAIN}
383+ if (i >= npts) { return; }
384+ let ix = i32(i % nx);
385+ let iy = i32((i / nx) % ny);
386+ let iz = i32(i / (nx * ny));
387+ let pc = p[i];
388+ let s = face(ix - 1, iy, iz, pc) + face(ix + 1, iy, iz, pc)
389+ + face(ix, iy - 1, iz, pc) + face(ix, iy + 1, iz, pc)
390+ + face(ix, iy, iz - 1, pc) + face(ix, iy, iz + 1, pc);
391+ let lp = wall[i] * s / (h * h);
392+ let sd = sig[i] * dt;
393+ pn[i] = wall[i] * ((2.0 * pc - (1.0 - sd) * pm[i] + (c0 * dt) * (c0 * dt) * lp) / (1.0 + sd));
394+}
395+`,
396+ constants: ['npts', 'nx', 'ny', 'nz', 'h', 'dt', 'c0'],
397+};
398+
399+function planeShader(profName: string, outName: string): { code: string; constants: string[] } {
400+ return {
401+ code: `// Contract the field against the ${profName === 'bprof' ? 'bridge patch' : 'radiation'} profile over z:
402+// one thread per air column.
403+${ov([['nx', 'u32'], ['ny', 'u32'], ['nz', 'u32']])}
404+${buf([[outName, 'read_write'], ['p', 'read'], [profName, 'read']])}
405+${MAIN}
406+ if (i >= nx * ny) { return; }
407+ var s = 0.0;
408+ for (var iz = 0u; iz < nz; iz++) {
409+ let k = i + nx * ny * iz;
410+ s += ${profName}[k] * p[k];
411+ }
412+ ${outName}[i] = s;
413+}
414+`,
415+ constants: ['nx', 'ny', 'nz'],
416+ };
417+}
418+
419+const xcShader = {
420+ code: `// Contract the plane against every x sine row: tmp[m + mx * iy].
421+${ov([['nx', 'u32'], ['ny', 'u32'], ['mx', 'u32']])}
422+${buf([['tmp', 'read_write'], ['plane', 'read'], ['sx', 'read']])}
423+${MAIN}
424+ if (i >= mx * ny) { return; }
425+ let m = i % mx;
426+ let iy = i / mx;
427+ var s = 0.0;
428+ for (var ix = 0u; ix < nx; ix++) {
429+ s += sx[m * nx + ix] * plane[ix + nx * iy];
430+ }
431+ tmp[i] = s;
432+}
433+`,
434+ constants: ['nx', 'ny', 'mx'],
435+};
436+
437+/** The last contraction writes this step's sample of every kernel row. */
438+function projShader(object: ObjectKind): { code: string; constants: string[] } {
439+ if (object === 'membrane') {
440+ return {
441+ code: `// Finish each pattern's projection against its y sine row and write this
442+// step's sample: kernels[q * ir_steps + t], q = (m-1) + mx*(n-1).
443+${ov([['ny', 'u32'], ['mx', 'u32'], ['my', 'u32'], ['ir_steps', 'u32']])}
444+${buf([['kernels', 'read_write'], ['tmp', 'read'], ['sy', 'read'], ['__tick', 'read']])}
445+${MAIN}
446+ if (i >= mx * my) { return; }
447+ let m = i % mx;
448+ let n = i / mx;
449+ var s = 0.0;
450+ for (var iy = 0u; iy < ny; iy++) {
451+ s += sy[n * ny + iy] * tmp[m + mx * iy];
452+ }
453+ let t = tick[0];
454+ if (t < ir_steps) {
455+ kernels[i * ir_steps + t] = s;
456+ }
457+}
458+`,
459+ constants: ['ny', 'mx', 'my', 'ir_steps'],
460+ };
461+ }
462+ return {
463+ code: `// A line object's y factor is one everywhere: finish by summing tmp over
464+// iy and write this step's sample of every kernel row.
465+${ov([['ny', 'u32'], ['mx', 'u32'], ['ir_steps', 'u32']])}
466+${buf([['kernels', 'read_write'], ['tmp', 'read'], ['__tick', 'read']])}
467+${MAIN}
468+ if (i >= mx) { return; }
469+ var s = 0.0;
470+ for (var iy = 0u; iy < ny; iy++) {
471+ s += tmp[i + mx * iy];
472+ }
473+ let t = tick[0];
474+ if (t < ir_steps) {
475+ kernels[i * ir_steps + t] = s;
476+ }
477+}
478+`,
479+ constants: ['ny', 'mx', 'ir_steps'],
480+ };
481+}
482+
483+const bridgeProjShader = {
484+ code: `// The bridge patch is a single fixed pattern: sum its plane and write the
485+// sample into the last kernel row (row mx). One thread.
486+${ov([['nx', 'u32'], ['ny', 'u32'], ['mx', 'u32'], ['ir_steps', 'u32']])}
487+${buf([['kernels', 'read_write'], ['bplane', 'read'], ['__tick', 'read']])}
488+@compute @workgroup_size(1)
489+fn main() {
490+ var s = 0.0;
491+ for (var k = 0u; k < nx * ny; k++) {
492+ s += bplane[k];
493+ }
494+ let t = tick[0];
495+ if (t < ir_steps) {
496+ kernels[mx * ir_steps + t] = s;
497+ }
498+}
499+`,
500+ constants: ['nx', 'ny', 'mx', 'ir_steps'],
501+};
502+
503+/* ------------------------------------------------------------- render --- */
504+
505+/** Shared complex and slot helpers for the slots shaders. A slot is one
506+ * characteristic root ready to filter one kernel row:
507+ * [cRe, cIm, zRe, zIm, row, start], six f32 each. */
508+const SLOT_HELPERS = `
509+fn cmul(ar: f32, ai: f32, br: f32, bi: f32) -> vec2f {
510+ return vec2f(ar * br - ai * bi, ar * bi + ai * br);
511+}
512+// c = a * gain * (z - 2 + 1/z) = a * gain * (z-1)^2 / z, the acceleration
513+// series, with z-1 supplied stably (the naive z - 1 cancels).
514+fn acc_scale(p: vec4f, zm1Re: f32, gain: f32) -> vec4f {
515+ let m2 = p.z * p.z + p.w * p.w;
516+ if (m2 == 0.0) { return vec4f(0.0); }
517+ let sq = cmul(zm1Re, p.w, zm1Re, p.w);
518+ let num = cmul(sq.x, sq.y, p.z, -p.w);
519+ let c = cmul(p.x, p.y, gain * num.x / m2, gain * num.y / m2);
520+ return vec4f(c.x, c.y, p.z, p.w);
521+}
522+// c = a * gain * z, the next-displacement series (what a string's bridge
523+// slope is read from).
524+fn next_scale(p: vec4f, gain: f32) -> vec4f {
525+ let c = cmul(p.x, p.y, gain * p.z, gain * p.w);
526+ return vec4f(c.x, c.y, p.z, p.w);
527+}
528+`;
529+
530+const WRITE_SLOT = `
531+fn write_slot(idx: u32, p: vec4f, row: f32, start: f32) {
532+ slots[idx * 6u] = p.x;
533+ slots[idx * 6u + 1u] = p.y;
534+ slots[idx * 6u + 2u] = p.z;
535+ slots[idx * 6u + 3u] = p.w;
536+ slots[idx * 6u + 4u] = row;
537+ slots[idx * 6u + 5u] = start;
538+}
539+`;
540+
541+/**
542+ * The mode's recursion solved in closed form, written to avoid the f32
543+ * cancellations the naive discriminant carries. Restricted to eigenvalue
544+ * lam, the leapfrog is a[k+1] = cA a[k] + cB a[k-1] with
545+ * cA = (2 - x) / (1 + d), x = dt^2 w2 + 2 sig1 dt lam,
546+ * cB = (-(1 - d) + 2 sig1 dt lam) / (1 + d), d = (6.91 / t60) dt,
547+ * and the quantities the roots need reduce to
548+ * zRe = (1 - x/2)/(1+d), |z|^2 = (1 - g)/(1+d), g = d + 2 sig1 dt lam,
549+ * zIm^2 (1+d)^2 = N = dt^2 w2 - g d - x^2/4,
550+ * every term small and positive-definite where it matters, so the mode's
551+ * angle keeps full relative f32 precision (the naive cA^2 + 4 cB loses it).
552+ * N > 0 is the underdamped (audible) case; N <= 0 gives two real roots.
553+ * Excitation: a strike is a velocity (a[0] = 0, a[-1] = b), a pluck a held
554+ * displacement (a[0] = a[-1] = b).
555+ *
556+ * Leaves in scope: p0, p1 (displacement-series roots as [aRe, aIm, zRe,
557+ * zIm], zeroed when absent) and zm10, zm11 (each root's z - 1 real part,
558+ * stably).
559+ */
560+const rootsBlock = (excite: 'strike' | 'pluck'): string => `
561+ let d = (6.91 / t60) * dt;
562+ let opd = 1.0 + d;
563+ let g = d + 2.0 * sig1 * dt * lam;
564+ let x = dt * dt * w2 + 2.0 * sig1 * dt * lam;
565+ let zRe = (1.0 - 0.5 * x) / opd;
566+ let zm1Re = -(0.5 * x + d) / opd;
567+ let nn = dt * dt * w2 - g * d - 0.25 * x * x;
568+ var p0 = vec4f(0.0);
569+ var p1 = vec4f(0.0);
570+ var zm10 = 0.0;
571+ var zm11 = 0.0;
572+ if (nn > 0.0) {
573+ let zIm = sqrt(nn) / opd;
574+ let mag2 = (1.0 - g) / opd;
575+${excite === 'strike'
576+ ? ` let aRe = 0.0;
577+ let aIm = b * mag2 / zIm;`
578+ : ` let aRe = b;
579+ let aIm = b * ((0.5 * x - g) / opd) / zIm;`}
580+ p0 = vec4f(aRe, aIm, zRe, zIm);
581+ zm10 = zm1Re;
582+ } else {
583+ let sq = sqrt(-nn) / opd;
584+ let r1 = zRe + sq;
585+ let r2 = zRe - sq;
586+ if (r1 != r2 && r1 != 0.0 && r2 != 0.0) {
587+ let det = 1.0 / r1 - 1.0 / r2;
588+${excite === 'strike'
589+ ? ` let a1 = b / det;
590+ let a2 = -a1;`
591+ : ` let a1 = (b - b / r2) / det;
592+ let a2 = b - a1;`}
593+ p0 = vec4f(a1, 0.0, r1, 0.0);
594+ p1 = vec4f(a2, 0.0, r2, 0.0);
595+ zm10 = zm1Re + sq;
596+ zm11 = zm1Re - sq;
597+ }
598+ }`;
599+
600+/** Event-row accessors for the tuning parameters a shader needs. */
601+const evAccess = (tuning: ParamSpec[], needed: string[]): string =>
602+ needed
603+ .map((k) => {
604+ const j = tuning.findIndex((p) => p.key === k);
605+ if (j < 0) throw new Error(`event param '${k}' not in tuning list`);
606+ return ` let ${k} = ev[base + ${1 + j}u];`;
607+ })
608+ .join('\n');
609+
610+/** The per-axis excitation sums: for a strike, sum_i sin(m pi i/(n-1)) *
611+ * exp(-((i h - pos)/r)^2); a pluck replaces the Gaussian by the triangle.
612+ * One thread per (event, mode index). */
613+function excitationShader(
614+ object: ObjectKind,
615+ tuning: ParamSpec[],
616+ axis: 'x' | 'y',
617+): { code: string; constants: string[] } {
618+ const [n, m, L, hobj] = axis === 'x' ? ['n1', 'mx', 'L1', 'h1'] : ['n2', 'my', 'L2', 'h2'];
619+ let access: string;
620+ let term: string;
621+ if (object === 'string') {
622+ access = evAccess(tuning, ['pluckpos', 'amp']);
623+ term = ` let xi = f32(k) * ${hobj};
624+ let ta = xi / (pluckpos * ${L});
625+ let tb = (${L} - xi) / (${L} - pluckpos * ${L});
626+ let u0 = amp * 0.5 * (ta + tb - abs(ta - tb));`;
627+ } else {
628+ const pos = object === 'bar' ? 'strikepos' : axis === 'x' ? 'strikex' : 'strikey';
629+ access = evAccess(tuning, [pos, 'striker']);
630+ term = ` let xi = f32(k) * ${hobj};
631+ let dx = (xi - ${pos} * ${L}) / striker;
632+ let u0 = exp(-dx * dx);`;
633+ }
634+ const consts: [string, 'u32' | 'f32'][] = [
635+ [n, 'u32'], [m, 'u32'], ['ev_stride', 'u32'], [hobj, 'f32'], [L, 'f32'],
636+ ];
637+ const code = `// Project this axis's excitation profile onto each recorded sine mode:
638+// b${axis}[e * ${m} + (mode-1)]. One thread per (event, mode).
639+const PI = 3.14159265358979;
640+${ov(consts)}
641+${buf([['bout', 'read_write'], ['__events', 'read']])}
642+${MAIN}
643+ if (i >= ${m} * arrayLength(&ev) / ev_stride) { return; }
644+ let mi = i % ${m};
645+ let e = i / ${m};
646+ let base = e * ev_stride;
647+${access}
648+ let mm = f32(mi + 1u);
649+ var s = 0.0;
650+ for (var k = 1u; k < ${n} - 1u; k++) {
651+${term}
652+ s += sin(mm * PI * f32(k) / f32(${n} - 1u)) * u0;
653+ }
654+ bout[i] = s;
655+}
656+`;
657+ return { code, constants: consts.map(([q]) => q) };
658+}
659+
660+/** The slots shader: every recorded mode of every event solved in closed
661+ * form and scaled into filter slots. */
662+function slotsShader(object: ObjectKind, tuning: ParamSpec[]): { code: string; constants: string[] } {
663+ if (object === 'membrane') {
664+ const consts: [string, 'u32' | 'f32'][] = [
665+ ['mx', 'u32'], ['my', 'u32'], ['n_rows', 'u32'], ['n1', 'u32'], ['n2', 'u32'],
666+ ['ev_stride', 'u32'],
667+ ['h1', 'f32'], ['h2', 'f32'], ['L1', 'f32'], ['L2', 'f32'], ['dt', 'f32'],
668+ ];
669+ const code = `// One thread per (event, recorded mode): the mode's excitation amplitude,
670+// its recursion roots, and the acceleration-series slots that drive its
671+// kernel row.
672+const PI = 3.14159265358979;
673+${ov(consts)}
674+${buf([['slots', 'read_write'], ['__events', 'read'], ['bx', 'read'], ['by', 'read']])}
675+${SLOT_HELPERS}
676+${WRITE_SLOT}
677+${MAIN}
678+ let n_events = arrayLength(&ev) / ev_stride;
679+ if (i >= n_events * n_rows) { return; }
680+ let e = i / n_rows;
681+ let q = i % n_rows;
682+ let base = e * ev_stride;
683+ let start = ev[base];
684+${evAccess(tuning, ['f0', 't60', 'sig1', 'vel', 'gair'])}
685+ let mi = q % mx;
686+ let ni = q / mx;
687+ let m = f32(mi + 1u);
688+ let n = f32(ni + 1u);
689+ let b = bx[e * mx + mi] * by[e * my + ni] * (-(dt * vel)) * 4.0 / (f32(n1 - 1u) * f32(n2 - 1u));
690+ let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));
691+ let s2 = sin(n * PI / (2.0 * f32(n2 - 1u)));
692+ let lam = (4.0 / (h1 * h1)) * s1 * s1 + (4.0 / (h2 * h2)) * s2 * s2;
693+ let cm = 2.0 * f0 / sqrt(1.0 / (L1 * L1) + 1.0 / (L2 * L2));
694+ let w2 = cm * cm * lam;
695+${rootsBlock('strike')}
696+ let ga = gair / (dt * dt);
697+ write_slot(i * 2u, acc_scale(p0, zm10, ga), f32(q), start);
698+ write_slot(i * 2u + 1u, acc_scale(p1, zm11, ga), f32(q), start);
699+}
700+`;
701+ return { code, constants: consts.map(([q]) => q) };
702+ }
703+ if (object === 'bar') {
704+ const consts: [string, 'u32' | 'f32'][] = [
705+ ['mx', 'u32'], ['n1', 'u32'], ['ev_stride', 'u32'],
706+ ['h1', 'f32'], ['L1', 'f32'], ['dt', 'f32'],
707+ ];
708+ const code = `// One thread per (event, mode) of the bar: the tension-free stiff scheme,
709+// partials at n^2 f1 up to the grid's own dispersion.
710+const PI = 3.14159265358979;
711+${ov(consts)}
712+${buf([['slots', 'read_write'], ['__events', 'read'], ['bx', 'read']])}
713+${SLOT_HELPERS}
714+${WRITE_SLOT}
715+${MAIN}
716+ let n_events = arrayLength(&ev) / ev_stride;
717+ if (i >= n_events * mx) { return; }
718+ let e = i / mx;
719+ let mi = i % mx;
720+ let base = e * ev_stride;
721+ let start = ev[base];
722+${evAccess(tuning, ['f1', 't60', 'sig1', 'vel', 'gair'])}
723+ let m = f32(mi + 1u);
724+ let b = bx[i] * (-(dt * vel)) * 2.0 / f32(n1 - 1u);
725+ let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));
726+ let lam = (4.0 / (h1 * h1)) * s1 * s1;
727+ let kap = 2.0 * f1 * (L1 * L1) / PI;
728+ let w2 = kap * kap * lam * lam;
729+${rootsBlock('strike')}
730+ let ga = gair / (dt * dt);
731+ write_slot(i * 2u, acc_scale(p0, zm10, ga), f32(mi), start);
732+ write_slot(i * 2u + 1u, acc_scale(p1, zm11, ga), f32(mi), start);
733+}
734+`;
735+ return { code, constants: consts.map(([q]) => q) };
736+ }
737+ const consts: [string, 'u32' | 'f32'][] = [
738+ ['mx', 'u32'], ['n1', 'u32'], ['ev_stride', 'u32'],
739+ ['h1', 'f32'], ['L1', 'f32'], ['dt', 'f32'],
740+ ];
741+ const code = `// One thread per (event, mode) of the string: the stiff damped string's
742+// roots, its radiation slots, and the bridge slots its arriving slope
743+// drives (the last kernel row, row mx).
744+const PI = 3.14159265358979;
745+${ov(consts)}
746+${buf([['slots', 'read_write'], ['__events', 'read'], ['bx', 'read']])}
747+${SLOT_HELPERS}
748+${WRITE_SLOT}
749+${MAIN}
750+ let n_events = arrayLength(&ev) / ev_stride;
751+ if (i >= n_events * mx) { return; }
752+ let e = i / mx;
753+ let mi = i % mx;
754+ let base = e * ev_stride;
755+ let start = ev[base];
756+${evAccess(tuning, ['f0', 'B', 't60', 'sig1', 'gair', 'gbridge'])}
757+ let m = f32(mi + 1u);
758+ let b = bx[i] * 2.0 / f32(n1 - 1u);
759+ let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));
760+ let lam = (4.0 / (h1 * h1)) * s1 * s1;
761+ let cs = 2.0 * L1 * f0;
762+ let kap2 = B * (cs * L1 / PI) * (cs * L1 / PI);
763+ let w2 = cs * cs * lam + kap2 * lam * lam;
764+${rootsBlock('pluck')}
765+ let ga = gair / (dt * dt);
766+ write_slot(i * 4u, acc_scale(p0, zm10, ga), f32(mi), start);
767+ write_slot(i * 4u + 1u, acc_scale(p1, zm11, ga), f32(mi), start);
768+ // The bridge: the far end is pinned, so the arriving slope is
769+ // -u[n-2]/h1, and the generated step's term is
770+ // -(gbridge cs^2 / L) bprof fb; together, +gbridge cs^2/(L h1) times
771+ // sin(m pi (n1-2)/(n1-1)) times the next-displacement series.
772+ let gb = (gbridge * cs * cs / (L1 * h1)) * sin(m * PI * f32(n1 - 2u) / f32(n1 - 1u));
773+ write_slot(i * 4u + 2u, next_scale(p0, gb), f32(mx), start);
774+ write_slot(i * 4u + 3u, next_scale(p1, gb), f32(mx), start);
775+}
776+`;
777+ return { code, constants: consts.map(([q]) => q) };
778+}
779+
780+const synthShader = {
781+ code: `// One thread per slot: a complex one-pole over this tile of samples, the
782+// kernel row as input while it lasts and silence after (the closed-form
783+// tail). State persists across tiles.
784+${ov([['tile', 'u32'], ['total_steps', 'u32'], ['ir_steps', 'u32'], ['n_slots', 'u32']])}
785+${buf([['slots', 'read'], ['state', 'read_write'], ['kernels', 'read'], ['partial', 'read_write'], ['__tick', 'read']])}
786+${MAIN}
787+ if (i >= n_slots) { return; }
788+ let cRe = slots[i * 6u];
789+ let cIm = slots[i * 6u + 1u];
790+ let zRe = slots[i * 6u + 2u];
791+ let zIm = slots[i * 6u + 3u];
792+ let row = u32(slots[i * 6u + 4u]);
793+ let start = u32(slots[i * 6u + 5u]);
794+ var wRe = state[i * 2u];
795+ var wIm = state[i * 2u + 1u];
796+ let t0 = tick[0] * tile;
797+ for (var s = 0u; s < tile; s++) {
798+ let t = t0 + s;
799+ if (t >= total_steps) { break; }
800+ var hv = 0.0;
801+ if (t >= start && t - start < ir_steps) {
802+ hv = kernels[row * ir_steps + (t - start)];
803+ }
804+ let nRe = zRe * wRe - zIm * wIm + hv;
805+ let nIm = zRe * wIm + zIm * wRe;
806+ wRe = nRe;
807+ wIm = nIm;
808+ partial[i * tile + s] = cRe * wRe - cIm * wIm;
809+ }
810+ state[i * 2u] = wRe;
811+ state[i * 2u + 1u] = wIm;
812+}
813+`,
814+ constants: ['tile', 'total_steps', 'ir_steps', 'n_slots'],
815+};
816+
817+const reduceShader = {
818+ code: `// One thread per sample of the tile: sum every slot's contribution.
819+${ov([['tile', 'u32'], ['total_steps', 'u32'], ['n_slots', 'u32']])}
820+${buf([['partial', 'read'], ['trace', 'read_write'], ['__tick', 'read']])}
821+${MAIN}
822+ if (i >= tile) { return; }
823+ let t = tick[0] * tile + i;
824+ if (t >= total_steps) { return; }
825+ var s = 0.0;
826+ for (var q = 0u; q < n_slots; q++) {
827+ s += partial[q * tile + i];
828+ }
829+ trace[t] = s;
830+}
831+`,
832+ constants: ['tile', 'total_steps', 'n_slots'],
833+};
834+
835+/* ----------------------------------------------------------- assembly --- */
836+
837+export function buildInstrument(opts: BuildOptions): InstrumentFile {
838+ const { object, body } = opts;
839+ const op = objectParams[object];
840+ const params: ParamSpec[] = [
841+ { ...P('nx', 'air cells along x', '1', 96, 32, 256, 'accuracy',
842+ 'The air grid; ny = nz = nx/2. The simulation is honest to about c/(8h). Must be even.'), step: 2 },
843+ P('Lx', 'domain length', 'm', 1.0, 0.6, 2.0, 'geometry',
844+ 'The air box around the instrument, twice as long in x as in y and z.'),
845+ P('ir_seconds', 'room response length', 's', 0.5, 0.1, 2.0, 'accuracy',
846+ 'How long the recorded room responses run; a room still ringing past this is truncated.'),
847+ ...(object === 'membrane'
848+ ? [P('mode_margin', 'mode set margin', '1', 2.0, 1.0, 4.0, 'accuracy',
849+ 'Head modes are recorded up to this multiple of what the air grid spatially resolves.')]
850+ : []),
851+ ...op.geometry,
852+ ...bodyParams[body],
853+ P('mic_x', 'microphone x', 'm', 0.15, -0.45, 0.45, 'geometry'),
854+ P('mic_y', 'microphone y', 'm', 0.1, -0.2, 0.2, 'geometry'),
855+ P('mic_z', 'microphone z', 'm', 0.12, -0.2, 0.2, 'geometry'),
856+ ...op.tuning,
857+ ];
858+ for (const [k, v] of Object.entries(opts.defaults ?? {})) {
859+ const p = params.find((q) => q.key === k);
860+ if (!p) throw new Error(`default for unknown parameter '${k}'`);
861+ p.value = v;
862+ }
863+ const tuning = op.tuning;
864+ const evStride = 1 + tuning.length;
865+
866+ const shaders: Record<string, { code: string; constants: string[] }> = {
867+ medium: mediumShader(object, body),
868+ sines_x: sinesShader('x'),
869+ impulse: impulseShader,
870+ step: stepShader,
871+ plane: planeShader('prof', 'plane'),
872+ xc: xcShader,
873+ proj: projShader(object),
874+ excite_x: excitationShader(object, tuning, 'x'),
875+ slots: slotsShader(object, tuning),
876+ synth: synthShader,
877+ reduce: reduceShader,
878+ };
879+ if (object === 'membrane') {
880+ shaders.sines_y = sinesShader('y');
881+ shaders.excite_y = excitationShader(object, tuning, 'y');
882+ }
883+ if (object === 'string') {
884+ shaders.bplane = planeShader('bprof', 'bplane');
885+ shaders.bridge_proj = bridgeProjShader;
886+ }
887+
888+ const preBuffers = [
889+ { name: 'wall', count: 'npts' },
890+ { name: 'sig', count: 'npts' },
891+ { name: 'prof', count: 'npts' },
892+ ...(object === 'string' ? [{ name: 'bprof', count: 'npts' }] : []),
893+ { name: 'sx', count: 'mx * nx' },
894+ ...(object === 'membrane' ? [{ name: 'sy', count: 'my * ny' }] : []),
895+ { name: 'p', count: 'npts' },
896+ { name: 'pm', count: 'npts' },
897+ { name: 'pn', count: 'npts' },
898+ { name: 'plane', count: 'nx * ny' },
899+ ...(object === 'string' ? [{ name: 'bplane', count: 'nx * ny' }] : []),
900+ { name: 'tmp', count: 'mx * ny' },
901+ { name: 'kernels', count: 'n_rows * ir_steps' },
902+ ];
903+ const mediumOut = ['wall', 'sig', 'prof', ...(object === 'string' ? ['bprof'] : [])];
904+ const preSetup: PassSpec[] = [
905+ { shader: 'medium', buffers: mediumOut, dispatch: 'npts' },
906+ { shader: 'sines_x', buffers: ['sx'], dispatch: 'mx * nx' },
907+ ...(object === 'membrane'
908+ ? [{ shader: 'sines_y', buffers: ['sy'], dispatch: 'my * ny' }]
909+ : []),
910+ { shader: 'impulse', buffers: ['p', 'wall', 'sig'], dispatch: '1' },
911+ ];
912+ const preLoop: PassSpec[] = [
913+ { shader: 'plane', buffers: ['plane', 'p', 'prof'], dispatch: 'nx * ny' },
914+ { shader: 'xc', buffers: ['tmp', 'plane', 'sx'], dispatch: 'mx * ny' },
915+ object === 'membrane'
916+ ? { shader: 'proj', buffers: ['kernels', 'tmp', 'sy', '__tick'], dispatch: 'n_rows' }
917+ : { shader: 'proj', buffers: ['kernels', 'tmp', '__tick'], dispatch: 'mx' },
918+ ...(object === 'string'
919+ ? [
920+ { shader: 'bplane', buffers: ['bplane', 'p', 'bprof'], dispatch: 'nx * ny' },
921+ { shader: 'bridge_proj', buffers: ['kernels', 'bplane', '__tick'], dispatch: '1' },
922+ ]
923+ : []),
924+ { shader: 'step', buffers: ['p', 'pm', 'pn', 'wall', 'sig'], dispatch: 'npts' },
925+ ];
926+
927+ const slotsPer = object === 'string' ? 4 : 2;
928+ const slotsCount = object === 'membrane' ? 'n_events * n_rows * 2' : `n_events * mx * ${slotsPer}`;
929+ const renBuffers = [
930+ { name: 'kernels', count: 'n_rows * ir_steps' },
931+ { name: 'bx', count: 'n_events * mx' },
932+ ...(object === 'membrane' ? [{ name: 'by', count: 'n_events * my' }] : []),
933+ { name: 'slots', count: 'n_slots * 6' },
934+ { name: 'state', count: 'n_slots * 2' },
935+ { name: 'partial', count: 'n_slots * tile' },
936+ { name: 'trace', count: 'total_steps' },
937+ ];
938+ const MIC: [number, number, number] = [0.3, 0.85, 1.0];
939+ const OUTLINE: [number, number, number] = [1.0, 0.85, 0.3];
940+ const markers: MarkerSpec[] = [
941+ object === 'membrane'
942+ ? {
943+ kind: 'rect',
944+ corner: ['-L1/2', '-L2/2', 'oz'],
945+ du: ['L1', '0', '0'],
946+ dv: ['0', 'L2', '0'],
947+ color: OUTLINE,
948+ label: 'head',
949+ }
950+ : {
951+ kind: 'line',
952+ from: ['-L1/2', 'oy', 'oz'],
953+ to: ['L1/2', 'oy', 'oz'],
954+ color: OUTLINE,
955+ label: object,
956+ },
957+ { kind: 'point', at: ['mic_x', 'mic_y', 'mic_z'], color: MIC, label: 'mic' },
958+ ];
959+ const visual: VisualSpec = {
960+ dims: ['nx', 'ny', 'nz'],
961+ extents: ['Lx', 'Ly', 'Lz'],
962+ buffers: [
963+ { name: 'wall', count: 'npts' },
964+ { name: 'sig', count: 'npts' },
965+ { name: 'prof', count: 'npts' },
966+ ...(object === 'string' ? [{ name: 'bprof', count: 'npts' }] : []),
967+ ],
968+ setup: [{ shader: 'medium', buffers: mediumOut, dispatch: 'npts' }],
969+ volumes: [
970+ // The body is the medium shader's own mask, so what the viewer shows
971+ // is what the solver sees, grid effects included.
972+ { buffer: 'wall', style: 'solid', invert: true, color: [0.62, 0.48, 0.34] },
973+ { buffer: 'prof', style: 'cloud', color: [1.0, 0.45, 0.2] },
974+ ...(object === 'string'
975+ ? [{ buffer: 'bprof', style: 'cloud' as const, color: [0.35, 0.65, 1.0] as [number, number, number] }]
976+ : []),
977+ ],
978+ markers,
979+ partials: { buffer: 'slots', stride: 6, zRe: 2, zIm: 3 },
980+ };
981+
982+ const renSetup: PassSpec[] = [
983+ { shader: 'excite_x', buffers: ['bx', '__events'], dispatch: 'n_events * mx' },
984+ ...(object === 'membrane'
985+ ? [{ shader: 'excite_y', buffers: ['by', '__events'], dispatch: 'n_events * my' }]
986+ : []),
987+ object === 'membrane'
988+ ? { shader: 'slots', buffers: ['slots', '__events', 'bx', 'by'], dispatch: 'n_events * n_rows' }
989+ : { shader: 'slots', buffers: ['slots', '__events', 'bx'], dispatch: 'n_events * mx' },
990+ ];
991+
992+ return {
993+ instrawave: 1,
994+ name: opts.name,
995+ description: opts.description,
996+ params,
997+ derived: derivedOf(object),
998+ shaders,
999+ precompute: {
1000+ buffers: preBuffers,
1001+ setup: preSetup,
1002+ loop: { count: 'ir_steps', batch: 512, rotate: [['p', 'pm', 'pn']], body: preLoop },
1003+ outputs: ['kernels'],
1004+ },
1005+ render: {
1006+ buffers: renBuffers,
1007+ inputs: ['kernels'],
1008+ eventParams: tuning.map((p) => p.key),
1009+ derived: { ev_stride: `${evStride}`, n_slots: slotsCount },
1010+ setup: renSetup,
1011+ loop: {
1012+ count: 'n_tiles',
1013+ batch: 8,
1014+ body: [
1015+ { shader: 'synth', buffers: ['slots', 'state', 'kernels', 'partial', '__tick'], dispatch: 'n_slots' },
1016+ { shader: 'reduce', buffers: ['partial', 'trace', '__tick'], dispatch: 'tile' },
1017+ ],
1018+ },
1019+ trace: 'trace',
1020+ },
1021+ visual,
1022+ };
1023+}
index.htmladded+86−0View file
@@ -0,0 +1,86 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <title>instrawave</title>
7+ <style>
8+ :root {
9+ color-scheme: light dark;
10+ font-family: system-ui, sans-serif;
11+ }
12+ body {
13+ margin: 0 auto;
14+ max-width: 72rem;
15+ padding: 1rem 1.5rem 4rem;
16+ line-height: 1.45;
17+ }
18+ h1 { font-size: 1.3rem; margin: 0.3rem 0 0; }
19+ h1 + p { margin-top: 0.2rem; opacity: 0.75; }
20+ h2 { font-size: 0.95rem; margin: 1.2rem 0 0.4rem; }
21+ .cost { font-weight: normal; opacity: 0.6; font-size: 0.8rem; }
22+ .top { display: grid; grid-template-columns: minmax(0, 1fr) 26rem; gap: 0 1.5rem; align-items: start; }
23+ @media (max-width: 56rem) { .top { grid-template-columns: 1fr; } }
24+ .scene { position: relative; width: 26rem; max-width: 100%; aspect-ratio: 4 / 3; margin-top: 1.2rem; }
25+ .scene canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 6px; }
26+ .scene .hint { position: absolute; right: 0.5rem; bottom: 0.3rem; font-size: 0.7rem; color: rgba(255,255,255,0.45); }
27+ .params { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 0.3rem 1.5rem; }
28+ .param { display: grid; font-size: 0.85rem; }
29+ .param label { display: flex; justify-content: space-between; }
30+ .param label span:last-child { opacity: 0.7; font-variant-numeric: tabular-nums; }
31+ .param input[type='range'] { width: 100%; }
32+ .bar { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; margin: 1rem 0 0.4rem; }
33+ button { font: inherit; padding: 0.35rem 0.9rem; }
34+ progress { width: 10rem; }
35+ #status { font-size: 0.85rem; opacity: 0.8; white-space: pre-wrap; }
36+ textarea { width: 100%; font: 0.8rem/1.4 ui-monospace, monospace; min-height: 3.2rem; box-sizing: border-box; }
37+ select, input[type='number'] { font: inherit; }
38+ .stale { color: #b45309; }
39+ .ready { color: #15803d; }
40+ .plots canvas { width: 100%; border-radius: 6px; display: block; margin-top: 0.6rem; }
41+ </style>
42+ </head>
43+ <body>
44+ <h1>instrawave</h1>
45+ <p>
46+ Instruments as files: each carries its parameters (geometry and accuracy feed a
47+ precomputed room; tuning is free) and the raw WebGPU programs for both steps.
48+ </p>
49+ <div class="bar">
50+ <label>instrument <select id="instrument"></select></label>
51+ <span id="gridinfo" style="font-size: 0.8rem; opacity: 0.7"></span>
52+ </div>
53+ <div class="top">
54+ <div>
55+ <h2>Tuning <span class="cost">free; renders straight from the bake</span></h2>
56+ <div class="params" id="tuning"></div>
57+ <h2>Geometry & accuracy <span class="cost">changing one invalidates the bake</span></h2>
58+ <div class="params" id="geometry"></div>
59+ </div>
60+ <div class="scene" id="scene">
61+ <canvas id="volume"></canvas>
62+ <canvas id="overlay"></canvas>
63+ <span class="hint">drag to orbit · wheel to zoom</span>
64+ </div>
65+ </div>
66+ <h2>Stimuli <span class="cost" id="evhint">JSON list; each event may override tuning parameters</span></h2>
67+ <textarea id="events" spellcheck="false">[{"t": 0}]</textarea>
68+ <div class="bar">
69+ <button id="precompute">Precompute</button>
70+ <span id="bakestate"></span>
71+ <progress id="progress" max="1" value="0" hidden></progress>
72+ <button id="cancel" hidden>Cancel</button>
73+ <span style="flex: 1"></span>
74+ <label>seconds <input id="seconds" type="number" value="4" min="0.2" max="60" step="0.2" style="width: 4.5rem" /></label>
75+ <button id="render" disabled>Render</button>
76+ <button id="play" disabled>Play</button>
77+ <button id="save" disabled>Save .wav</button>
78+ </div>
79+ <p id="status"></p>
80+ <div class="plots">
81+ <canvas id="trace" height="140" hidden></canvas>
82+ <canvas id="spectrum" height="220" hidden></canvas>
83+ </div>
84+ <script type="module" src="./src/main.ts"></script>
85+ </body>
86+</html>
instruments/chime.instrument.jsonadded+602−0View file
@@ -0,0 +1,602 @@
1+{
2+ "instrawave": 1,
3+ "name": "chime",
4+ "description": "A stiff bar in open air, struck. No tension, so the stiffness sets the partials at n²·f1, which is the bell-like sound.",
5+ "params": [
6+ {
7+ "key": "nx",
8+ "label": "air cells along x",
9+ "unit": "1",
10+ "value": 96,
11+ "min": 32,
12+ "max": 256,
13+ "class": "accuracy",
14+ "hint": "The air grid; ny = nz = nx/2. The simulation is honest to about c/(8h). Must be even.",
15+ "step": 2
16+ },
17+ {
18+ "key": "Lx",
19+ "label": "domain length",
20+ "unit": "m",
21+ "value": 1,
22+ "min": 0.6,
23+ "max": 2,
24+ "class": "geometry",
25+ "hint": "The air box around the instrument, twice as long in x as in y and z."
26+ },
27+ {
28+ "key": "ir_seconds",
29+ "label": "room response length",
30+ "unit": "s",
31+ "value": 0.5,
32+ "min": 0.1,
33+ "max": 2,
34+ "class": "accuracy",
35+ "hint": "How long the recorded room responses run; a room still ringing past this is truncated."
36+ },
37+ {
38+ "key": "L1",
39+ "label": "length",
40+ "unit": "m",
41+ "value": 0.3,
42+ "min": 0.15,
43+ "max": 0.5,
44+ "class": "geometry"
45+ },
46+ {
47+ "key": "oy",
48+ "label": "across",
49+ "unit": "m",
50+ "value": 0,
51+ "min": -0.15,
52+ "max": 0.15,
53+ "class": "geometry"
54+ },
55+ {
56+ "key": "oz",
57+ "label": "height",
58+ "unit": "m",
59+ "value": 0.05,
60+ "min": 0.005,
61+ "max": 0.15,
62+ "class": "geometry"
63+ },
64+ {
65+ "key": "mic_x",
66+ "label": "microphone x",
67+ "unit": "m",
68+ "value": 0.12,
69+ "min": -0.45,
70+ "max": 0.45,
71+ "class": "geometry"
72+ },
73+ {
74+ "key": "mic_y",
75+ "label": "microphone y",
76+ "unit": "m",
77+ "value": 0.08,
78+ "min": -0.2,
79+ "max": 0.2,
80+ "class": "geometry"
81+ },
82+ {
83+ "key": "mic_z",
84+ "label": "microphone z",
85+ "unit": "m",
86+ "value": 0.1,
87+ "min": -0.2,
88+ "max": 0.2,
89+ "class": "geometry"
90+ },
91+ {
92+ "key": "f1",
93+ "label": "pitch",
94+ "unit": "Hz",
95+ "value": 780,
96+ "min": 200,
97+ "max": 2000,
98+ "class": "tuning",
99+ "hint": "The first partial; higher ones land at n^2 f1."
100+ },
101+ {
102+ "key": "t60",
103+ "label": "decay t60",
104+ "unit": "s",
105+ "value": 6,
106+ "min": 0.5,
107+ "max": 12,
108+ "class": "tuning"
109+ },
110+ {
111+ "key": "sig1",
112+ "label": "brightness decay",
113+ "unit": "m^2/s",
114+ "value": 0.001,
115+ "min": 0,
116+ "max": 0.02,
117+ "class": "tuning"
118+ },
119+ {
120+ "key": "strikepos",
121+ "label": "strike position",
122+ "unit": "1",
123+ "value": 0.3,
124+ "min": 0.1,
125+ "max": 0.9,
126+ "class": "tuning"
127+ },
128+ {
129+ "key": "striker",
130+ "label": "mallet width",
131+ "unit": "m",
132+ "value": 0.025,
133+ "min": 0.01,
134+ "max": 0.08,
135+ "class": "tuning"
136+ },
137+ {
138+ "key": "vel",
139+ "label": "strike speed",
140+ "unit": "m/s",
141+ "value": 1,
142+ "min": 0.1,
143+ "max": 5,
144+ "class": "tuning"
145+ },
146+ {
147+ "key": "gair",
148+ "label": "radiation gain",
149+ "unit": "1",
150+ "value": 1,
151+ "min": 0,
152+ "max": 2,
153+ "class": "tuning"
154+ }
155+ ],
156+ "derived": {
157+ "c0": "343",
158+ "cfl": "0.5",
159+ "ny": "nx / 2",
160+ "nz": "nx / 2",
161+ "npts": "nx * ny * nz",
162+ "h": "Lx / nx",
163+ "Ly": "ny * h",
164+ "Lz": "nz * h",
165+ "dt": "cfl * h / (sqrt(3) * c0)",
166+ "ir_steps": "max(1, round(ir_seconds / dt))",
167+ "mic_index": "clamp(round((mic_x + Lx/2) / h - 0.5), 0, nx - 1) + nx * (clamp(round((mic_y + Ly/2) / h - 0.5), 0, ny - 1) + ny * clamp(round((mic_z + Lz/2) / h - 0.5), 0, nz - 1))",
168+ "kap_worst": "2 * f1__max * (L1 * L1) / pi",
169+ "kap2_worst": "kap_worst * kap_worst",
170+ "cs_worst": "0",
171+ "a_worst": "cs_worst*cs_worst*dt*dt + 4*sig1__max*dt",
172+ "h_min": "sqrt((a_worst + sqrt(a_worst*a_worst + 16*kap2_worst*dt*dt)) / 2) / 0.95",
173+ "n1": "max(5, min(1024, floor(L1 / h_min) + 1))",
174+ "h1": "L1 / (n1 - 1)",
175+ "mx": "n1 - 2",
176+ "n_rows": "mx"
177+ },
178+ "shaders": {
179+ "medium": {
180+ "code": "// The medium: the body's rigid mask, the absorption, and where the object\n// couples into the air. One thread per air cell.\nconst PI = 3.14159265358979;\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride h: f32;\noverride Lx: f32;\noverride Ly: f32;\noverride Lz: f32;\noverride oy: f32;\noverride oz: f32;\noverride L1: f32;\n@group(0) @binding(0) var<storage, read_write> wall: array<f32>;\n@group(0) @binding(1) var<storage, read_write> sig: array<f32>;\n@group(0) @binding(2) var<storage, read_write> prof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i % nx;\n let iy = (i / nx) % ny;\n let iz = i / (nx * ny);\n let x = -Lx/2.0 + (f32(ix) + 0.5) * h;\n let y = -Ly/2.0 + (f32(iy) + 0.5) * h;\n let z = -Lz/2.0 + (f32(iz) + 0.5) * h;\n let sw = 0.45 * h;\n let shell = 0.0;\n let w = 1.0 - shell;\n wall[i] = w;\n // Open boundary at the domain edge (a quadratic absorbing ramp over the\n // outer 0.15 Ly of each face), plus the body's lossy surface skin.\n let spw = 0.15 * Ly;\n let dx = max(0.0, spw - (Lx/2.0 - abs(x))) / spw;\n let dy = max(0.0, spw - (Ly/2.0 - abs(y))) / spw;\n let dz = max(0.0, spw - (Lz/2.0 - abs(z))) / spw;\n let d = max(max(dx, dy), dz);\n sig[i] = 9000.0 * d * d;\n // The object radiates from its line: a tube two cells wide around\n // (y = oy, z = oz), fading at its ends, cross-section normalized to one.\n let aw = 2.0 * h;\n let tube = exp(-((y - oy)*(y - oy) + (z - oz)*(z - oz)) / (aw*aw)) / (PI * aw * aw);\n let span = 0.5 * (1.0 + tanh((L1/2.0 - abs(x)) / (1.5*h)));\n prof[i] = tube * span * w;\n\n}\n",
181+ "constants": [
182+ "npts",
183+ "nx",
184+ "ny",
185+ "h",
186+ "Lx",
187+ "Ly",
188+ "Lz",
189+ "oy",
190+ "oz",
191+ "L1"
192+ ]
193+ },
194+ "sines_x": {
195+ "code": "// Sine mode m of the object grid, interpolated to air\n// x centers: sx[(mode-1) * nx + ix]. One thread per table entry.\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride nx: u32;\noverride L1: f32;\noverride h1: f32;\noverride Lx: f32;\noverride h: f32;\n@group(0) @binding(0) var<storage, read_write> table: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * nx) { return; }\n let mi = i % mx;\n let ia = i / mx;\n let xw = -Lx/2.0 + (f32(ia) + 0.5) * h;\n let u = (xw + L1/2.0) / h1;\n let j = i32(floor(u));\n var v = 0.0;\n if (j >= 0 && j < i32(n1) - 1) {\n let f = u - f32(j);\n let mm = f32(mi + 1u);\n let s0 = sin(mm * PI * f32(j) / f32(n1 - 1u));\n let s1 = sin(mm * PI * f32(j + 1) / f32(n1 - 1u));\n v = (1.0 - f) * s0 + f * s1;\n }\n table[mi * nx + ia] = v;\n}\n",
196+ "constants": [
197+ "n1",
198+ "mx",
199+ "nx",
200+ "L1",
201+ "h1",
202+ "Lx",
203+ "h"
204+ ]
205+ },
206+ "impulse": {
207+ "code": "// The reciprocal kick: one unit impulse at the microphone cell, entering\n// the way a source enters the first step. One thread.\noverride mic_index: u32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> p: array<f32>;\n@group(0) @binding(1) var<storage, read> wall: array<f32>;\n@group(0) @binding(2) var<storage, read> sig: array<f32>;\n@compute @workgroup_size(1)\nfn main() {\n p[mic_index] = dt * dt * wall[mic_index] / (1.0 + sig[mic_index] * dt);\n}\n",
208+ "constants": [
209+ "mic_index",
210+ "dt"
211+ ]
212+ },
213+ "step": {
214+ "code": "// One leapfrog step of the air: the wall-masked Laplacian (each stencil\n// face scaled by the mask at the neighbour it reads, the rigid condition in\n// divergence form) fused with the update. One thread per air cell.\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\noverride h: f32;\noverride dt: f32;\noverride c0: f32;\n@group(0) @binding(0) var<storage, read> p: array<f32>;\n@group(0) @binding(1) var<storage, read> pm: array<f32>;\n@group(0) @binding(2) var<storage, read_write> pn: array<f32>;\n@group(0) @binding(3) var<storage, read> wall: array<f32>;\n@group(0) @binding(4) var<storage, read> sig: array<f32>;\nfn face(ix: i32, iy: i32, iz: i32, pc: f32) -> f32 {\n if (ix < 0 || ix >= i32(nx) || iy < 0 || iy >= i32(ny) || iz < 0 || iz >= i32(nz)) {\n return -pc; // outside the domain: p = 0, open\n }\n let k = u32(ix) + nx * (u32(iy) + ny * u32(iz));\n return wall[k] * (p[k] - pc);\n}\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i32(i % nx);\n let iy = i32((i / nx) % ny);\n let iz = i32(i / (nx * ny));\n let pc = p[i];\n let s = face(ix - 1, iy, iz, pc) + face(ix + 1, iy, iz, pc)\n + face(ix, iy - 1, iz, pc) + face(ix, iy + 1, iz, pc)\n + face(ix, iy, iz - 1, pc) + face(ix, iy, iz + 1, pc);\n let lp = wall[i] * s / (h * h);\n let sd = sig[i] * dt;\n pn[i] = wall[i] * ((2.0 * pc - (1.0 - sd) * pm[i] + (c0 * dt) * (c0 * dt) * lp) / (1.0 + sd));\n}\n",
215+ "constants": [
216+ "npts",
217+ "nx",
218+ "ny",
219+ "nz",
220+ "h",
221+ "dt",
222+ "c0"
223+ ]
224+ },
225+ "plane": {
226+ "code": "// Contract the field against the radiation profile over z:\n// one thread per air column.\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\n@group(0) @binding(0) var<storage, read_write> plane: array<f32>;\n@group(0) @binding(1) var<storage, read> p: array<f32>;\n@group(0) @binding(2) var<storage, read> prof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= nx * ny) { return; }\n var s = 0.0;\n for (var iz = 0u; iz < nz; iz++) {\n let k = i + nx * ny * iz;\n s += prof[k] * p[k];\n }\n plane[i] = s;\n}\n",
227+ "constants": [
228+ "nx",
229+ "ny",
230+ "nz"
231+ ]
232+ },
233+ "xc": {
234+ "code": "// Contract the plane against every x sine row: tmp[m + mx * iy].\noverride nx: u32;\noverride ny: u32;\noverride mx: u32;\n@group(0) @binding(0) var<storage, read_write> tmp: array<f32>;\n@group(0) @binding(1) var<storage, read> plane: array<f32>;\n@group(0) @binding(2) var<storage, read> sx: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * ny) { return; }\n let m = i % mx;\n let iy = i / mx;\n var s = 0.0;\n for (var ix = 0u; ix < nx; ix++) {\n s += sx[m * nx + ix] * plane[ix + nx * iy];\n }\n tmp[i] = s;\n}\n",
235+ "constants": [
236+ "nx",
237+ "ny",
238+ "mx"
239+ ]
240+ },
241+ "proj": {
242+ "code": "// A line object's y factor is one everywhere: finish by summing tmp over\n// iy and write this step's sample of every kernel row.\noverride ny: u32;\noverride mx: u32;\noverride ir_steps: u32;\n@group(0) @binding(0) var<storage, read_write> kernels: array<f32>;\n@group(0) @binding(1) var<storage, read> tmp: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx) { return; }\n var s = 0.0;\n for (var iy = 0u; iy < ny; iy++) {\n s += tmp[i + mx * iy];\n }\n let t = tick[0];\n if (t < ir_steps) {\n kernels[i * ir_steps + t] = s;\n }\n}\n",
243+ "constants": [
244+ "ny",
245+ "mx",
246+ "ir_steps"
247+ ]
248+ },
249+ "excite_x": {
250+ "code": "// Project this axis's excitation profile onto each recorded sine mode:\n// bx[e * mx + (mode-1)]. One thread per (event, mode).\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride L1: f32;\n@group(0) @binding(0) var<storage, read_write> bout: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * arrayLength(&ev) / ev_stride) { return; }\n let mi = i % mx;\n let e = i / mx;\n let base = e * ev_stride;\n let strikepos = ev[base + 4u];\n let striker = ev[base + 5u];\n let mm = f32(mi + 1u);\n var s = 0.0;\n for (var k = 1u; k < n1 - 1u; k++) {\n let xi = f32(k) * h1;\n let dx = (xi - strikepos * L1) / striker;\n let u0 = exp(-dx * dx);\n s += sin(mm * PI * f32(k) / f32(n1 - 1u)) * u0;\n }\n bout[i] = s;\n}\n",
251+ "constants": [
252+ "n1",
253+ "mx",
254+ "ev_stride",
255+ "h1",
256+ "L1"
257+ ]
258+ },
259+ "slots": {
260+ "code": "// One thread per (event, mode) of the bar: the tension-free stiff scheme,\n// partials at n^2 f1 up to the grid's own dispersion.\nconst PI = 3.14159265358979;\noverride mx: u32;\noverride n1: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride L1: f32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> slots: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@group(0) @binding(2) var<storage, read> bx: array<f32>;\n\nfn cmul(ar: f32, ai: f32, br: f32, bi: f32) -> vec2f {\n return vec2f(ar * br - ai * bi, ar * bi + ai * br);\n}\n// c = a * gain * (z - 2 + 1/z) = a * gain * (z-1)^2 / z, the acceleration\n// series, with z-1 supplied stably (the naive z - 1 cancels).\nfn acc_scale(p: vec4f, zm1Re: f32, gain: f32) -> vec4f {\n let m2 = p.z * p.z + p.w * p.w;\n if (m2 == 0.0) { return vec4f(0.0); }\n let sq = cmul(zm1Re, p.w, zm1Re, p.w);\n let num = cmul(sq.x, sq.y, p.z, -p.w);\n let c = cmul(p.x, p.y, gain * num.x / m2, gain * num.y / m2);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n// c = a * gain * z, the next-displacement series (what a string's bridge\n// slope is read from).\nfn next_scale(p: vec4f, gain: f32) -> vec4f {\n let c = cmul(p.x, p.y, gain * p.z, gain * p.w);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n\n\nfn write_slot(idx: u32, p: vec4f, row: f32, start: f32) {\n slots[idx * 6u] = p.x;\n slots[idx * 6u + 1u] = p.y;\n slots[idx * 6u + 2u] = p.z;\n slots[idx * 6u + 3u] = p.w;\n slots[idx * 6u + 4u] = row;\n slots[idx * 6u + 5u] = start;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n let n_events = arrayLength(&ev) / ev_stride;\n if (i >= n_events * mx) { return; }\n let e = i / mx;\n let mi = i % mx;\n let base = e * ev_stride;\n let start = ev[base];\n let f1 = ev[base + 1u];\n let t60 = ev[base + 2u];\n let sig1 = ev[base + 3u];\n let vel = ev[base + 6u];\n let gair = ev[base + 7u];\n let m = f32(mi + 1u);\n let b = bx[i] * (-(dt * vel)) * 2.0 / f32(n1 - 1u);\n let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));\n let lam = (4.0 / (h1 * h1)) * s1 * s1;\n let kap = 2.0 * f1 * (L1 * L1) / PI;\n let w2 = kap * kap * lam * lam;\n\n let d = (6.91 / t60) * dt;\n let opd = 1.0 + d;\n let g = d + 2.0 * sig1 * dt * lam;\n let x = dt * dt * w2 + 2.0 * sig1 * dt * lam;\n let zRe = (1.0 - 0.5 * x) / opd;\n let zm1Re = -(0.5 * x + d) / opd;\n let nn = dt * dt * w2 - g * d - 0.25 * x * x;\n var p0 = vec4f(0.0);\n var p1 = vec4f(0.0);\n var zm10 = 0.0;\n var zm11 = 0.0;\n if (nn > 0.0) {\n let zIm = sqrt(nn) / opd;\n let mag2 = (1.0 - g) / opd;\n let aRe = 0.0;\n let aIm = b * mag2 / zIm;\n p0 = vec4f(aRe, aIm, zRe, zIm);\n zm10 = zm1Re;\n } else {\n let sq = sqrt(-nn) / opd;\n let r1 = zRe + sq;\n let r2 = zRe - sq;\n if (r1 != r2 && r1 != 0.0 && r2 != 0.0) {\n let det = 1.0 / r1 - 1.0 / r2;\n let a1 = b / det;\n let a2 = -a1;\n p0 = vec4f(a1, 0.0, r1, 0.0);\n p1 = vec4f(a2, 0.0, r2, 0.0);\n zm10 = zm1Re + sq;\n zm11 = zm1Re - sq;\n }\n }\n let ga = gair / (dt * dt);\n write_slot(i * 2u, acc_scale(p0, zm10, ga), f32(mi), start);\n write_slot(i * 2u + 1u, acc_scale(p1, zm11, ga), f32(mi), start);\n}\n",
261+ "constants": [
262+ "mx",
263+ "n1",
264+ "ev_stride",
265+ "h1",
266+ "L1",
267+ "dt"
268+ ]
269+ },
270+ "synth": {
271+ "code": "// One thread per slot: a complex one-pole over this tile of samples, the\n// kernel row as input while it lasts and silence after (the closed-form\n// tail). State persists across tiles.\noverride tile: u32;\noverride total_steps: u32;\noverride ir_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> slots: array<f32>;\n@group(0) @binding(1) var<storage, read_write> state: array<f32>;\n@group(0) @binding(2) var<storage, read> kernels: array<f32>;\n@group(0) @binding(3) var<storage, read_write> partial: array<f32>;\n@group(0) @binding(4) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= n_slots) { return; }\n let cRe = slots[i * 6u];\n let cIm = slots[i * 6u + 1u];\n let zRe = slots[i * 6u + 2u];\n let zIm = slots[i * 6u + 3u];\n let row = u32(slots[i * 6u + 4u]);\n let start = u32(slots[i * 6u + 5u]);\n var wRe = state[i * 2u];\n var wIm = state[i * 2u + 1u];\n let t0 = tick[0] * tile;\n for (var s = 0u; s < tile; s++) {\n let t = t0 + s;\n if (t >= total_steps) { break; }\n var hv = 0.0;\n if (t >= start && t - start < ir_steps) {\n hv = kernels[row * ir_steps + (t - start)];\n }\n let nRe = zRe * wRe - zIm * wIm + hv;\n let nIm = zRe * wIm + zIm * wRe;\n wRe = nRe;\n wIm = nIm;\n partial[i * tile + s] = cRe * wRe - cIm * wIm;\n }\n state[i * 2u] = wRe;\n state[i * 2u + 1u] = wIm;\n}\n",
272+ "constants": [
273+ "tile",
274+ "total_steps",
275+ "ir_steps",
276+ "n_slots"
277+ ]
278+ },
279+ "reduce": {
280+ "code": "// One thread per sample of the tile: sum every slot's contribution.\noverride tile: u32;\noverride total_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> partial: array<f32>;\n@group(0) @binding(1) var<storage, read_write> trace: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= tile) { return; }\n let t = tick[0] * tile + i;\n if (t >= total_steps) { return; }\n var s = 0.0;\n for (var q = 0u; q < n_slots; q++) {\n s += partial[q * tile + i];\n }\n trace[t] = s;\n}\n",
281+ "constants": [
282+ "tile",
283+ "total_steps",
284+ "n_slots"
285+ ]
286+ }
287+ },
288+ "precompute": {
289+ "buffers": [
290+ {
291+ "name": "wall",
292+ "count": "npts"
293+ },
294+ {
295+ "name": "sig",
296+ "count": "npts"
297+ },
298+ {
299+ "name": "prof",
300+ "count": "npts"
301+ },
302+ {
303+ "name": "sx",
304+ "count": "mx * nx"
305+ },
306+ {
307+ "name": "p",
308+ "count": "npts"
309+ },
310+ {
311+ "name": "pm",
312+ "count": "npts"
313+ },
314+ {
315+ "name": "pn",
316+ "count": "npts"
317+ },
318+ {
319+ "name": "plane",
320+ "count": "nx * ny"
321+ },
322+ {
323+ "name": "tmp",
324+ "count": "mx * ny"
325+ },
326+ {
327+ "name": "kernels",
328+ "count": "n_rows * ir_steps"
329+ }
330+ ],
331+ "setup": [
332+ {
333+ "shader": "medium",
334+ "buffers": [
335+ "wall",
336+ "sig",
337+ "prof"
338+ ],
339+ "dispatch": "npts"
340+ },
341+ {
342+ "shader": "sines_x",
343+ "buffers": [
344+ "sx"
345+ ],
346+ "dispatch": "mx * nx"
347+ },
348+ {
349+ "shader": "impulse",
350+ "buffers": [
351+ "p",
352+ "wall",
353+ "sig"
354+ ],
355+ "dispatch": "1"
356+ }
357+ ],
358+ "loop": {
359+ "count": "ir_steps",
360+ "batch": 512,
361+ "rotate": [
362+ [
363+ "p",
364+ "pm",
365+ "pn"
366+ ]
367+ ],
368+ "body": [
369+ {
370+ "shader": "plane",
371+ "buffers": [
372+ "plane",
373+ "p",
374+ "prof"
375+ ],
376+ "dispatch": "nx * ny"
377+ },
378+ {
379+ "shader": "xc",
380+ "buffers": [
381+ "tmp",
382+ "plane",
383+ "sx"
384+ ],
385+ "dispatch": "mx * ny"
386+ },
387+ {
388+ "shader": "proj",
389+ "buffers": [
390+ "kernels",
391+ "tmp",
392+ "__tick"
393+ ],
394+ "dispatch": "mx"
395+ },
396+ {
397+ "shader": "step",
398+ "buffers": [
399+ "p",
400+ "pm",
401+ "pn",
402+ "wall",
403+ "sig"
404+ ],
405+ "dispatch": "npts"
406+ }
407+ ]
408+ },
409+ "outputs": [
410+ "kernels"
411+ ]
412+ },
413+ "render": {
414+ "buffers": [
415+ {
416+ "name": "kernels",
417+ "count": "n_rows * ir_steps"
418+ },
419+ {
420+ "name": "bx",
421+ "count": "n_events * mx"
422+ },
423+ {
424+ "name": "slots",
425+ "count": "n_slots * 6"
426+ },
427+ {
428+ "name": "state",
429+ "count": "n_slots * 2"
430+ },
431+ {
432+ "name": "partial",
433+ "count": "n_slots * tile"
434+ },
435+ {
436+ "name": "trace",
437+ "count": "total_steps"
438+ }
439+ ],
440+ "inputs": [
441+ "kernels"
442+ ],
443+ "eventParams": [
444+ "f1",
445+ "t60",
446+ "sig1",
447+ "strikepos",
448+ "striker",
449+ "vel",
450+ "gair"
451+ ],
452+ "derived": {
453+ "ev_stride": "8",
454+ "n_slots": "n_events * mx * 2"
455+ },
456+ "setup": [
457+ {
458+ "shader": "excite_x",
459+ "buffers": [
460+ "bx",
461+ "__events"
462+ ],
463+ "dispatch": "n_events * mx"
464+ },
465+ {
466+ "shader": "slots",
467+ "buffers": [
468+ "slots",
469+ "__events",
470+ "bx"
471+ ],
472+ "dispatch": "n_events * mx"
473+ }
474+ ],
475+ "loop": {
476+ "count": "n_tiles",
477+ "batch": 8,
478+ "body": [
479+ {
480+ "shader": "synth",
481+ "buffers": [
482+ "slots",
483+ "state",
484+ "kernels",
485+ "partial",
486+ "__tick"
487+ ],
488+ "dispatch": "n_slots"
489+ },
490+ {
491+ "shader": "reduce",
492+ "buffers": [
493+ "partial",
494+ "trace",
495+ "__tick"
496+ ],
497+ "dispatch": "tile"
498+ }
499+ ]
500+ },
501+ "trace": "trace"
502+ },
503+ "visual": {
504+ "dims": [
505+ "nx",
506+ "ny",
507+ "nz"
508+ ],
509+ "extents": [
510+ "Lx",
511+ "Ly",
512+ "Lz"
513+ ],
514+ "buffers": [
515+ {
516+ "name": "wall",
517+ "count": "npts"
518+ },
519+ {
520+ "name": "sig",
521+ "count": "npts"
522+ },
523+ {
524+ "name": "prof",
525+ "count": "npts"
526+ }
527+ ],
528+ "setup": [
529+ {
530+ "shader": "medium",
531+ "buffers": [
532+ "wall",
533+ "sig",
534+ "prof"
535+ ],
536+ "dispatch": "npts"
537+ }
538+ ],
539+ "volumes": [
540+ {
541+ "buffer": "wall",
542+ "style": "solid",
543+ "invert": true,
544+ "color": [
545+ 0.62,
546+ 0.48,
547+ 0.34
548+ ]
549+ },
550+ {
551+ "buffer": "prof",
552+ "style": "cloud",
553+ "color": [
554+ 1,
555+ 0.45,
556+ 0.2
557+ ]
558+ }
559+ ],
560+ "markers": [
561+ {
562+ "kind": "line",
563+ "from": [
564+ "-L1/2",
565+ "oy",
566+ "oz"
567+ ],
568+ "to": [
569+ "L1/2",
570+ "oy",
571+ "oz"
572+ ],
573+ "color": [
574+ 1,
575+ 0.85,
576+ 0.3
577+ ],
578+ "label": "bar"
579+ },
580+ {
581+ "kind": "point",
582+ "at": [
583+ "mic_x",
584+ "mic_y",
585+ "mic_z"
586+ ],
587+ "color": [
588+ 0.3,
589+ 0.85,
590+ 1
591+ ],
592+ "label": "mic"
593+ }
594+ ],
595+ "partials": {
596+ "buffer": "slots",
597+ "stride": 6,
598+ "zRe": 2,
599+ "zIm": 3
600+ }
601+ }
602+}
instruments/drum.instrument.jsonadded+742−0View file
@@ -0,0 +1,742 @@
1+{
2+ "instrawave": 1,
3+ "name": "drum",
4+ "description": "A struck membrane over a cylindrical shell. The head’s inharmonic modes plus the cavity under it are what make it a drum.",
5+ "params": [
6+ {
7+ "key": "nx",
8+ "label": "air cells along x",
9+ "unit": "1",
10+ "value": 96,
11+ "min": 32,
12+ "max": 256,
13+ "class": "accuracy",
14+ "hint": "The air grid; ny = nz = nx/2. The simulation is honest to about c/(8h). Must be even.",
15+ "step": 2
16+ },
17+ {
18+ "key": "Lx",
19+ "label": "domain length",
20+ "unit": "m",
21+ "value": 1,
22+ "min": 0.6,
23+ "max": 2,
24+ "class": "geometry",
25+ "hint": "The air box around the instrument, twice as long in x as in y and z."
26+ },
27+ {
28+ "key": "ir_seconds",
29+ "label": "room response length",
30+ "unit": "s",
31+ "value": 0.5,
32+ "min": 0.1,
33+ "max": 2,
34+ "class": "accuracy",
35+ "hint": "How long the recorded room responses run; a room still ringing past this is truncated."
36+ },
37+ {
38+ "key": "mode_margin",
39+ "label": "mode set margin",
40+ "unit": "1",
41+ "value": 2,
42+ "min": 1,
43+ "max": 4,
44+ "class": "accuracy",
45+ "hint": "Head modes are recorded up to this multiple of what the air grid spatially resolves."
46+ },
47+ {
48+ "key": "L1",
49+ "label": "head width",
50+ "unit": "m",
51+ "value": 0.32,
52+ "min": 0.15,
53+ "max": 0.5,
54+ "class": "geometry",
55+ "hint": "Extent along x."
56+ },
57+ {
58+ "key": "L2",
59+ "label": "head depth",
60+ "unit": "m",
61+ "value": 0.32,
62+ "min": 0.15,
63+ "max": 0.5,
64+ "class": "geometry",
65+ "hint": "Extent along y."
66+ },
67+ {
68+ "key": "oz",
69+ "label": "head height",
70+ "unit": "m",
71+ "value": 0.02,
72+ "min": 0,
73+ "max": 0.15,
74+ "class": "geometry",
75+ "hint": "Height of the head plane above z = 0."
76+ },
77+ {
78+ "key": "shellr",
79+ "label": "shell radius",
80+ "unit": "m",
81+ "value": 0.17,
82+ "min": 0.08,
83+ "max": 0.3,
84+ "class": "geometry",
85+ "hint": "A hair larger than the head over it."
86+ },
87+ {
88+ "key": "shelld",
89+ "label": "shell depth",
90+ "unit": "m",
91+ "value": 0.3,
92+ "min": 0.05,
93+ "max": 0.4,
94+ "class": "geometry",
95+ "hint": "The cavity under the head; deeper is a lower air resonance."
96+ },
97+ {
98+ "key": "thick",
99+ "label": "wall thickness",
100+ "unit": "m",
101+ "value": 0.016,
102+ "min": 0.008,
103+ "max": 0.05,
104+ "class": "geometry"
105+ },
106+ {
107+ "key": "closed",
108+ "label": "closed bottom",
109+ "unit": "1",
110+ "value": 1,
111+ "min": 0,
112+ "max": 1,
113+ "class": "geometry",
114+ "hint": "1 puts a plate across the bottom; 0 leaves it open."
115+ },
116+ {
117+ "key": "absorb",
118+ "label": "surface absorption",
119+ "unit": "1/s",
120+ "value": 800,
121+ "min": 0,
122+ "max": 4000,
123+ "class": "geometry",
124+ "hint": "A lossy skin over the shell surfaces; 0 lets the cavity ring hard."
125+ },
126+ {
127+ "key": "mic_x",
128+ "label": "microphone x",
129+ "unit": "m",
130+ "value": 0.15,
131+ "min": -0.45,
132+ "max": 0.45,
133+ "class": "geometry"
134+ },
135+ {
136+ "key": "mic_y",
137+ "label": "microphone y",
138+ "unit": "m",
139+ "value": 0.1,
140+ "min": -0.2,
141+ "max": 0.2,
142+ "class": "geometry"
143+ },
144+ {
145+ "key": "mic_z",
146+ "label": "microphone z",
147+ "unit": "m",
148+ "value": 0.12,
149+ "min": -0.2,
150+ "max": 0.2,
151+ "class": "geometry"
152+ },
153+ {
154+ "key": "f0",
155+ "label": "pitch",
156+ "unit": "Hz",
157+ "value": 110,
158+ "min": 60,
159+ "max": 300,
160+ "class": "tuning",
161+ "hint": "The (1,1) fundamental; the tension follows from it and the size."
162+ },
163+ {
164+ "key": "t60",
165+ "label": "decay t60",
166+ "unit": "s",
167+ "value": 1.5,
168+ "min": 0.2,
169+ "max": 6,
170+ "class": "tuning",
171+ "hint": "Seconds to decay 60 dB."
172+ },
173+ {
174+ "key": "sig1",
175+ "label": "brightness decay",
176+ "unit": "m^2/s",
177+ "value": 0.0005,
178+ "min": 0,
179+ "max": 0.01,
180+ "class": "tuning",
181+ "hint": "Frequency-dependent damping; the slap of the attack fades first."
182+ },
183+ {
184+ "key": "strikex",
185+ "label": "strike x",
186+ "unit": "1",
187+ "value": 0.35,
188+ "min": 0.1,
189+ "max": 0.9,
190+ "class": "tuning",
191+ "hint": "Where the stick lands along x, as a fraction."
192+ },
193+ {
194+ "key": "strikey",
195+ "label": "strike y",
196+ "unit": "1",
197+ "value": 0.5,
198+ "min": 0.1,
199+ "max": 0.9,
200+ "class": "tuning"
201+ },
202+ {
203+ "key": "striker",
204+ "label": "stick width",
205+ "unit": "m",
206+ "value": 0.04,
207+ "min": 0.01,
208+ "max": 0.1,
209+ "class": "tuning",
210+ "hint": "Radius of the struck patch."
211+ },
212+ {
213+ "key": "vel",
214+ "label": "strike speed",
215+ "unit": "m/s",
216+ "value": 1.5,
217+ "min": 0.1,
218+ "max": 5,
219+ "class": "tuning",
220+ "hint": "Linear equations: sets scale only."
221+ },
222+ {
223+ "key": "gair",
224+ "label": "radiation gain",
225+ "unit": "1",
226+ "value": 1,
227+ "min": 0,
228+ "max": 2,
229+ "class": "tuning"
230+ }
231+ ],
232+ "derived": {
233+ "c0": "343",
234+ "cfl": "0.5",
235+ "ny": "nx / 2",
236+ "nz": "nx / 2",
237+ "npts": "nx * ny * nz",
238+ "h": "Lx / nx",
239+ "Ly": "ny * h",
240+ "Lz": "nz * h",
241+ "dt": "cfl * h / (sqrt(3) * c0)",
242+ "ir_steps": "max(1, round(ir_seconds / dt))",
243+ "mic_index": "clamp(round((mic_x + Lx/2) / h - 0.5), 0, nx - 1) + nx * (clamp(round((mic_y + Ly/2) / h - 0.5), 0, ny - 1) + ny * clamp(round((mic_z + Lz/2) / h - 0.5), 0, nz - 1))",
244+ "c_worst": "2 * f0__max / sqrt(1/(L1*L1) + 1/(L2*L2))",
245+ "h_min": "sqrt(2 * (c_worst*c_worst*dt*dt + 4*sig1__max*dt)) / 0.95",
246+ "n1": "max(5, min(256, floor(L1 / h_min) + 1))",
247+ "n2": "max(5, min(256, floor(L2 / h_min) + 1))",
248+ "h1": "L1 / (n1 - 1)",
249+ "h2": "L2 / (n2 - 1)",
250+ "mx": "min(n1 - 2, ceil(mode_margin * L1 / (2 * h)))",
251+ "my": "min(n2 - 2, ceil(mode_margin * L2 / (2 * h)))",
252+ "n_rows": "mx * my"
253+ },
254+ "shaders": {
255+ "medium": {
256+ "code": "// The medium: the body's rigid mask, the absorption, and where the object\n// couples into the air. One thread per air cell.\nconst PI = 3.14159265358979;\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride h: f32;\noverride Lx: f32;\noverride Ly: f32;\noverride Lz: f32;\noverride shellr: f32;\noverride shelld: f32;\noverride thick: f32;\noverride closed: f32;\noverride absorb: f32;\noverride oz: f32;\noverride L1: f32;\noverride L2: f32;\n@group(0) @binding(0) var<storage, read_write> wall: array<f32>;\n@group(0) @binding(1) var<storage, read_write> sig: array<f32>;\n@group(0) @binding(2) var<storage, read_write> prof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i % nx;\n let iy = (i / nx) % ny;\n let iz = i / (nx * ny);\n let x = -Lx/2.0 + (f32(ix) + 0.5) * h;\n let y = -Ly/2.0 + (f32(iy) + 0.5) * h;\n let z = -Lz/2.0 + (f32(iz) + 0.5) * h;\n let sw = 0.45 * h;\n // The shell: a cylindrical ring from z = -shelld to 0, optional bottom.\n let r = sqrt(x*x + y*y);\n let cyl_o = 0.5 * (1.0 - tanh((r - shellr) / sw));\n let cyl_i = 0.5 * (1.0 - tanh((r - (shellr - thick)) / sw));\n let cyl_z = 0.5 * (1.0 - tanh((abs(z + shelld/2.0) - shelld/2.0) / sw));\n let bot = 0.5 * (1.0 - tanh((abs(z + shelld - thick/2.0) - thick/2.0) / sw));\n let shell = min(1.0, max(0.0, cyl_o - cyl_i) * cyl_z + closed * (cyl_i * bot));\n let w = 1.0 - shell;\n wall[i] = w;\n // Open boundary at the domain edge (a quadratic absorbing ramp over the\n // outer 0.15 Ly of each face), plus the body's lossy surface skin.\n let spw = 0.15 * Ly;\n let dx = max(0.0, spw - (Lx/2.0 - abs(x))) / spw;\n let dy = max(0.0, spw - (Ly/2.0 - abs(y))) / spw;\n let dz = max(0.0, spw - (Lz/2.0 - abs(z))) / spw;\n let d = max(max(dx, dy), dz);\n sig[i] = 9000.0 * d * d + absorb * (4.0 * shell * (1.0 - shell));\n // The head radiates from its plane: a thin sheet at z = oz over its\n // extent, cross-section normalized to one.\n let aw = 2.0 * h;\n let sk = exp(-((z - oz)*(z - oz)) / (aw*aw)) / (sqrt(PI) * aw);\n let exx = 0.5 * (1.0 + tanh((L1/2.0 - abs(x)) / (1.5*h)));\n let exy = 0.5 * (1.0 + tanh((L2/2.0 - abs(y)) / (1.5*h)));\n prof[i] = sk * exx * exy * w;\n\n}\n",
257+ "constants": [
258+ "npts",
259+ "nx",
260+ "ny",
261+ "h",
262+ "Lx",
263+ "Ly",
264+ "Lz",
265+ "shellr",
266+ "shelld",
267+ "thick",
268+ "closed",
269+ "absorb",
270+ "oz",
271+ "L1",
272+ "L2"
273+ ]
274+ },
275+ "sines_x": {
276+ "code": "// Sine mode m of the object grid, interpolated to air\n// x centers: sx[(mode-1) * nx + ix]. One thread per table entry.\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride nx: u32;\noverride L1: f32;\noverride h1: f32;\noverride Lx: f32;\noverride h: f32;\n@group(0) @binding(0) var<storage, read_write> table: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * nx) { return; }\n let mi = i % mx;\n let ia = i / mx;\n let xw = -Lx/2.0 + (f32(ia) + 0.5) * h;\n let u = (xw + L1/2.0) / h1;\n let j = i32(floor(u));\n var v = 0.0;\n if (j >= 0 && j < i32(n1) - 1) {\n let f = u - f32(j);\n let mm = f32(mi + 1u);\n let s0 = sin(mm * PI * f32(j) / f32(n1 - 1u));\n let s1 = sin(mm * PI * f32(j + 1) / f32(n1 - 1u));\n v = (1.0 - f) * s0 + f * s1;\n }\n table[mi * nx + ia] = v;\n}\n",
277+ "constants": [
278+ "n1",
279+ "mx",
280+ "nx",
281+ "L1",
282+ "h1",
283+ "Lx",
284+ "h"
285+ ]
286+ },
287+ "impulse": {
288+ "code": "// The reciprocal kick: one unit impulse at the microphone cell, entering\n// the way a source enters the first step. One thread.\noverride mic_index: u32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> p: array<f32>;\n@group(0) @binding(1) var<storage, read> wall: array<f32>;\n@group(0) @binding(2) var<storage, read> sig: array<f32>;\n@compute @workgroup_size(1)\nfn main() {\n p[mic_index] = dt * dt * wall[mic_index] / (1.0 + sig[mic_index] * dt);\n}\n",
289+ "constants": [
290+ "mic_index",
291+ "dt"
292+ ]
293+ },
294+ "step": {
295+ "code": "// One leapfrog step of the air: the wall-masked Laplacian (each stencil\n// face scaled by the mask at the neighbour it reads, the rigid condition in\n// divergence form) fused with the update. One thread per air cell.\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\noverride h: f32;\noverride dt: f32;\noverride c0: f32;\n@group(0) @binding(0) var<storage, read> p: array<f32>;\n@group(0) @binding(1) var<storage, read> pm: array<f32>;\n@group(0) @binding(2) var<storage, read_write> pn: array<f32>;\n@group(0) @binding(3) var<storage, read> wall: array<f32>;\n@group(0) @binding(4) var<storage, read> sig: array<f32>;\nfn face(ix: i32, iy: i32, iz: i32, pc: f32) -> f32 {\n if (ix < 0 || ix >= i32(nx) || iy < 0 || iy >= i32(ny) || iz < 0 || iz >= i32(nz)) {\n return -pc; // outside the domain: p = 0, open\n }\n let k = u32(ix) + nx * (u32(iy) + ny * u32(iz));\n return wall[k] * (p[k] - pc);\n}\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i32(i % nx);\n let iy = i32((i / nx) % ny);\n let iz = i32(i / (nx * ny));\n let pc = p[i];\n let s = face(ix - 1, iy, iz, pc) + face(ix + 1, iy, iz, pc)\n + face(ix, iy - 1, iz, pc) + face(ix, iy + 1, iz, pc)\n + face(ix, iy, iz - 1, pc) + face(ix, iy, iz + 1, pc);\n let lp = wall[i] * s / (h * h);\n let sd = sig[i] * dt;\n pn[i] = wall[i] * ((2.0 * pc - (1.0 - sd) * pm[i] + (c0 * dt) * (c0 * dt) * lp) / (1.0 + sd));\n}\n",
296+ "constants": [
297+ "npts",
298+ "nx",
299+ "ny",
300+ "nz",
301+ "h",
302+ "dt",
303+ "c0"
304+ ]
305+ },
306+ "plane": {
307+ "code": "// Contract the field against the radiation profile over z:\n// one thread per air column.\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\n@group(0) @binding(0) var<storage, read_write> plane: array<f32>;\n@group(0) @binding(1) var<storage, read> p: array<f32>;\n@group(0) @binding(2) var<storage, read> prof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= nx * ny) { return; }\n var s = 0.0;\n for (var iz = 0u; iz < nz; iz++) {\n let k = i + nx * ny * iz;\n s += prof[k] * p[k];\n }\n plane[i] = s;\n}\n",
308+ "constants": [
309+ "nx",
310+ "ny",
311+ "nz"
312+ ]
313+ },
314+ "xc": {
315+ "code": "// Contract the plane against every x sine row: tmp[m + mx * iy].\noverride nx: u32;\noverride ny: u32;\noverride mx: u32;\n@group(0) @binding(0) var<storage, read_write> tmp: array<f32>;\n@group(0) @binding(1) var<storage, read> plane: array<f32>;\n@group(0) @binding(2) var<storage, read> sx: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * ny) { return; }\n let m = i % mx;\n let iy = i / mx;\n var s = 0.0;\n for (var ix = 0u; ix < nx; ix++) {\n s += sx[m * nx + ix] * plane[ix + nx * iy];\n }\n tmp[i] = s;\n}\n",
316+ "constants": [
317+ "nx",
318+ "ny",
319+ "mx"
320+ ]
321+ },
322+ "proj": {
323+ "code": "// Finish each pattern's projection against its y sine row and write this\n// step's sample: kernels[q * ir_steps + t], q = (m-1) + mx*(n-1).\noverride ny: u32;\noverride mx: u32;\noverride my: u32;\noverride ir_steps: u32;\n@group(0) @binding(0) var<storage, read_write> kernels: array<f32>;\n@group(0) @binding(1) var<storage, read> tmp: array<f32>;\n@group(0) @binding(2) var<storage, read> sy: array<f32>;\n@group(0) @binding(3) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * my) { return; }\n let m = i % mx;\n let n = i / mx;\n var s = 0.0;\n for (var iy = 0u; iy < ny; iy++) {\n s += sy[n * ny + iy] * tmp[m + mx * iy];\n }\n let t = tick[0];\n if (t < ir_steps) {\n kernels[i * ir_steps + t] = s;\n }\n}\n",
324+ "constants": [
325+ "ny",
326+ "mx",
327+ "my",
328+ "ir_steps"
329+ ]
330+ },
331+ "excite_x": {
332+ "code": "// Project this axis's excitation profile onto each recorded sine mode:\n// bx[e * mx + (mode-1)]. One thread per (event, mode).\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride L1: f32;\n@group(0) @binding(0) var<storage, read_write> bout: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * arrayLength(&ev) / ev_stride) { return; }\n let mi = i % mx;\n let e = i / mx;\n let base = e * ev_stride;\n let strikex = ev[base + 4u];\n let striker = ev[base + 6u];\n let mm = f32(mi + 1u);\n var s = 0.0;\n for (var k = 1u; k < n1 - 1u; k++) {\n let xi = f32(k) * h1;\n let dx = (xi - strikex * L1) / striker;\n let u0 = exp(-dx * dx);\n s += sin(mm * PI * f32(k) / f32(n1 - 1u)) * u0;\n }\n bout[i] = s;\n}\n",
333+ "constants": [
334+ "n1",
335+ "mx",
336+ "ev_stride",
337+ "h1",
338+ "L1"
339+ ]
340+ },
341+ "slots": {
342+ "code": "// One thread per (event, recorded mode): the mode's excitation amplitude,\n// its recursion roots, and the acceleration-series slots that drive its\n// kernel row.\nconst PI = 3.14159265358979;\noverride mx: u32;\noverride my: u32;\noverride n_rows: u32;\noverride n1: u32;\noverride n2: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride h2: f32;\noverride L1: f32;\noverride L2: f32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> slots: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@group(0) @binding(2) var<storage, read> bx: array<f32>;\n@group(0) @binding(3) var<storage, read> by: array<f32>;\n\nfn cmul(ar: f32, ai: f32, br: f32, bi: f32) -> vec2f {\n return vec2f(ar * br - ai * bi, ar * bi + ai * br);\n}\n// c = a * gain * (z - 2 + 1/z) = a * gain * (z-1)^2 / z, the acceleration\n// series, with z-1 supplied stably (the naive z - 1 cancels).\nfn acc_scale(p: vec4f, zm1Re: f32, gain: f32) -> vec4f {\n let m2 = p.z * p.z + p.w * p.w;\n if (m2 == 0.0) { return vec4f(0.0); }\n let sq = cmul(zm1Re, p.w, zm1Re, p.w);\n let num = cmul(sq.x, sq.y, p.z, -p.w);\n let c = cmul(p.x, p.y, gain * num.x / m2, gain * num.y / m2);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n// c = a * gain * z, the next-displacement series (what a string's bridge\n// slope is read from).\nfn next_scale(p: vec4f, gain: f32) -> vec4f {\n let c = cmul(p.x, p.y, gain * p.z, gain * p.w);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n\n\nfn write_slot(idx: u32, p: vec4f, row: f32, start: f32) {\n slots[idx * 6u] = p.x;\n slots[idx * 6u + 1u] = p.y;\n slots[idx * 6u + 2u] = p.z;\n slots[idx * 6u + 3u] = p.w;\n slots[idx * 6u + 4u] = row;\n slots[idx * 6u + 5u] = start;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n let n_events = arrayLength(&ev) / ev_stride;\n if (i >= n_events * n_rows) { return; }\n let e = i / n_rows;\n let q = i % n_rows;\n let base = e * ev_stride;\n let start = ev[base];\n let f0 = ev[base + 1u];\n let t60 = ev[base + 2u];\n let sig1 = ev[base + 3u];\n let vel = ev[base + 7u];\n let gair = ev[base + 8u];\n let mi = q % mx;\n let ni = q / mx;\n let m = f32(mi + 1u);\n let n = f32(ni + 1u);\n let b = bx[e * mx + mi] * by[e * my + ni] * (-(dt * vel)) * 4.0 / (f32(n1 - 1u) * f32(n2 - 1u));\n let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));\n let s2 = sin(n * PI / (2.0 * f32(n2 - 1u)));\n let lam = (4.0 / (h1 * h1)) * s1 * s1 + (4.0 / (h2 * h2)) * s2 * s2;\n let cm = 2.0 * f0 / sqrt(1.0 / (L1 * L1) + 1.0 / (L2 * L2));\n let w2 = cm * cm * lam;\n\n let d = (6.91 / t60) * dt;\n let opd = 1.0 + d;\n let g = d + 2.0 * sig1 * dt * lam;\n let x = dt * dt * w2 + 2.0 * sig1 * dt * lam;\n let zRe = (1.0 - 0.5 * x) / opd;\n let zm1Re = -(0.5 * x + d) / opd;\n let nn = dt * dt * w2 - g * d - 0.25 * x * x;\n var p0 = vec4f(0.0);\n var p1 = vec4f(0.0);\n var zm10 = 0.0;\n var zm11 = 0.0;\n if (nn > 0.0) {\n let zIm = sqrt(nn) / opd;\n let mag2 = (1.0 - g) / opd;\n let aRe = 0.0;\n let aIm = b * mag2 / zIm;\n p0 = vec4f(aRe, aIm, zRe, zIm);\n zm10 = zm1Re;\n } else {\n let sq = sqrt(-nn) / opd;\n let r1 = zRe + sq;\n let r2 = zRe - sq;\n if (r1 != r2 && r1 != 0.0 && r2 != 0.0) {\n let det = 1.0 / r1 - 1.0 / r2;\n let a1 = b / det;\n let a2 = -a1;\n p0 = vec4f(a1, 0.0, r1, 0.0);\n p1 = vec4f(a2, 0.0, r2, 0.0);\n zm10 = zm1Re + sq;\n zm11 = zm1Re - sq;\n }\n }\n let ga = gair / (dt * dt);\n write_slot(i * 2u, acc_scale(p0, zm10, ga), f32(q), start);\n write_slot(i * 2u + 1u, acc_scale(p1, zm11, ga), f32(q), start);\n}\n",
343+ "constants": [
344+ "mx",
345+ "my",
346+ "n_rows",
347+ "n1",
348+ "n2",
349+ "ev_stride",
350+ "h1",
351+ "h2",
352+ "L1",
353+ "L2",
354+ "dt"
355+ ]
356+ },
357+ "synth": {
358+ "code": "// One thread per slot: a complex one-pole over this tile of samples, the\n// kernel row as input while it lasts and silence after (the closed-form\n// tail). State persists across tiles.\noverride tile: u32;\noverride total_steps: u32;\noverride ir_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> slots: array<f32>;\n@group(0) @binding(1) var<storage, read_write> state: array<f32>;\n@group(0) @binding(2) var<storage, read> kernels: array<f32>;\n@group(0) @binding(3) var<storage, read_write> partial: array<f32>;\n@group(0) @binding(4) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= n_slots) { return; }\n let cRe = slots[i * 6u];\n let cIm = slots[i * 6u + 1u];\n let zRe = slots[i * 6u + 2u];\n let zIm = slots[i * 6u + 3u];\n let row = u32(slots[i * 6u + 4u]);\n let start = u32(slots[i * 6u + 5u]);\n var wRe = state[i * 2u];\n var wIm = state[i * 2u + 1u];\n let t0 = tick[0] * tile;\n for (var s = 0u; s < tile; s++) {\n let t = t0 + s;\n if (t >= total_steps) { break; }\n var hv = 0.0;\n if (t >= start && t - start < ir_steps) {\n hv = kernels[row * ir_steps + (t - start)];\n }\n let nRe = zRe * wRe - zIm * wIm + hv;\n let nIm = zRe * wIm + zIm * wRe;\n wRe = nRe;\n wIm = nIm;\n partial[i * tile + s] = cRe * wRe - cIm * wIm;\n }\n state[i * 2u] = wRe;\n state[i * 2u + 1u] = wIm;\n}\n",
359+ "constants": [
360+ "tile",
361+ "total_steps",
362+ "ir_steps",
363+ "n_slots"
364+ ]
365+ },
366+ "reduce": {
367+ "code": "// One thread per sample of the tile: sum every slot's contribution.\noverride tile: u32;\noverride total_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> partial: array<f32>;\n@group(0) @binding(1) var<storage, read_write> trace: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= tile) { return; }\n let t = tick[0] * tile + i;\n if (t >= total_steps) { return; }\n var s = 0.0;\n for (var q = 0u; q < n_slots; q++) {\n s += partial[q * tile + i];\n }\n trace[t] = s;\n}\n",
368+ "constants": [
369+ "tile",
370+ "total_steps",
371+ "n_slots"
372+ ]
373+ },
374+ "sines_y": {
375+ "code": "// Sine mode n of the object grid, interpolated to air\n// y centers: sy[(mode-1) * ny + iy]. One thread per table entry.\nconst PI = 3.14159265358979;\noverride n2: u32;\noverride my: u32;\noverride ny: u32;\noverride L2: f32;\noverride h2: f32;\noverride Ly: f32;\noverride h: f32;\n@group(0) @binding(0) var<storage, read_write> table: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= my * ny) { return; }\n let mi = i % my;\n let ia = i / my;\n let xw = -Ly/2.0 + (f32(ia) + 0.5) * h;\n let u = (xw + L2/2.0) / h2;\n let j = i32(floor(u));\n var v = 0.0;\n if (j >= 0 && j < i32(n2) - 1) {\n let f = u - f32(j);\n let mm = f32(mi + 1u);\n let s0 = sin(mm * PI * f32(j) / f32(n2 - 1u));\n let s1 = sin(mm * PI * f32(j + 1) / f32(n2 - 1u));\n v = (1.0 - f) * s0 + f * s1;\n }\n table[mi * ny + ia] = v;\n}\n",
376+ "constants": [
377+ "n2",
378+ "my",
379+ "ny",
380+ "L2",
381+ "h2",
382+ "Ly",
383+ "h"
384+ ]
385+ },
386+ "excite_y": {
387+ "code": "// Project this axis's excitation profile onto each recorded sine mode:\n// by[e * my + (mode-1)]. One thread per (event, mode).\nconst PI = 3.14159265358979;\noverride n2: u32;\noverride my: u32;\noverride ev_stride: u32;\noverride h2: f32;\noverride L2: f32;\n@group(0) @binding(0) var<storage, read_write> bout: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= my * arrayLength(&ev) / ev_stride) { return; }\n let mi = i % my;\n let e = i / my;\n let base = e * ev_stride;\n let strikey = ev[base + 5u];\n let striker = ev[base + 6u];\n let mm = f32(mi + 1u);\n var s = 0.0;\n for (var k = 1u; k < n2 - 1u; k++) {\n let xi = f32(k) * h2;\n let dx = (xi - strikey * L2) / striker;\n let u0 = exp(-dx * dx);\n s += sin(mm * PI * f32(k) / f32(n2 - 1u)) * u0;\n }\n bout[i] = s;\n}\n",
388+ "constants": [
389+ "n2",
390+ "my",
391+ "ev_stride",
392+ "h2",
393+ "L2"
394+ ]
395+ }
396+ },
397+ "precompute": {
398+ "buffers": [
399+ {
400+ "name": "wall",
401+ "count": "npts"
402+ },
403+ {
404+ "name": "sig",
405+ "count": "npts"
406+ },
407+ {
408+ "name": "prof",
409+ "count": "npts"
410+ },
411+ {
412+ "name": "sx",
413+ "count": "mx * nx"
414+ },
415+ {
416+ "name": "sy",
417+ "count": "my * ny"
418+ },
419+ {
420+ "name": "p",
421+ "count": "npts"
422+ },
423+ {
424+ "name": "pm",
425+ "count": "npts"
426+ },
427+ {
428+ "name": "pn",
429+ "count": "npts"
430+ },
431+ {
432+ "name": "plane",
433+ "count": "nx * ny"
434+ },
435+ {
436+ "name": "tmp",
437+ "count": "mx * ny"
438+ },
439+ {
440+ "name": "kernels",
441+ "count": "n_rows * ir_steps"
442+ }
443+ ],
444+ "setup": [
445+ {
446+ "shader": "medium",
447+ "buffers": [
448+ "wall",
449+ "sig",
450+ "prof"
451+ ],
452+ "dispatch": "npts"
453+ },
454+ {
455+ "shader": "sines_x",
456+ "buffers": [
457+ "sx"
458+ ],
459+ "dispatch": "mx * nx"
460+ },
461+ {
462+ "shader": "sines_y",
463+ "buffers": [
464+ "sy"
465+ ],
466+ "dispatch": "my * ny"
467+ },
468+ {
469+ "shader": "impulse",
470+ "buffers": [
471+ "p",
472+ "wall",
473+ "sig"
474+ ],
475+ "dispatch": "1"
476+ }
477+ ],
478+ "loop": {
479+ "count": "ir_steps",
480+ "batch": 512,
481+ "rotate": [
482+ [
483+ "p",
484+ "pm",
485+ "pn"
486+ ]
487+ ],
488+ "body": [
489+ {
490+ "shader": "plane",
491+ "buffers": [
492+ "plane",
493+ "p",
494+ "prof"
495+ ],
496+ "dispatch": "nx * ny"
497+ },
498+ {
499+ "shader": "xc",
500+ "buffers": [
501+ "tmp",
502+ "plane",
503+ "sx"
504+ ],
505+ "dispatch": "mx * ny"
506+ },
507+ {
508+ "shader": "proj",
509+ "buffers": [
510+ "kernels",
511+ "tmp",
512+ "sy",
513+ "__tick"
514+ ],
515+ "dispatch": "n_rows"
516+ },
517+ {
518+ "shader": "step",
519+ "buffers": [
520+ "p",
521+ "pm",
522+ "pn",
523+ "wall",
524+ "sig"
525+ ],
526+ "dispatch": "npts"
527+ }
528+ ]
529+ },
530+ "outputs": [
531+ "kernels"
532+ ]
533+ },
534+ "render": {
535+ "buffers": [
536+ {
537+ "name": "kernels",
538+ "count": "n_rows * ir_steps"
539+ },
540+ {
541+ "name": "bx",
542+ "count": "n_events * mx"
543+ },
544+ {
545+ "name": "by",
546+ "count": "n_events * my"
547+ },
548+ {
549+ "name": "slots",
550+ "count": "n_slots * 6"
551+ },
552+ {
553+ "name": "state",
554+ "count": "n_slots * 2"
555+ },
556+ {
557+ "name": "partial",
558+ "count": "n_slots * tile"
559+ },
560+ {
561+ "name": "trace",
562+ "count": "total_steps"
563+ }
564+ ],
565+ "inputs": [
566+ "kernels"
567+ ],
568+ "eventParams": [
569+ "f0",
570+ "t60",
571+ "sig1",
572+ "strikex",
573+ "strikey",
574+ "striker",
575+ "vel",
576+ "gair"
577+ ],
578+ "derived": {
579+ "ev_stride": "9",
580+ "n_slots": "n_events * n_rows * 2"
581+ },
582+ "setup": [
583+ {
584+ "shader": "excite_x",
585+ "buffers": [
586+ "bx",
587+ "__events"
588+ ],
589+ "dispatch": "n_events * mx"
590+ },
591+ {
592+ "shader": "excite_y",
593+ "buffers": [
594+ "by",
595+ "__events"
596+ ],
597+ "dispatch": "n_events * my"
598+ },
599+ {
600+ "shader": "slots",
601+ "buffers": [
602+ "slots",
603+ "__events",
604+ "bx",
605+ "by"
606+ ],
607+ "dispatch": "n_events * n_rows"
608+ }
609+ ],
610+ "loop": {
611+ "count": "n_tiles",
612+ "batch": 8,
613+ "body": [
614+ {
615+ "shader": "synth",
616+ "buffers": [
617+ "slots",
618+ "state",
619+ "kernels",
620+ "partial",
621+ "__tick"
622+ ],
623+ "dispatch": "n_slots"
624+ },
625+ {
626+ "shader": "reduce",
627+ "buffers": [
628+ "partial",
629+ "trace",
630+ "__tick"
631+ ],
632+ "dispatch": "tile"
633+ }
634+ ]
635+ },
636+ "trace": "trace"
637+ },
638+ "visual": {
639+ "dims": [
640+ "nx",
641+ "ny",
642+ "nz"
643+ ],
644+ "extents": [
645+ "Lx",
646+ "Ly",
647+ "Lz"
648+ ],
649+ "buffers": [
650+ {
651+ "name": "wall",
652+ "count": "npts"
653+ },
654+ {
655+ "name": "sig",
656+ "count": "npts"
657+ },
658+ {
659+ "name": "prof",
660+ "count": "npts"
661+ }
662+ ],
663+ "setup": [
664+ {
665+ "shader": "medium",
666+ "buffers": [
667+ "wall",
668+ "sig",
669+ "prof"
670+ ],
671+ "dispatch": "npts"
672+ }
673+ ],
674+ "volumes": [
675+ {
676+ "buffer": "wall",
677+ "style": "solid",
678+ "invert": true,
679+ "color": [
680+ 0.62,
681+ 0.48,
682+ 0.34
683+ ]
684+ },
685+ {
686+ "buffer": "prof",
687+ "style": "cloud",
688+ "color": [
689+ 1,
690+ 0.45,
691+ 0.2
692+ ]
693+ }
694+ ],
695+ "markers": [
696+ {
697+ "kind": "rect",
698+ "corner": [
699+ "-L1/2",
700+ "-L2/2",
701+ "oz"
702+ ],
703+ "du": [
704+ "L1",
705+ "0",
706+ "0"
707+ ],
708+ "dv": [
709+ "0",
710+ "L2",
711+ "0"
712+ ],
713+ "color": [
714+ 1,
715+ 0.85,
716+ 0.3
717+ ],
718+ "label": "head"
719+ },
720+ {
721+ "kind": "point",
722+ "at": [
723+ "mic_x",
724+ "mic_y",
725+ "mic_z"
726+ ],
727+ "color": [
728+ 0.3,
729+ 0.85,
730+ 1
731+ ],
732+ "label": "mic"
733+ }
734+ ],
735+ "partials": {
736+ "buffer": "slots",
737+ "stride": 6,
738+ "zRe": 2,
739+ "zIm": 3
740+ }
741+ }
742+}
instruments/dulcimer.instrument.jsonadded+744−0View file
@@ -0,0 +1,744 @@
1+{
2+ "instrawave": 1,
3+ "name": "dulcimer",
4+ "description": "One plucked string over a rigid box with a sound hole, radiating along its line and driving a bridge patch above the plate.",
5+ "params": [
6+ {
7+ "key": "nx",
8+ "label": "air cells along x",
9+ "unit": "1",
10+ "value": 96,
11+ "min": 32,
12+ "max": 256,
13+ "class": "accuracy",
14+ "hint": "The air grid; ny = nz = nx/2. The simulation is honest to about c/(8h). Must be even.",
15+ "step": 2
16+ },
17+ {
18+ "key": "Lx",
19+ "label": "domain length",
20+ "unit": "m",
21+ "value": 1,
22+ "min": 0.6,
23+ "max": 2,
24+ "class": "geometry",
25+ "hint": "The air box around the instrument, twice as long in x as in y and z."
26+ },
27+ {
28+ "key": "ir_seconds",
29+ "label": "room response length",
30+ "unit": "s",
31+ "value": 0.5,
32+ "min": 0.1,
33+ "max": 2,
34+ "class": "accuracy",
35+ "hint": "How long the recorded room responses run; a room still ringing past this is truncated."
36+ },
37+ {
38+ "key": "L1",
39+ "label": "speaking length",
40+ "unit": "m",
41+ "value": 0.6,
42+ "min": 0.3,
43+ "max": 0.9,
44+ "class": "geometry"
45+ },
46+ {
47+ "key": "oy",
48+ "label": "across",
49+ "unit": "m",
50+ "value": 0,
51+ "min": -0.15,
52+ "max": 0.15,
53+ "class": "geometry"
54+ },
55+ {
56+ "key": "oz",
57+ "label": "height",
58+ "unit": "m",
59+ "value": 0.02,
60+ "min": 0.005,
61+ "max": 0.15,
62+ "class": "geometry",
63+ "hint": "Height of the string above z = 0."
64+ },
65+ {
66+ "key": "boxl",
67+ "label": "box length",
68+ "unit": "m",
69+ "value": 0.66,
70+ "min": 0.3,
71+ "max": 0.9,
72+ "class": "geometry"
73+ },
74+ {
75+ "key": "boxw",
76+ "label": "box width",
77+ "unit": "m",
78+ "value": 0.16,
79+ "min": 0.08,
80+ "max": 0.4,
81+ "class": "geometry"
82+ },
83+ {
84+ "key": "boxd",
85+ "label": "box depth",
86+ "unit": "m",
87+ "value": 0.08,
88+ "min": 0.03,
89+ "max": 0.25,
90+ "class": "geometry",
91+ "hint": "How far the box hangs below its top plate at z = 0."
92+ },
93+ {
94+ "key": "thick",
95+ "label": "wall thickness",
96+ "unit": "m",
97+ "value": 0.016,
98+ "min": 0.008,
99+ "max": 0.05,
100+ "class": "geometry"
101+ },
102+ {
103+ "key": "holer",
104+ "label": "sound hole radius",
105+ "unit": "m",
106+ "value": 0.035,
107+ "min": 0,
108+ "max": 0.12,
109+ "class": "geometry",
110+ "hint": "0 seals the box; the hole is what lets the cavity speak."
111+ },
112+ {
113+ "key": "holex",
114+ "label": "sound hole x",
115+ "unit": "m",
116+ "value": -0.05,
117+ "min": -0.3,
118+ "max": 0.3,
119+ "class": "geometry"
120+ },
121+ {
122+ "key": "absorb",
123+ "label": "surface absorption",
124+ "unit": "1/s",
125+ "value": 800,
126+ "min": 0,
127+ "max": 4000,
128+ "class": "geometry"
129+ },
130+ {
131+ "key": "mic_x",
132+ "label": "microphone x",
133+ "unit": "m",
134+ "value": 0.12,
135+ "min": -0.45,
136+ "max": 0.45,
137+ "class": "geometry"
138+ },
139+ {
140+ "key": "mic_y",
141+ "label": "microphone y",
142+ "unit": "m",
143+ "value": 0.08,
144+ "min": -0.2,
145+ "max": 0.2,
146+ "class": "geometry"
147+ },
148+ {
149+ "key": "mic_z",
150+ "label": "microphone z",
151+ "unit": "m",
152+ "value": 0.1,
153+ "min": -0.2,
154+ "max": 0.2,
155+ "class": "geometry"
156+ },
157+ {
158+ "key": "f0",
159+ "label": "pitch",
160+ "unit": "Hz",
161+ "value": 294,
162+ "min": 80,
163+ "max": 880,
164+ "class": "tuning",
165+ "hint": "The fundamental; the wave speed follows, cs = 2 L f0."
166+ },
167+ {
168+ "key": "B",
169+ "label": "inharmonicity",
170+ "unit": "1",
171+ "value": 0.0001,
172+ "min": 0,
173+ "max": 0.002,
174+ "class": "tuning",
175+ "hint": "Bending stiffness as the inharmonicity coefficient."
176+ },
177+ {
178+ "key": "t60",
179+ "label": "decay t60",
180+ "unit": "s",
181+ "value": 4,
182+ "min": 0.2,
183+ "max": 8,
184+ "class": "tuning"
185+ },
186+ {
187+ "key": "sig1",
188+ "label": "brightness decay",
189+ "unit": "m^2/s",
190+ "value": 0.005,
191+ "min": 0,
192+ "max": 0.05,
193+ "class": "tuning"
194+ },
195+ {
196+ "key": "pluckpos",
197+ "label": "pluck position",
198+ "unit": "1",
199+ "value": 0.22,
200+ "min": 0.08,
201+ "max": 0.92,
202+ "class": "tuning"
203+ },
204+ {
205+ "key": "amp",
206+ "label": "pluck height",
207+ "unit": "m",
208+ "value": 0.002,
209+ "min": 0.0002,
210+ "max": 0.005,
211+ "class": "tuning"
212+ },
213+ {
214+ "key": "gair",
215+ "label": "line radiation gain",
216+ "unit": "1",
217+ "value": 0.3,
218+ "min": 0,
219+ "max": 2,
220+ "class": "tuning"
221+ },
222+ {
223+ "key": "gbridge",
224+ "label": "bridge drive gain",
225+ "unit": "1",
226+ "value": 1,
227+ "min": 0,
228+ "max": 2,
229+ "class": "tuning",
230+ "hint": "The pull at the far end driving a patch above the plate."
231+ }
232+ ],
233+ "derived": {
234+ "c0": "343",
235+ "cfl": "0.5",
236+ "ny": "nx / 2",
237+ "nz": "nx / 2",
238+ "npts": "nx * ny * nz",
239+ "h": "Lx / nx",
240+ "Ly": "ny * h",
241+ "Lz": "nz * h",
242+ "dt": "cfl * h / (sqrt(3) * c0)",
243+ "ir_steps": "max(1, round(ir_seconds / dt))",
244+ "mic_index": "clamp(round((mic_x + Lx/2) / h - 0.5), 0, nx - 1) + nx * (clamp(round((mic_y + Ly/2) / h - 0.5), 0, ny - 1) + ny * clamp(round((mic_z + Lz/2) / h - 0.5), 0, nz - 1))",
245+ "cs_worst": "2 * L1 * f0__max",
246+ "kap2_worst": "B__max * (cs_worst * cs_worst * L1 * L1) / (pi * pi)",
247+ "a_worst": "cs_worst*cs_worst*dt*dt + 4*sig1__max*dt",
248+ "h_min": "sqrt((a_worst + sqrt(a_worst*a_worst + 16*kap2_worst*dt*dt)) / 2) / 0.95",
249+ "n1": "max(5, min(1024, floor(L1 / h_min) + 1))",
250+ "h1": "L1 / (n1 - 1)",
251+ "mx": "n1 - 2",
252+ "n_rows": "mx + 1"
253+ },
254+ "shaders": {
255+ "medium": {
256+ "code": "// The medium: the body's rigid mask, the absorption, and where the object\n// couples into the air. One thread per air cell.\nconst PI = 3.14159265358979;\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride h: f32;\noverride Lx: f32;\noverride Ly: f32;\noverride Lz: f32;\noverride boxl: f32;\noverride boxw: f32;\noverride boxd: f32;\noverride thick: f32;\noverride holer: f32;\noverride holex: f32;\noverride absorb: f32;\noverride oy: f32;\noverride oz: f32;\noverride L1: f32;\n@group(0) @binding(0) var<storage, read_write> wall: array<f32>;\n@group(0) @binding(1) var<storage, read_write> sig: array<f32>;\n@group(0) @binding(2) var<storage, read_write> prof: array<f32>;\n@group(0) @binding(3) var<storage, read_write> bprof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i % nx;\n let iy = (i / nx) % ny;\n let iz = i / (nx * ny);\n let x = -Lx/2.0 + (f32(ix) + 0.5) * h;\n let y = -Ly/2.0 + (f32(iy) + 0.5) * h;\n let z = -Lz/2.0 + (f32(iz) + 0.5) * h;\n let sw = 0.45 * h;\n // The box: the outer solid minus its cavity, top plate at z = 0, with a\n // round sound hole cut out of the top plate only.\n let box_ox = 0.5 * (1.0 - tanh((abs(x) - boxl/2.0) / sw));\n let box_oy = 0.5 * (1.0 - tanh((abs(y) - boxw/2.0) / sw));\n let box_oz = 0.5 * (1.0 - tanh((abs(z + boxd/2.0) - boxd/2.0) / sw));\n let box_ix = 0.5 * (1.0 - tanh((abs(x) - (boxl/2.0 - thick)) / sw));\n let box_iy = 0.5 * (1.0 - tanh((abs(y) - (boxw/2.0 - thick)) / sw));\n let box_iz = 0.5 * (1.0 - tanh((abs(z + boxd/2.0) - (boxd/2.0 - thick)) / sw));\n var shell = max(0.0, box_ox * box_oy * box_oz - box_ix * box_iy * box_iz);\n let hole_r = sqrt((x - holex)*(x - holex) + y*y);\n let hole = 0.5 * (1.0 - tanh((hole_r - holer) / sw));\n let top = 0.5 * (1.0 - tanh((abs(z + thick/2.0) - thick/2.0) / sw));\n shell = max(0.0, shell - hole * top);\n let w = 1.0 - shell;\n wall[i] = w;\n // Open boundary at the domain edge (a quadratic absorbing ramp over the\n // outer 0.15 Ly of each face), plus the body's lossy surface skin.\n let spw = 0.15 * Ly;\n let dx = max(0.0, spw - (Lx/2.0 - abs(x))) / spw;\n let dy = max(0.0, spw - (Ly/2.0 - abs(y))) / spw;\n let dz = max(0.0, spw - (Lz/2.0 - abs(z))) / spw;\n let d = max(max(dx, dy), dz);\n sig[i] = 9000.0 * d * d + absorb * (4.0 * shell * (1.0 - shell));\n // The object radiates from its line: a tube two cells wide around\n // (y = oy, z = oz), fading at its ends, cross-section normalized to one.\n let aw = 2.0 * h;\n let tube = exp(-((y - oy)*(y - oy) + (z - oz)*(z - oz)) / (aw*aw)) / (PI * aw * aw);\n let span = 0.5 * (1.0 + tanh((L1/2.0 - abs(x)) / (1.5*h)));\n prof[i] = tube * span * w;\n // ...and its bridge force drives a patch of air just above the plate,\n // around the x = +L/2 end (a stand-in for a moving top plate).\n let pw = 1.5 * h;\n let pr = exp(-((x - L1/2.0)*(x - L1/2.0) + (y - oy)*(y - oy)) / (0.04*0.04)) / (PI * 0.04 * 0.04);\n let skin = exp(-(z - pw)*(z - pw) / (pw*pw)) / (sqrt(PI) * pw);\n bprof[i] = pr * skin * w;\n}\n",
257+ "constants": [
258+ "npts",
259+ "nx",
260+ "ny",
261+ "h",
262+ "Lx",
263+ "Ly",
264+ "Lz",
265+ "boxl",
266+ "boxw",
267+ "boxd",
268+ "thick",
269+ "holer",
270+ "holex",
271+ "absorb",
272+ "oy",
273+ "oz",
274+ "L1"
275+ ]
276+ },
277+ "sines_x": {
278+ "code": "// Sine mode m of the object grid, interpolated to air\n// x centers: sx[(mode-1) * nx + ix]. One thread per table entry.\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride nx: u32;\noverride L1: f32;\noverride h1: f32;\noverride Lx: f32;\noverride h: f32;\n@group(0) @binding(0) var<storage, read_write> table: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * nx) { return; }\n let mi = i % mx;\n let ia = i / mx;\n let xw = -Lx/2.0 + (f32(ia) + 0.5) * h;\n let u = (xw + L1/2.0) / h1;\n let j = i32(floor(u));\n var v = 0.0;\n if (j >= 0 && j < i32(n1) - 1) {\n let f = u - f32(j);\n let mm = f32(mi + 1u);\n let s0 = sin(mm * PI * f32(j) / f32(n1 - 1u));\n let s1 = sin(mm * PI * f32(j + 1) / f32(n1 - 1u));\n v = (1.0 - f) * s0 + f * s1;\n }\n table[mi * nx + ia] = v;\n}\n",
279+ "constants": [
280+ "n1",
281+ "mx",
282+ "nx",
283+ "L1",
284+ "h1",
285+ "Lx",
286+ "h"
287+ ]
288+ },
289+ "impulse": {
290+ "code": "// The reciprocal kick: one unit impulse at the microphone cell, entering\n// the way a source enters the first step. One thread.\noverride mic_index: u32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> p: array<f32>;\n@group(0) @binding(1) var<storage, read> wall: array<f32>;\n@group(0) @binding(2) var<storage, read> sig: array<f32>;\n@compute @workgroup_size(1)\nfn main() {\n p[mic_index] = dt * dt * wall[mic_index] / (1.0 + sig[mic_index] * dt);\n}\n",
291+ "constants": [
292+ "mic_index",
293+ "dt"
294+ ]
295+ },
296+ "step": {
297+ "code": "// One leapfrog step of the air: the wall-masked Laplacian (each stencil\n// face scaled by the mask at the neighbour it reads, the rigid condition in\n// divergence form) fused with the update. One thread per air cell.\noverride npts: u32;\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\noverride h: f32;\noverride dt: f32;\noverride c0: f32;\n@group(0) @binding(0) var<storage, read> p: array<f32>;\n@group(0) @binding(1) var<storage, read> pm: array<f32>;\n@group(0) @binding(2) var<storage, read_write> pn: array<f32>;\n@group(0) @binding(3) var<storage, read> wall: array<f32>;\n@group(0) @binding(4) var<storage, read> sig: array<f32>;\nfn face(ix: i32, iy: i32, iz: i32, pc: f32) -> f32 {\n if (ix < 0 || ix >= i32(nx) || iy < 0 || iy >= i32(ny) || iz < 0 || iz >= i32(nz)) {\n return -pc; // outside the domain: p = 0, open\n }\n let k = u32(ix) + nx * (u32(iy) + ny * u32(iz));\n return wall[k] * (p[k] - pc);\n}\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= npts) { return; }\n let ix = i32(i % nx);\n let iy = i32((i / nx) % ny);\n let iz = i32(i / (nx * ny));\n let pc = p[i];\n let s = face(ix - 1, iy, iz, pc) + face(ix + 1, iy, iz, pc)\n + face(ix, iy - 1, iz, pc) + face(ix, iy + 1, iz, pc)\n + face(ix, iy, iz - 1, pc) + face(ix, iy, iz + 1, pc);\n let lp = wall[i] * s / (h * h);\n let sd = sig[i] * dt;\n pn[i] = wall[i] * ((2.0 * pc - (1.0 - sd) * pm[i] + (c0 * dt) * (c0 * dt) * lp) / (1.0 + sd));\n}\n",
298+ "constants": [
299+ "npts",
300+ "nx",
301+ "ny",
302+ "nz",
303+ "h",
304+ "dt",
305+ "c0"
306+ ]
307+ },
308+ "plane": {
309+ "code": "// Contract the field against the radiation profile over z:\n// one thread per air column.\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\n@group(0) @binding(0) var<storage, read_write> plane: array<f32>;\n@group(0) @binding(1) var<storage, read> p: array<f32>;\n@group(0) @binding(2) var<storage, read> prof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= nx * ny) { return; }\n var s = 0.0;\n for (var iz = 0u; iz < nz; iz++) {\n let k = i + nx * ny * iz;\n s += prof[k] * p[k];\n }\n plane[i] = s;\n}\n",
310+ "constants": [
311+ "nx",
312+ "ny",
313+ "nz"
314+ ]
315+ },
316+ "xc": {
317+ "code": "// Contract the plane against every x sine row: tmp[m + mx * iy].\noverride nx: u32;\noverride ny: u32;\noverride mx: u32;\n@group(0) @binding(0) var<storage, read_write> tmp: array<f32>;\n@group(0) @binding(1) var<storage, read> plane: array<f32>;\n@group(0) @binding(2) var<storage, read> sx: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * ny) { return; }\n let m = i % mx;\n let iy = i / mx;\n var s = 0.0;\n for (var ix = 0u; ix < nx; ix++) {\n s += sx[m * nx + ix] * plane[ix + nx * iy];\n }\n tmp[i] = s;\n}\n",
318+ "constants": [
319+ "nx",
320+ "ny",
321+ "mx"
322+ ]
323+ },
324+ "proj": {
325+ "code": "// A line object's y factor is one everywhere: finish by summing tmp over\n// iy and write this step's sample of every kernel row.\noverride ny: u32;\noverride mx: u32;\noverride ir_steps: u32;\n@group(0) @binding(0) var<storage, read_write> kernels: array<f32>;\n@group(0) @binding(1) var<storage, read> tmp: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx) { return; }\n var s = 0.0;\n for (var iy = 0u; iy < ny; iy++) {\n s += tmp[i + mx * iy];\n }\n let t = tick[0];\n if (t < ir_steps) {\n kernels[i * ir_steps + t] = s;\n }\n}\n",
326+ "constants": [
327+ "ny",
328+ "mx",
329+ "ir_steps"
330+ ]
331+ },
332+ "excite_x": {
333+ "code": "// Project this axis's excitation profile onto each recorded sine mode:\n// bx[e * mx + (mode-1)]. One thread per (event, mode).\nconst PI = 3.14159265358979;\noverride n1: u32;\noverride mx: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride L1: f32;\n@group(0) @binding(0) var<storage, read_write> bout: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= mx * arrayLength(&ev) / ev_stride) { return; }\n let mi = i % mx;\n let e = i / mx;\n let base = e * ev_stride;\n let pluckpos = ev[base + 5u];\n let amp = ev[base + 6u];\n let mm = f32(mi + 1u);\n var s = 0.0;\n for (var k = 1u; k < n1 - 1u; k++) {\n let xi = f32(k) * h1;\n let ta = xi / (pluckpos * L1);\n let tb = (L1 - xi) / (L1 - pluckpos * L1);\n let u0 = amp * 0.5 * (ta + tb - abs(ta - tb));\n s += sin(mm * PI * f32(k) / f32(n1 - 1u)) * u0;\n }\n bout[i] = s;\n}\n",
334+ "constants": [
335+ "n1",
336+ "mx",
337+ "ev_stride",
338+ "h1",
339+ "L1"
340+ ]
341+ },
342+ "slots": {
343+ "code": "// One thread per (event, mode) of the string: the stiff damped string's\n// roots, its radiation slots, and the bridge slots its arriving slope\n// drives (the last kernel row, row mx).\nconst PI = 3.14159265358979;\noverride mx: u32;\noverride n1: u32;\noverride ev_stride: u32;\noverride h1: f32;\noverride L1: f32;\noverride dt: f32;\n@group(0) @binding(0) var<storage, read_write> slots: array<f32>;\n@group(0) @binding(1) var<storage, read> ev: array<f32>;\n@group(0) @binding(2) var<storage, read> bx: array<f32>;\n\nfn cmul(ar: f32, ai: f32, br: f32, bi: f32) -> vec2f {\n return vec2f(ar * br - ai * bi, ar * bi + ai * br);\n}\n// c = a * gain * (z - 2 + 1/z) = a * gain * (z-1)^2 / z, the acceleration\n// series, with z-1 supplied stably (the naive z - 1 cancels).\nfn acc_scale(p: vec4f, zm1Re: f32, gain: f32) -> vec4f {\n let m2 = p.z * p.z + p.w * p.w;\n if (m2 == 0.0) { return vec4f(0.0); }\n let sq = cmul(zm1Re, p.w, zm1Re, p.w);\n let num = cmul(sq.x, sq.y, p.z, -p.w);\n let c = cmul(p.x, p.y, gain * num.x / m2, gain * num.y / m2);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n// c = a * gain * z, the next-displacement series (what a string's bridge\n// slope is read from).\nfn next_scale(p: vec4f, gain: f32) -> vec4f {\n let c = cmul(p.x, p.y, gain * p.z, gain * p.w);\n return vec4f(c.x, c.y, p.z, p.w);\n}\n\n\nfn write_slot(idx: u32, p: vec4f, row: f32, start: f32) {\n slots[idx * 6u] = p.x;\n slots[idx * 6u + 1u] = p.y;\n slots[idx * 6u + 2u] = p.z;\n slots[idx * 6u + 3u] = p.w;\n slots[idx * 6u + 4u] = row;\n slots[idx * 6u + 5u] = start;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n let n_events = arrayLength(&ev) / ev_stride;\n if (i >= n_events * mx) { return; }\n let e = i / mx;\n let mi = i % mx;\n let base = e * ev_stride;\n let start = ev[base];\n let f0 = ev[base + 1u];\n let B = ev[base + 2u];\n let t60 = ev[base + 3u];\n let sig1 = ev[base + 4u];\n let gair = ev[base + 7u];\n let gbridge = ev[base + 8u];\n let m = f32(mi + 1u);\n let b = bx[i] * 2.0 / f32(n1 - 1u);\n let s1 = sin(m * PI / (2.0 * f32(n1 - 1u)));\n let lam = (4.0 / (h1 * h1)) * s1 * s1;\n let cs = 2.0 * L1 * f0;\n let kap2 = B * (cs * L1 / PI) * (cs * L1 / PI);\n let w2 = cs * cs * lam + kap2 * lam * lam;\n\n let d = (6.91 / t60) * dt;\n let opd = 1.0 + d;\n let g = d + 2.0 * sig1 * dt * lam;\n let x = dt * dt * w2 + 2.0 * sig1 * dt * lam;\n let zRe = (1.0 - 0.5 * x) / opd;\n let zm1Re = -(0.5 * x + d) / opd;\n let nn = dt * dt * w2 - g * d - 0.25 * x * x;\n var p0 = vec4f(0.0);\n var p1 = vec4f(0.0);\n var zm10 = 0.0;\n var zm11 = 0.0;\n if (nn > 0.0) {\n let zIm = sqrt(nn) / opd;\n let mag2 = (1.0 - g) / opd;\n let aRe = b;\n let aIm = b * ((0.5 * x - g) / opd) / zIm;\n p0 = vec4f(aRe, aIm, zRe, zIm);\n zm10 = zm1Re;\n } else {\n let sq = sqrt(-nn) / opd;\n let r1 = zRe + sq;\n let r2 = zRe - sq;\n if (r1 != r2 && r1 != 0.0 && r2 != 0.0) {\n let det = 1.0 / r1 - 1.0 / r2;\n let a1 = (b - b / r2) / det;\n let a2 = b - a1;\n p0 = vec4f(a1, 0.0, r1, 0.0);\n p1 = vec4f(a2, 0.0, r2, 0.0);\n zm10 = zm1Re + sq;\n zm11 = zm1Re - sq;\n }\n }\n let ga = gair / (dt * dt);\n write_slot(i * 4u, acc_scale(p0, zm10, ga), f32(mi), start);\n write_slot(i * 4u + 1u, acc_scale(p1, zm11, ga), f32(mi), start);\n // The bridge: the far end is pinned, so the arriving slope is\n // -u[n-2]/h1, and the generated step's term is\n // -(gbridge cs^2 / L) bprof fb; together, +gbridge cs^2/(L h1) times\n // sin(m pi (n1-2)/(n1-1)) times the next-displacement series.\n let gb = (gbridge * cs * cs / (L1 * h1)) * sin(m * PI * f32(n1 - 2u) / f32(n1 - 1u));\n write_slot(i * 4u + 2u, next_scale(p0, gb), f32(mx), start);\n write_slot(i * 4u + 3u, next_scale(p1, gb), f32(mx), start);\n}\n",
344+ "constants": [
345+ "mx",
346+ "n1",
347+ "ev_stride",
348+ "h1",
349+ "L1",
350+ "dt"
351+ ]
352+ },
353+ "synth": {
354+ "code": "// One thread per slot: a complex one-pole over this tile of samples, the\n// kernel row as input while it lasts and silence after (the closed-form\n// tail). State persists across tiles.\noverride tile: u32;\noverride total_steps: u32;\noverride ir_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> slots: array<f32>;\n@group(0) @binding(1) var<storage, read_write> state: array<f32>;\n@group(0) @binding(2) var<storage, read> kernels: array<f32>;\n@group(0) @binding(3) var<storage, read_write> partial: array<f32>;\n@group(0) @binding(4) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= n_slots) { return; }\n let cRe = slots[i * 6u];\n let cIm = slots[i * 6u + 1u];\n let zRe = slots[i * 6u + 2u];\n let zIm = slots[i * 6u + 3u];\n let row = u32(slots[i * 6u + 4u]);\n let start = u32(slots[i * 6u + 5u]);\n var wRe = state[i * 2u];\n var wIm = state[i * 2u + 1u];\n let t0 = tick[0] * tile;\n for (var s = 0u; s < tile; s++) {\n let t = t0 + s;\n if (t >= total_steps) { break; }\n var hv = 0.0;\n if (t >= start && t - start < ir_steps) {\n hv = kernels[row * ir_steps + (t - start)];\n }\n let nRe = zRe * wRe - zIm * wIm + hv;\n let nIm = zRe * wIm + zIm * wRe;\n wRe = nRe;\n wIm = nIm;\n partial[i * tile + s] = cRe * wRe - cIm * wIm;\n }\n state[i * 2u] = wRe;\n state[i * 2u + 1u] = wIm;\n}\n",
355+ "constants": [
356+ "tile",
357+ "total_steps",
358+ "ir_steps",
359+ "n_slots"
360+ ]
361+ },
362+ "reduce": {
363+ "code": "// One thread per sample of the tile: sum every slot's contribution.\noverride tile: u32;\noverride total_steps: u32;\noverride n_slots: u32;\n@group(0) @binding(0) var<storage, read> partial: array<f32>;\n@group(0) @binding(1) var<storage, read_write> trace: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= tile) { return; }\n let t = tick[0] * tile + i;\n if (t >= total_steps) { return; }\n var s = 0.0;\n for (var q = 0u; q < n_slots; q++) {\n s += partial[q * tile + i];\n }\n trace[t] = s;\n}\n",
364+ "constants": [
365+ "tile",
366+ "total_steps",
367+ "n_slots"
368+ ]
369+ },
370+ "bplane": {
371+ "code": "// Contract the field against the bridge patch profile over z:\n// one thread per air column.\noverride nx: u32;\noverride ny: u32;\noverride nz: u32;\n@group(0) @binding(0) var<storage, read_write> bplane: array<f32>;\n@group(0) @binding(1) var<storage, read> p: array<f32>;\n@group(0) @binding(2) var<storage, read> bprof: array<f32>;\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let i = gid.x + gid.y * 4194240u;\n if (i >= nx * ny) { return; }\n var s = 0.0;\n for (var iz = 0u; iz < nz; iz++) {\n let k = i + nx * ny * iz;\n s += bprof[k] * p[k];\n }\n bplane[i] = s;\n}\n",
372+ "constants": [
373+ "nx",
374+ "ny",
375+ "nz"
376+ ]
377+ },
378+ "bridge_proj": {
379+ "code": "// The bridge patch is a single fixed pattern: sum its plane and write the\n// sample into the last kernel row (row mx). One thread.\noverride nx: u32;\noverride ny: u32;\noverride mx: u32;\noverride ir_steps: u32;\n@group(0) @binding(0) var<storage, read_write> kernels: array<f32>;\n@group(0) @binding(1) var<storage, read> bplane: array<f32>;\n@group(0) @binding(2) var<storage, read> tick: array<u32>;\n@compute @workgroup_size(1)\nfn main() {\n var s = 0.0;\n for (var k = 0u; k < nx * ny; k++) {\n s += bplane[k];\n }\n let t = tick[0];\n if (t < ir_steps) {\n kernels[mx * ir_steps + t] = s;\n }\n}\n",
380+ "constants": [
381+ "nx",
382+ "ny",
383+ "mx",
384+ "ir_steps"
385+ ]
386+ }
387+ },
388+ "precompute": {
389+ "buffers": [
390+ {
391+ "name": "wall",
392+ "count": "npts"
393+ },
394+ {
395+ "name": "sig",
396+ "count": "npts"
397+ },
398+ {
399+ "name": "prof",
400+ "count": "npts"
401+ },
402+ {
403+ "name": "bprof",
404+ "count": "npts"
405+ },
406+ {
407+ "name": "sx",
408+ "count": "mx * nx"
409+ },
410+ {
411+ "name": "p",
412+ "count": "npts"
413+ },
414+ {
415+ "name": "pm",
416+ "count": "npts"
417+ },
418+ {
419+ "name": "pn",
420+ "count": "npts"
421+ },
422+ {
423+ "name": "plane",
424+ "count": "nx * ny"
425+ },
426+ {
427+ "name": "bplane",
428+ "count": "nx * ny"
429+ },
430+ {
431+ "name": "tmp",
432+ "count": "mx * ny"
433+ },
434+ {
435+ "name": "kernels",
436+ "count": "n_rows * ir_steps"
437+ }
438+ ],
439+ "setup": [
440+ {
441+ "shader": "medium",
442+ "buffers": [
443+ "wall",
444+ "sig",
445+ "prof",
446+ "bprof"
447+ ],
448+ "dispatch": "npts"
449+ },
450+ {
451+ "shader": "sines_x",
452+ "buffers": [
453+ "sx"
454+ ],
455+ "dispatch": "mx * nx"
456+ },
457+ {
458+ "shader": "impulse",
459+ "buffers": [
460+ "p",
461+ "wall",
462+ "sig"
463+ ],
464+ "dispatch": "1"
465+ }
466+ ],
467+ "loop": {
468+ "count": "ir_steps",
469+ "batch": 512,
470+ "rotate": [
471+ [
472+ "p",
473+ "pm",
474+ "pn"
475+ ]
476+ ],
477+ "body": [
478+ {
479+ "shader": "plane",
480+ "buffers": [
481+ "plane",
482+ "p",
483+ "prof"
484+ ],
485+ "dispatch": "nx * ny"
486+ },
487+ {
488+ "shader": "xc",
489+ "buffers": [
490+ "tmp",
491+ "plane",
492+ "sx"
493+ ],
494+ "dispatch": "mx * ny"
495+ },
496+ {
497+ "shader": "proj",
498+ "buffers": [
499+ "kernels",
500+ "tmp",
501+ "__tick"
502+ ],
503+ "dispatch": "mx"
504+ },
505+ {
506+ "shader": "bplane",
507+ "buffers": [
508+ "bplane",
509+ "p",
510+ "bprof"
511+ ],
512+ "dispatch": "nx * ny"
513+ },
514+ {
515+ "shader": "bridge_proj",
516+ "buffers": [
517+ "kernels",
518+ "bplane",
519+ "__tick"
520+ ],
521+ "dispatch": "1"
522+ },
523+ {
524+ "shader": "step",
525+ "buffers": [
526+ "p",
527+ "pm",
528+ "pn",
529+ "wall",
530+ "sig"
531+ ],
532+ "dispatch": "npts"
533+ }
534+ ]
535+ },
536+ "outputs": [
537+ "kernels"
538+ ]
539+ },
540+ "render": {
541+ "buffers": [
542+ {
543+ "name": "kernels",
544+ "count": "n_rows * ir_steps"
545+ },
546+ {
547+ "name": "bx",
548+ "count": "n_events * mx"
549+ },
550+ {
551+ "name": "slots",
552+ "count": "n_slots * 6"
553+ },
554+ {
555+ "name": "state",
556+ "count": "n_slots * 2"
557+ },
558+ {
559+ "name": "partial",
560+ "count": "n_slots * tile"
561+ },
562+ {
563+ "name": "trace",
564+ "count": "total_steps"
565+ }
566+ ],
567+ "inputs": [
568+ "kernels"
569+ ],
570+ "eventParams": [
571+ "f0",
572+ "B",
573+ "t60",
574+ "sig1",
575+ "pluckpos",
576+ "amp",
577+ "gair",
578+ "gbridge"
579+ ],
580+ "derived": {
581+ "ev_stride": "9",
582+ "n_slots": "n_events * mx * 4"
583+ },
584+ "setup": [
585+ {
586+ "shader": "excite_x",
587+ "buffers": [
588+ "bx",
589+ "__events"
590+ ],
591+ "dispatch": "n_events * mx"
592+ },
593+ {
594+ "shader": "slots",
595+ "buffers": [
596+ "slots",
597+ "__events",
598+ "bx"
599+ ],
600+ "dispatch": "n_events * mx"
601+ }
602+ ],
603+ "loop": {
604+ "count": "n_tiles",
605+ "batch": 8,
606+ "body": [
607+ {
608+ "shader": "synth",
609+ "buffers": [
610+ "slots",
611+ "state",
612+ "kernels",
613+ "partial",
614+ "__tick"
615+ ],
616+ "dispatch": "n_slots"
617+ },
618+ {
619+ "shader": "reduce",
620+ "buffers": [
621+ "partial",
622+ "trace",
623+ "__tick"
624+ ],
625+ "dispatch": "tile"
626+ }
627+ ]
628+ },
629+ "trace": "trace"
630+ },
631+ "visual": {
632+ "dims": [
633+ "nx",
634+ "ny",
635+ "nz"
636+ ],
637+ "extents": [
638+ "Lx",
639+ "Ly",
640+ "Lz"
641+ ],
642+ "buffers": [
643+ {
644+ "name": "wall",
645+ "count": "npts"
646+ },
647+ {
648+ "name": "sig",
649+ "count": "npts"
650+ },
651+ {
652+ "name": "prof",
653+ "count": "npts"
654+ },
655+ {
656+ "name": "bprof",
657+ "count": "npts"
658+ }
659+ ],
660+ "setup": [
661+ {
662+ "shader": "medium",
663+ "buffers": [
664+ "wall",
665+ "sig",
666+ "prof",
667+ "bprof"
668+ ],
669+ "dispatch": "npts"
670+ }
671+ ],
672+ "volumes": [
673+ {
674+ "buffer": "wall",
675+ "style": "solid",
676+ "invert": true,
677+ "color": [
678+ 0.62,
679+ 0.48,
680+ 0.34
681+ ]
682+ },
683+ {
684+ "buffer": "prof",
685+ "style": "cloud",
686+ "color": [
687+ 1,
688+ 0.45,
689+ 0.2
690+ ]
691+ },
692+ {
693+ "buffer": "bprof",
694+ "style": "cloud",
695+ "color": [
696+ 0.35,
697+ 0.65,
698+ 1
699+ ]
700+ }
701+ ],
702+ "markers": [
703+ {
704+ "kind": "line",
705+ "from": [
706+ "-L1/2",
707+ "oy",
708+ "oz"
709+ ],
710+ "to": [
711+ "L1/2",
712+ "oy",
713+ "oz"
714+ ],
715+ "color": [
716+ 1,
717+ 0.85,
718+ 0.3
719+ ],
720+ "label": "string"
721+ },
722+ {
723+ "kind": "point",
724+ "at": [
725+ "mic_x",
726+ "mic_y",
727+ "mic_z"
728+ ],
729+ "color": [
730+ 0.3,
731+ 0.85,
732+ 1
733+ ],
734+ "label": "mic"
735+ }
736+ ],
737+ "partials": {
738+ "buffer": "slots",
739+ "stride": 6,
740+ "zRe": 2,
741+ "zIm": 3
742+ }
743+ }
744+}
package-lock.jsonadded+2385−0View file
This diff is 2,390 lines long and is not shown.
package.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "name": "instrawave",
3+ "version": "0.1.0",
4+ "description": "Instruments as files: a parameter spec plus raw WebGPU programs for precomputing a room and rendering audio from it",
5+ "type": "module",
6+ "engines": {
7+ "node": ">=22.6"
8+ },
9+ "license": "Apache-2.0",
10+ "scripts": {
11+ "dev": "vite",
12+ "build": "tsc --noEmit && vite build",
13+ "author": "vite-node scripts/author.ts",
14+ "render": "vite-node scripts/render.ts",
15+ "test:node": "vite-node scripts/test-node.ts",
16+ "test": "npm run test:node"
17+ },
18+ "optionalDependencies": {
19+ "webgpu": "^0.4.0"
20+ },
21+ "devDependencies": {
22+ "@types/node": "^26.1.1",
23+ "@webgpu/types": "^0.1.44",
24+ "typescript": "^5.5.0",
25+ "vite": "^5.4.0",
26+ "vite-node": "^6.0.0"
27+ }
28+}
scripts/author.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+/**
2+ * Write the three v1 instrument files. The builder (author/build.ts) is the
3+ * source of truth; the JSON in instruments/ is its checked-in output, so
4+ * `npm run author` after an edit regenerates them deterministically.
5+ */
6+import { mkdirSync, writeFileSync } from 'node:fs';
7+import { buildInstrument } from '../author/build.ts';
8+
9+const instruments = [
10+ buildInstrument({
11+ name: 'drum',
12+ description:
13+ 'A struck membrane over a cylindrical shell. The head’s inharmonic modes plus the cavity under it are what make it a drum.',
14+ object: 'membrane',
15+ body: 'shell',
16+ }),
17+ buildInstrument({
18+ name: 'dulcimer',
19+ description:
20+ 'One plucked string over a rigid box with a sound hole, radiating along its line and driving a bridge patch above the plate.',
21+ object: 'string',
22+ body: 'box',
23+ defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 },
24+ }),
25+ buildInstrument({
26+ name: 'chime',
27+ description:
28+ 'A stiff bar in open air, struck. No tension, so the stiffness sets the partials at n²·f1, which is the bell-like sound.',
29+ object: 'bar',
30+ body: 'none',
31+ defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 },
32+ }),
33+];
34+
35+mkdirSync(new URL('../instruments', import.meta.url), { recursive: true });
36+for (const inst of instruments) {
37+ const path = new URL(`../instruments/${inst.name}.instrument.json`, import.meta.url);
38+ writeFileSync(path, `${JSON.stringify(inst, null, 2)}\n`);
39+ console.log(`wrote instruments/${inst.name}.instrument.json (${inst.params.length} params, ${Object.keys(inst.shaders).length} shaders)`);
40+}
scripts/nodeWebGpu.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+/**
2+ * Desktop WebGPU for the command-line scripts, via the optional `webgpu`
3+ * package (prebuilt Google Dawn). Installs Dawn under the globals the
4+ * host code expects (navigator.gpu, GPUBufferUsage, ...) so everything
5+ * under src/ runs here unchanged.
6+ */
7+export const errMsg = (e: unknown): string =>
8+ e instanceof Error ? e.message : String(e);
9+
10+export async function installWebGpu(): Promise<string> {
11+ const specifier = 'webgpu';
12+ let mod: { create: (flags: string[]) => GPU; globals: Record<string, unknown> };
13+ try {
14+ mod = await import(specifier);
15+ } catch (e) {
16+ const detail = errMsg(e);
17+ if (/Cannot find (package|module) '?webgpu'?/.test(detail)) {
18+ throw new Error(
19+ 'desktop WebGPU needs the optional `webgpu` package (prebuilt Google Dawn):\n' +
20+ ' npm install webgpu',
21+ );
22+ }
23+ throw new Error(`the \`webgpu\` package is installed but did not load:\n ${detail}`);
24+ }
25+ Object.assign(globalThis, mod.globals);
26+ const dawnFlags = process.env.DAWN_FLAGS?.split(';').filter(Boolean) ?? [];
27+ Object.defineProperty(globalThis, 'navigator', {
28+ value: { gpu: mod.create(dawnFlags) },
29+ configurable: true,
30+ writable: true,
31+ });
32+ const { version } = await import(`${specifier}/package.json`, {
33+ with: { type: 'json' },
34+ }).then(
35+ (m) => m.default as { version: string },
36+ () => ({ version: '?' }),
37+ );
38+ return `node-webgpu ${version} (Google Dawn)`;
39+}
scripts/render.tsadded+145−0View file
@@ -0,0 +1,145 @@
1+/**
2+ * The headless renderer: instrument file in, .wav out.
3+ *
4+ * npm run render -- instruments/drum.instrument.json --seconds 5
5+ * npm run render -- instruments/drum.instrument.json --set f0=90 --set nx=128
6+ * npm run render -- instruments/drum.instrument.json --events hits.json
7+ *
8+ * The bake is kept in a sidecar file (<instrument>.bake by default) keyed
9+ * to the geometry and accuracy parameters; a render whose key matches
10+ * reuses it and only the cheap synthesis runs. --events takes a JSON list
11+ * of stimuli, [{"t": 0}, {"t": 0.5, "set": {"f0": 147}}], each event an
12+ * excitation at time t with any tuning parameter overridden for that
13+ * event alone; with no list, one event at t = 0.
14+ */
15+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16+import { parseBake, serializeBake } from '../src/host/bake.ts';
17+import { requestDevice } from '../src/host/device.ts';
18+import { InstrumentSession, kernelDecay } from '../src/host/session.ts';
19+import { parseInstrument, paramValues, type StimulusEvent } from '../src/format.ts';
20+import { traceToWavBytes, WAV_RATE } from '../src/wav.ts';
21+import { errMsg, installWebGpu } from './nodeWebGpu.ts';
22+
23+const usage = `usage: npm run render -- <instrument.json> [options]
24+ --seconds <s> trace length (default 5)
25+ --set k=v override a parameter (repeatable)
26+ --events <file> stimulus list JSON: [{"t": 0, "set": {...}}, ...]
27+ --out <file.wav> output (default <instrument>.wav)
28+ --bake <file> bake sidecar (default <instrument>.bake)
29+ --rebake precompute even if a matching bake exists
30+ --info print parameters and derived values, then exit`;
31+
32+const args = process.argv.slice(2);
33+let file: string | null = null;
34+let seconds = 5;
35+let out: string | null = null;
36+let bakePath: string | null = null;
37+let eventsPath: string | null = null;
38+let rebake = false;
39+let info = false;
40+const sets: Record<string, number> = {};
41+for (let i = 0; i < args.length; i++) {
42+ const a = args[i];
43+ const next = (): string => {
44+ if (i + 1 >= args.length) throw new Error(`${a} needs a value`);
45+ return args[++i];
46+ };
47+ if (a === '--seconds') seconds = Number(next());
48+ else if (a === '--out') out = next();
49+ else if (a === '--bake') bakePath = next();
50+ else if (a === '--events') eventsPath = next();
51+ else if (a === '--rebake') rebake = true;
52+ else if (a === '--info') info = true;
53+ else if (a === '--set') {
54+ const kv = next();
55+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(-?[0-9.eE+-]+)$/.exec(kv);
56+ if (!m) throw new Error(`--set wants key=value, got '${kv}'`);
57+ sets[m[1]] = Number(m[2]);
58+ } else if (a === '--help' || a === '-h') {
59+ console.log(usage);
60+ process.exit(0);
61+ } else if (!a.startsWith('--') && file === null) file = a;
62+ else throw new Error(`unknown argument '${a}'\n${usage}`);
63+}
64+if (!file) {
65+ console.log(usage);
66+ process.exit(1);
67+}
68+
69+const inst = parseInstrument(readFileSync(file, 'utf8'));
70+const values = paramValues(inst, sets);
71+const stem = file.replace(/\.instrument\.json$|\.json$/, '');
72+const outPath = out ?? `${stem}.wav`;
73+const bakeFile = bakePath ?? `${stem}.bake`;
74+
75+let events: StimulusEvent[] = [{ t: 0 }];
76+if (eventsPath) {
77+ events = JSON.parse(readFileSync(eventsPath, 'utf8')) as StimulusEvent[];
78+ if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) {
79+ throw new Error(`${eventsPath} must be a JSON list of {"t": seconds, "set": {...}}`);
80+ }
81+}
82+
83+console.log(await installWebGpu());
84+const device = await requestDevice();
85+const session = new InstrumentSession(device, inst, values);
86+const scope = session.scope();
87+
88+console.log(
89+ `${inst.name}: air ${scope.nx}x${scope.ny}x${scope.nz}, dt = ${(1e6 * scope.dt).toFixed(2)} µs ` +
90+ `(${Math.round(1 / scope.dt).toLocaleString()} steps/s), ${scope.n_rows} kernel rows of ${scope.ir_steps} steps`,
91+);
92+if (info) {
93+ for (const p of inst.params) {
94+ console.log(` ${p.class.padEnd(8)} ${p.key.padEnd(12)} = ${values[p.key]} ${p.unit} (${p.label})`);
95+ }
96+ console.log(` derived: ${JSON.stringify(scope)}`);
97+ process.exit(0);
98+}
99+
100+const key = await session.bakeKey();
101+let bake = null;
102+if (!rebake && existsSync(bakeFile)) {
103+ const b = parseBake(new Uint8Array(readFileSync(bakeFile)));
104+ if (b.key === key) {
105+ bake = b;
106+ console.log(`bake: reusing ${bakeFile}`);
107+ } else {
108+ console.log(`bake: ${bakeFile} is for different geometry/accuracy parameters; precomputing afresh`);
109+ }
110+}
111+if (!bake) {
112+ const t0 = performance.now();
113+ let lastPct = -1;
114+ bake = await session.precompute((done, total) => {
115+ const pct = Math.floor((100 * done) / total);
116+ if (pct !== lastPct && pct % 10 === 0) {
117+ process.stdout.write(`\rprecompute: ${pct}%`);
118+ lastPct = pct;
119+ }
120+ });
121+ process.stdout.write('\r');
122+ const bytes = serializeBake(bake);
123+ writeFileSync(bakeFile, bytes);
124+ console.log(
125+ `precompute: ${((performance.now() - t0) / 1000).toFixed(1)} s, ` +
126+ `${(bytes.length / 1e6).toFixed(1)} MB -> ${bakeFile}`,
127+ );
128+}
129+const decay = kernelDecay(bake);
130+if (decay !== null) {
131+ console.log(
132+ `room response at truncation: ${decay > 0 ? (20 * Math.log10(decay)).toFixed(0) : '-inf'} dB` +
133+ (decay > 1e-3 ? ' (still ringing; consider a longer ir_seconds)' : ''),
134+ );
135+}
136+
137+const t1 = performance.now();
138+const { trace, dt } = await session.render(bake, seconds, events);
139+console.log(
140+ `render: ${events.length} event${events.length === 1 ? '' : 's'}, ` +
141+ `${trace.length.toLocaleString()} samples in ${((performance.now() - t1) / 1000).toFixed(1)} s`,
142+);
143+writeFileSync(outPath, traceToWavBytes(trace, dt));
144+console.log(`wrote ${outPath} (${seconds} s at ${WAV_RATE} Hz)`);
145+process.exit(0);
scripts/test-node.tsadded+350−0View file
@@ -0,0 +1,350 @@
1+/**
2+ * The test suite. Two layers:
3+ *
4+ * - unit checks on the host: the expression evaluator, the bake container
5+ * round-trip, the bake key's tuning-blindness.
6+ * - physics validation: each instrument, rendered from its own bake, must
7+ * match the timestepped soundbox solver for the equivalent scene, run
8+ * through numbl's MATLAB-to-WebGPU pipeline on the same device. That is
9+ * the sense in which numbl stays the source of truth for the physics:
10+ * the instrument files' hand-parameterized WGSL is held, numerically,
11+ * to the compiled solver's output. The gate is soundbox's own 5e-3.
12+ *
13+ * The soundbox import assumes the vault layout (a sibling checkout at
14+ * ../soundbox with its node_modules installed).
15+ */
16+import { parseBake, serializeBake } from '../src/host/bake.ts';
17+import { runPhase } from '../src/host/engine.ts';
18+import { raymarchWgsl, slotPartials } from '../src/host/view.ts';
19+import { spectrumOf } from '../src/fft.ts';
20+import { evalDerived, evalExpr } from '../src/expr.ts';
21+import { requestDevice } from '../src/host/device.ts';
22+import { InstrumentSession } from '../src/host/session.ts';
23+import { parseInstrument, paramValues, type InstrumentFile } from '../src/format.ts';
24+import { buildInstrument } from '../author/build.ts';
25+import { errMsg, installWebGpu } from './nodeWebGpu.ts';
26+import { readFileSync } from 'node:fs';
27+
28+// soundbox: the same scenes through the numbl pipeline's timestepped solver.
29+import { StudioSession, specParams } from '../../soundbox/src/studio/session.ts';
30+import { presetByKey, cloneSpec } from '../../soundbox/src/studio/presets.ts';
31+import type { Params, SceneSpec } from '../../soundbox/src/studio/types.ts';
32+
33+let passed = 0;
34+let failed = 0;
35+const check = (name: string, ok: boolean, detail?: string): void => {
36+ console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${!ok && detail ? ` — ${detail}` : ''}`);
37+ if (ok) passed++;
38+ else failed++;
39+};
40+
41+const relErr = (a: Float32Array, b: Float32Array, n: number): number => {
42+ let d = 0;
43+ let r = 0;
44+ for (let i = 0; i < n; i++) {
45+ d += (a[i] - b[i]) * (a[i] - b[i]);
46+ r += b[i] * b[i];
47+ }
48+ return Math.sqrt(d / Math.max(r, 1e-300));
49+};
50+
51+/* ------------------------------------------------------- unit checks --- */
52+
53+console.log('host checks');
54+{
55+ const s = { nx: 96, Lx: 1 };
56+ check('expr arithmetic', evalExpr('nx / 2 + 3 * 4', s) === 60);
57+ check('expr functions', evalExpr('max(5, min(256, floor(0.32 / 0.002) + 1))', {}) === 161);
58+ check('expr clamp/round', evalExpr('clamp(round(2.6), 0, 2)', {}) === 2);
59+ let threw = false;
60+ try {
61+ evalExpr('nx + undefined_name', s);
62+ } catch {
63+ threw = true;
64+ }
65+ check('expr rejects unknown names', threw);
66+}
67+{
68+ const bake = {
69+ key: 'k',
70+ instrument: 'x',
71+ scope: { a: 1.5 },
72+ arrays: { kernels: Float32Array.from([1, 2, 3.5]), other: Float32Array.from([-1]) },
73+ };
74+ const round = parseBake(serializeBake(bake));
75+ check(
76+ 'bake container round-trips',
77+ round.key === 'k' &&
78+ round.scope.a === 1.5 &&
79+ round.arrays.kernels.length === 3 &&
80+ round.arrays.kernels[2] === 3.5 &&
81+ round.arrays.other[0] === -1,
82+ );
83+}
84+
85+const instruments: Record<string, InstrumentFile> = {};
86+for (const name of ['drum', 'dulcimer', 'chime']) {
87+ const text = readFileSync(new URL(`../instruments/${name}.instrument.json`, import.meta.url), 'utf8');
88+ instruments[name] = parseInstrument(text);
89+ const rebuilt = buildInstrument(
90+ name === 'drum'
91+ ? { name: 'drum', description: instruments[name].description, object: 'membrane', body: 'shell' }
92+ : name === 'dulcimer'
93+ ? { name, description: instruments[name].description, object: 'string', body: 'box', defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 } }
94+ : { name, description: instruments[name].description, object: 'bar', body: 'none', defaults: { mic_x: 0.12, mic_y: 0.08, mic_z: 0.1 } },
95+ );
96+ check(
97+ `${name}.instrument.json is the builder's output`,
98+ `${JSON.stringify(rebuilt, null, 2)}\n` === text,
99+ 'run npm run author',
100+ );
101+}
102+
103+console.log(await installWebGpu());
104+const device = await requestDevice();
105+
106+{
107+ const sess = (v: Record<string, number>): InstrumentSession =>
108+ new InstrumentSession(device, instruments.drum, paramValues(instruments.drum, v));
109+ const kA = await sess({}).bakeKey();
110+ const kB = await sess({ f0: 200, strikex: 0.7, t60: 0.4 }).bakeKey();
111+ const kC = await sess({ shellr: 0.2 }).bakeKey();
112+ const kD = await sess({ mic_x: 0.1 }).bakeKey();
113+ const kE = await sess({ nx: 64 }).bakeKey();
114+ check('tuning does not touch the bake key', kA === kB);
115+ check('geometry touches the bake key', kA !== kC && kA !== kD);
116+ check('accuracy touches the bake key', kA !== kE);
117+}
118+
119+/* -------------------------------------------- solver-matched renders --- */
120+
121+const NX = 32;
122+const STEPS = 6000;
123+
124+/** The timestepped reference: the equivalent soundbox scene, compiled by
125+ * numbl, stepped on the same device, recorded at the microphone. */
126+async function reference(
127+ presetKey: string,
128+ edits: Params,
129+ mic: { x: number; y: number; z: number },
130+): Promise<Float32Array> {
131+ const spec: SceneSpec = cloneSpec(presetByKey(presetKey)!.spec);
132+ const s = await StudioSession.create({ device, spec, nx: NX, params: edits });
133+ s.setMic(mic.x, mic.y, mic.z);
134+ s.excite();
135+ s.step(STEPS);
136+ await s.sync();
137+ const trace = await s.recorder.read();
138+ s.destroy();
139+ return trace;
140+}
141+
142+interface Case {
143+ name: string;
144+ preset: string;
145+ /** instrawave parameter overrides beyond nx/ir_seconds. */
146+ set: Record<string, number>;
147+ /** the same edits in soundbox's suffixed names. */
148+ edits: Params;
149+ mic: { x: number; y: number; z: number };
150+}
151+
152+const mic1 = { x: 0.12, y: 0.08, z: 0.1 };
153+const micDrum = { x: 0.15, y: 0.1, z: 0.12 };
154+const cases: Case[] = [
155+ { name: 'chime', preset: 'chime', set: {}, edits: {}, mic: mic1 },
156+ { name: 'dulcimer', preset: 'dulcimer', set: {}, edits: {}, mic: mic1 },
157+ { name: 'drum', preset: 'drum', set: {}, edits: {}, mic: micDrum },
158+];
159+
160+console.log(`\nagainst the timestepped solver (nx = ${NX}, ${STEPS} steps)`);
161+const bakes: Record<string, Awaited<ReturnType<InstrumentSession['precompute']>>> = {};
162+for (const c of cases) {
163+ const inst = instruments[c.name];
164+ const session = new InstrumentSession(device, inst, paramValues(inst, c.set));
165+ const dt = session.dt;
166+ // A kernel at least as long as the trace, so truncation plays no part.
167+ session.values = paramValues(inst, {
168+ ...c.set,
169+ nx: NX,
170+ ir_seconds: (STEPS + 100) * dt,
171+ ...(c.name === 'drum' ? { mode_margin: 4 } : {}),
172+ });
173+ const bake = await session.precompute();
174+ bakes[c.name] = bake;
175+ const { trace } = await session.render(bake, STEPS * session.dt);
176+ const ref = await reference(c.preset, c.edits, c.mic);
177+ const n = Math.min(trace.length, ref.length, STEPS);
178+ const err = relErr(trace, ref, n);
179+ console.log(` ${c.name}: ${bake.scope.n_rows} kernel rows; rel err ${err.toExponential(2)}`);
180+ check(`${c.name} matches the solver`, err < 5e-3, `rel err ${err.toExponential(2)}`);
181+}
182+
183+// The default (geometric) drum mode set, as a number: what the band limit
184+// at the default mode_margin costs against the full solver. Measured on the
185+// development machine: 5.3e-2 at margin 1.25, 4.0e-3 at 2 (the default),
186+// and a 2.4e-3 f32 floor from 2.5 up.
187+{
188+ const inst = instruments.drum;
189+ const margin = inst.params.find((p) => p.key === 'mode_margin')!.value;
190+ const session = new InstrumentSession(device, inst, paramValues(inst, {}));
191+ const dt = session.dt;
192+ session.values = paramValues(inst, { nx: NX, ir_seconds: (STEPS + 100) * dt });
193+ const bake = await session.precompute();
194+ const { trace } = await session.render(bake, STEPS * session.dt);
195+ const ref = await reference('drum', {}, micDrum);
196+ const err = relErr(trace, ref, Math.min(trace.length, ref.length, STEPS));
197+ console.log(` drum at default mode_margin ${margin}: ${bake.scope.n_rows} rows; rel err ${err.toExponential(2)}`);
198+ check('the default drum mode set stays close', err < 8e-3, `rel err ${err.toExponential(2)}`);
199+}
200+
201+// Retune and restrike from the same bake: tuning moves, the bake does not.
202+{
203+ const inst = instruments.drum;
204+ const set = { f0: 150, t60: 0.7, sig1: 0.002, strikex: 0.62, striker: 0.03 };
205+ const session = new InstrumentSession(
206+ device,
207+ inst,
208+ paramValues(inst, {
209+ ...set,
210+ nx: NX,
211+ ir_seconds: (STEPS + 100) * new InstrumentSession(device, inst, paramValues(inst, {})).dt,
212+ mode_margin: 4,
213+ }),
214+ );
215+ const { trace } = await session.render(bakes.drum, STEPS * session.dt);
216+ const ref = await reference(
217+ 'drum',
218+ { f0_m1: 150, t60_m1: 0.7, sig1_m1: 0.002, strikex_m1: 0.62, striker_m1: 0.03 },
219+ micDrum,
220+ );
221+ const err = relErr(trace, ref, Math.min(trace.length, ref.length, STEPS));
222+ console.log(` retuned, restruck drum from the same bake: rel err ${err.toExponential(2)}`);
223+ check('the same bake serves a retuned drum', err < 5e-3, `rel err ${err.toExponential(2)}`);
224+}
225+
226+// The visual section: the medium fields run headless through the same
227+// engine, the raymarch shader compiles, the markers evaluate.
228+console.log('\nvisual');
229+for (const name of ['drum', 'dulcimer', 'chime'] as const) {
230+ const inst = instruments[name];
231+ const visual = inst.visual!;
232+ // nx = 64: at 32 the default walls are under half a cell and the mask
233+ // never saturates (a real property, which the viewer draws as a haze).
234+ const scope = evalDerived(inst.derived, paramValues(inst, { nx: 64 }));
235+ const fields = await runPhase({
236+ device,
237+ shaders: inst.shaders,
238+ phase: { buffers: visual.buffers, setup: visual.setup },
239+ scope,
240+ outputs: visual.volumes.map((v) => v.buffer),
241+ label: `${name}-visual`,
242+ });
243+ const wall = fields.wall;
244+ let solid = 0;
245+ let air = 0;
246+ for (const v of wall) {
247+ if (v < 0.5) solid++;
248+ if (v > 0.95) air++;
249+ }
250+ const hasBody = name !== 'chime';
251+ check(
252+ `${name}: the medium fields draw ${hasBody ? 'a body in air' : 'open air'}`,
253+ air > 0.5 * wall.length && (hasBody ? solid > 100 : solid === 0),
254+ `${solid} solid cells, ${air} air cells of ${wall.length}`,
255+ );
256+ let profPeak = 0;
257+ for (const v of fields[visual.volumes[1].buffer]) profPeak = Math.max(profPeak, Math.abs(v));
258+ check(`${name}: the coupling profile is present`, profPeak > 0, `peak ${profPeak}`);
259+
260+ device.pushErrorScope('validation');
261+ const module = device.createShaderModule({ code: raymarchWgsl(visual) });
262+ await device.createRenderPipelineAsync({
263+ layout: 'auto',
264+ vertex: { module, entryPoint: 'vmain' },
265+ fragment: {
266+ module,
267+ entryPoint: 'fmain',
268+ targets: [{ format: 'bgra8unorm' }],
269+ constants: {
270+ NX: evalExpr(visual.dims[0], scope),
271+ NY: evalExpr(visual.dims[1], scope),
272+ NZ: evalExpr(visual.dims[2], scope),
273+ },
274+ },
275+ });
276+ const err = await device.popErrorScope();
277+ check(`${name}: the raymarch shader compiles`, err === null, err?.message);
278+ const half = visual.extents.map((e) => evalExpr(e, scope) / 2);
279+ let markersOk = true;
280+ for (const m of visual.markers) {
281+ const pts = m.kind === 'point' ? [m.at] : m.kind === 'line' ? [m.from, m.to] : [m.corner];
282+ for (const p of pts) {
283+ const v = p.map((x) => evalExpr(x, scope));
284+ if (v.some((x, i) => !Number.isFinite(x) || Math.abs(x) > half[i] * 1.001)) markersOk = false;
285+ }
286+ }
287+ check(`${name}: the markers land inside the domain`, markersOk);
288+}
289+
290+// The spectrum tools: a pure sine peaks at its own bin, and a rendered
291+// chime's strongest measured peak lands on the strongest predicted partial.
292+{
293+ const dt = 1 / 48000;
294+ const sine = new Float32Array(1 << 15);
295+ for (let i = 0; i < sine.length; i++) sine[i] = Math.sin(2 * Math.PI * 440 * i * dt);
296+ const { db, binHz } = spectrumOf(sine, dt);
297+ let best = 0;
298+ for (let i = 1; i < db.length; i++) if (db[i] > db[best]) best = i;
299+ check(
300+ 'the spectrum of a 440 Hz sine peaks at 440 Hz',
301+ Math.abs(best * binHz - 440) < 2 * binHz,
302+ `peak at ${(best * binHz).toFixed(1)} Hz`,
303+ );
304+}
305+
306+// Stimuli are LTI: two events must equal the sum of their single renders.
307+{
308+ const inst = instruments.chime;
309+ const session = new InstrumentSession(device, inst, paramValues(inst, { nx: NX }));
310+ const dt = session.dt;
311+ const bake = await session.precompute();
312+ const seconds = 0.4;
313+ const t1 = 0.15;
314+ const evB = { t: t1, set: { f1: 620, strikepos: 0.44 } };
315+ const both = await session.render(bake, seconds, [{ t: 0 }, evB]);
316+ const a = await session.render(bake, seconds, [{ t: 0 }]);
317+ const b = await session.render(bake, seconds, [evB]);
318+ const sum = new Float32Array(both.trace.length);
319+ for (let i = 0; i < sum.length; i++) sum[i] = a.trace[i] + b.trace[i];
320+ const err = relErr(both.trace, sum, sum.length);
321+ console.log(` two stimuli vs summed singles: rel err ${err.toExponential(2)}`);
322+ check('stimuli superpose', err < 1e-5, `rel err ${err.toExponential(2)}`);
323+ let head = 0;
324+ const startB = Math.round(t1 / dt);
325+ for (let i = 0; i < Math.min(startB, b.trace.length); i++) head = Math.max(head, Math.abs(b.trace[i]));
326+ check('a later event is silent before its time', head === 0, `peak ${head}`);
327+
328+ // The predicted partials, probed from the slots buffer, against the
329+ // measured spectrum of the same render.
330+ const vp = inst.visual!.partials!;
331+ const r = await session.render(bake, 1.2, [{ t: 0 }], [vp.buffer]);
332+ const partials = slotPartials(r.probes[vp.buffer], vp, r.dt);
333+ const { db, binHz } = spectrumOf(r.trace, r.dt);
334+ let best = 0;
335+ for (let i = 1; i < db.length; i++) if (db[i] > db[best]) best = i;
336+ const peakHz = best * binHz;
337+ const nearest = partials.reduce((a, p) => (Math.abs(p.freq - peakHz) < Math.abs(a.freq - peakHz) ? p : a), partials[0]);
338+ console.log(
339+ ` chime partials: ${partials.map((p) => p.freq.toFixed(0)).join(', ')} Hz; ` +
340+ `measured peak ${peakHz.toFixed(0)} Hz`,
341+ );
342+ check(
343+ 'the measured peak lands on a predicted partial',
344+ partials.length >= 1 && Math.abs(peakHz - nearest.freq) < Math.max(3 * binHz, 0.01 * nearest.freq),
345+ `peak ${peakHz.toFixed(1)} Hz vs nearest predicted ${nearest?.freq.toFixed(1)} Hz`,
346+ );
347+}
348+
349+console.log(`\n${passed} passed, ${failed} failed`);
350+process.exit(failed === 0 ? 0 : 1);
src/expr.tsadded+154−0View file
@@ -0,0 +1,154 @@
1+/**
2+ * A small arithmetic expression language for an instrument file's derived
3+ * quantities: grid sizes, the timestep, mode counts, dispatch sizes.
4+ *
5+ * An instrument file must be able to say how its discretization follows
6+ * from its parameters (n1 from a stability bound, dt from the CFL
7+ * condition) without shipping executable JavaScript, so the host evaluates
8+ * these expressions itself over a plain map of numbers. The language is
9+ * deliberately tiny: numbers, named values, + - * / %, parentheses, unary
10+ * minus, and a fixed set of functions. Everything is IEEE double; integer
11+ * results come from floor/ceil/round where the author wants them.
12+ */
13+
14+const FUNCTIONS: Record<string, (...a: number[]) => number> = {
15+ floor: Math.floor,
16+ ceil: Math.ceil,
17+ round: Math.round,
18+ sqrt: Math.sqrt,
19+ abs: Math.abs,
20+ exp: Math.exp,
21+ log: Math.log,
22+ min: Math.min,
23+ max: Math.max,
24+ pow: Math.pow,
25+ clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)),
26+};
27+
28+const CONSTANTS: Record<string, number> = { pi: Math.PI };
29+
30+type Token =
31+ | { kind: 'num'; value: number }
32+ | { kind: 'name'; value: string }
33+ | { kind: 'op'; value: string };
34+
35+function tokenize(src: string): Token[] {
36+ const out: Token[] = [];
37+ let i = 0;
38+ while (i < src.length) {
39+ const ch = src[i];
40+ if (/\s/.test(ch)) {
41+ i++;
42+ } else if (/[0-9.]/.test(ch)) {
43+ const m = /^[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/.exec(src.slice(i))!;
44+ out.push({ kind: 'num', value: Number(m[0]) });
45+ i += m[0].length;
46+ } else if (/[A-Za-z_]/.test(ch)) {
47+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i))!;
48+ out.push({ kind: 'name', value: m[0] });
49+ i += m[0].length;
50+ } else if ('+-*/%(),'.includes(ch)) {
51+ out.push({ kind: 'op', value: ch });
52+ i++;
53+ } else {
54+ throw new Error(`unexpected character '${ch}' in expression '${src}'`);
55+ }
56+ }
57+ return out;
58+}
59+
60+/** Evaluate one expression against a map of named values. */
61+export function evalExpr(src: string, scope: Record<string, number>): number {
62+ const toks = tokenize(src);
63+ let pos = 0;
64+ const peek = (): Token | undefined => toks[pos];
65+ const isOp = (v: string): boolean => {
66+ const t = peek();
67+ return t !== undefined && t.kind === 'op' && t.value === v;
68+ };
69+ const expect = (v: string): void => {
70+ if (!isOp(v)) throw new Error(`expected '${v}' in expression '${src}'`);
71+ pos++;
72+ };
73+
74+ const primary = (): number => {
75+ const t = peek();
76+ if (t === undefined) throw new Error(`expression '${src}' ends early`);
77+ if (t.kind === 'num') {
78+ pos++;
79+ return t.value;
80+ }
81+ if (t.kind === 'name') {
82+ pos++;
83+ if (isOp('(')) {
84+ const fn = FUNCTIONS[t.value];
85+ if (!fn) throw new Error(`unknown function '${t.value}' in '${src}'`);
86+ pos++;
87+ const args: number[] = [];
88+ if (!isOp(')')) {
89+ args.push(addSub());
90+ while (isOp(',')) {
91+ pos++;
92+ args.push(addSub());
93+ }
94+ }
95+ expect(')');
96+ return fn(...args);
97+ }
98+ if (t.value in scope) return scope[t.value];
99+ if (t.value in CONSTANTS) return CONSTANTS[t.value];
100+ throw new Error(`unknown name '${t.value}' in expression '${src}'`);
101+ }
102+ if (t.value === '(') {
103+ pos++;
104+ const v = addSub();
105+ expect(')');
106+ return v;
107+ }
108+ if (t.value === '-') {
109+ pos++;
110+ return -primary();
111+ }
112+ throw new Error(`unexpected '${t.value}' in expression '${src}'`);
113+ };
114+
115+ const mulDiv = (): number => {
116+ let v = primary();
117+ while (isOp('*') || isOp('/') || isOp('%')) {
118+ const op = (peek() as Token & { kind: 'op' }).value;
119+ pos++;
120+ const r = primary();
121+ v = op === '*' ? v * r : op === '/' ? v / r : v % r;
122+ }
123+ return v;
124+ };
125+
126+ const addSub = (): number => {
127+ let v = mulDiv();
128+ while (isOp('+') || isOp('-')) {
129+ const op = (peek() as Token & { kind: 'op' }).value;
130+ pos++;
131+ const r = mulDiv();
132+ v = op === '+' ? v + r : v - r;
133+ }
134+ return v;
135+ };
136+
137+ const v = addSub();
138+ if (pos !== toks.length) throw new Error(`trailing input in expression '${src}'`);
139+ if (!Number.isFinite(v)) throw new Error(`expression '${src}' is not finite`);
140+ return v;
141+}
142+
143+/** Evaluate an ordered map of derived expressions; each may use the scope
144+ * and every earlier result. Returns the extended scope. */
145+export function evalDerived(
146+ derived: Record<string, string>,
147+ scope: Record<string, number>,
148+): Record<string, number> {
149+ const out = { ...scope };
150+ for (const [name, src] of Object.entries(derived)) {
151+ out[name] = evalExpr(src, out);
152+ }
153+ return out;
154+}
src/fft.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+/**
2+ * A plain radix-2 FFT, enough for the spectrum view: Hann-windowed
3+ * magnitude of the first power-of-two chunk of the trace, in dB below the
4+ * peak. Nothing here is performance-critical; a quarter-million-point
5+ * transform is well under a frame.
6+ */
7+
8+/** In-place complex FFT, re/im interleaved as two arrays, n a power of 2. */
9+export function fft(re: Float64Array, im: Float64Array): void {
10+ const n = re.length;
11+ for (let i = 1, j = 0; i < n; i++) {
12+ let bit = n >> 1;
13+ for (; j & bit; bit >>= 1) j ^= bit;
14+ j ^= bit;
15+ if (i < j) {
16+ [re[i], re[j]] = [re[j], re[i]];
17+ [im[i], im[j]] = [im[j], im[i]];
18+ }
19+ }
20+ for (let len = 2; len <= n; len <<= 1) {
21+ const ang = (-2 * Math.PI) / len;
22+ const wr = Math.cos(ang);
23+ const wi = Math.sin(ang);
24+ for (let i = 0; i < n; i += len) {
25+ let cr = 1;
26+ let ci = 0;
27+ for (let k = 0; k < len / 2; k++) {
28+ const ur = re[i + k];
29+ const ui = im[i + k];
30+ const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
31+ const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
32+ re[i + k] = ur + vr;
33+ im[i + k] = ui + vi;
34+ re[i + k + len / 2] = ur - vr;
35+ im[i + k + len / 2] = ui - vi;
36+ const nr = cr * wr - ci * wi;
37+ ci = cr * wi + ci * wr;
38+ cr = nr;
39+ }
40+ }
41+ }
42+}
43+
44+export interface Spectrum {
45+ /** Magnitude in dB relative to the strongest bin (all <= 0). */
46+ db: Float32Array;
47+ /** Hz per bin. */
48+ binHz: number;
49+}
50+
51+/** The spectrum of a trace sampled at 1/dt: Hann window over the first
52+ * power-of-two chunk (at most 2^18 samples). */
53+export function spectrumOf(trace: Float32Array, dt: number): Spectrum {
54+ let n = 1;
55+ while (n * 2 <= Math.min(trace.length, 1 << 18)) n *= 2;
56+ const re = new Float64Array(n);
57+ const im = new Float64Array(n);
58+ for (let i = 0; i < n; i++) {
59+ re[i] = trace[i] * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1)));
60+ }
61+ fft(re, im);
62+ const half = n / 2;
63+ const mag = new Float64Array(half);
64+ let peak = 0;
65+ for (let i = 0; i < half; i++) {
66+ mag[i] = Math.hypot(re[i], im[i]);
67+ if (mag[i] > peak) peak = mag[i];
68+ }
69+ const db = new Float32Array(half);
70+ for (let i = 0; i < half; i++) {
71+ db[i] = peak > 0 ? 20 * Math.log10(Math.max(mag[i] / peak, 1e-8)) : -160;
72+ }
73+ return { db, binHz: 1 / (n * dt) };
74+}
src/format.tsadded+435−0View file
@@ -0,0 +1,435 @@
1+/**
2+ * The instrument file: one JSON document that is the whole instrument.
3+ *
4+ * The host (src/host/) is a generic runner; everything specific to an
5+ * instrument lives here. The file carries:
6+ *
7+ * - a parameter spec, every parameter in SI units and classed by cost:
8+ * `geometry` and `accuracy` parameters feed the precompute (changing one
9+ * invalidates the bake); `tuning` parameters only feed the render and are
10+ * free to move. The microphone position is a geometry parameter like any
11+ * other.
12+ * - `derived`, an ordered map of expressions (src/expr.ts) giving the
13+ * discretization: grid sizes, the timestep, mode counts, buffer sizes,
14+ * dispatch sizes. The host evaluates these; the instrument file, not the
15+ * host, knows its stability bounds.
16+ * - the WGSL programs. Grid sizes and parameter values reach a shader as
17+ * pipeline override constants, so the WGSL text is fixed and one file
18+ * covers every setting of every parameter.
19+ * - two schedules, `precompute` and `render`: each declares its buffers,
20+ * a list of setup passes, and optionally a loop (the air run; the
21+ * synthesis tiles). The precompute's `outputs` are read back into the
22+ * bake; the render's `inputs` are uploaded from it.
23+ *
24+ * The render schedule additionally sees `total_steps`, `n_events`, `tile`
25+ * and `n_tiles` in scope, and an `__events` buffer the host packs from the
26+ * stimulus list: one row per event, [start_step, ...eventParams], f32.
27+ * Either schedule's loop may bind `__tick`, a u32 the host zeroes before
28+ * the loop and increments after every iteration.
29+ */
30+
31+export type ParamClass = 'geometry' | 'accuracy' | 'tuning';
32+
33+export interface ParamSpec {
34+ key: string;
35+ label: string;
36+ /** SI unit as text ('m', 'Hz', 's', 'm/s', '1'). Documentation, not code. */
37+ unit: string;
38+ value: number;
39+ min: number;
40+ max: number;
41+ step?: number;
42+ class: ParamClass;
43+ hint?: string;
44+}
45+
46+export interface ShaderSpec {
47+ code: string;
48+ /** Names from the evaluation scope passed as pipeline override constants.
49+ * Must match the shader's `override` declarations exactly. */
50+ constants: string[];
51+}
52+
53+export interface PassSpec {
54+ shader: string;
55+ /** Buffer names bound in order from binding 0. */
56+ buffers: string[];
57+ /** Thread count, an expression over the scope. */
58+ dispatch: string;
59+}
60+
61+export interface LoopSpec {
62+ count: string;
63+ /** Iterations encoded per submission; the host awaits between batches. */
64+ batch?: number;
65+ /** Groups of buffer names rotated one place per iteration (state
66+ * histories: [p, pm, pn] makes this step's pn the next step's p). */
67+ rotate?: string[][];
68+ body: PassSpec[];
69+}
70+
71+export interface PhaseSpec {
72+ buffers: { name: string; count: string }[];
73+ setup: PassSpec[];
74+ loop?: LoopSpec;
75+}
76+
77+export interface PrecomputeSpec extends PhaseSpec {
78+ /** Buffers read back into the bake, by name. */
79+ outputs: string[];
80+}
81+
82+export interface RenderSpec extends PhaseSpec {
83+ /** Bake arrays uploaded before the render, by name. */
84+ inputs: string[];
85+ /** Tuning parameters each stimulus event may override; the host packs
86+ * them, after start_step, into each __events row. */
87+ eventParams: string[];
88+ /** Extra derived values evaluated with the render scope in view. */
89+ derived?: Record<string, string>;
90+ /** The buffer holding the finished microphone trace. */
91+ trace: string;
92+}
93+
94+/** A visualization marker: a crisp annotation drawn over the raymarched
95+ * fields, its coordinates expressions over the evaluated scope. Markers
96+ * annotate (an object's outline, the microphone); the geometry itself is
97+ * the medium shader's own fields, never a second copy. */
98+export type MarkerSpec =
99+ | { kind: 'point'; at: [string, string, string]; color: [number, number, number]; label?: string }
100+ | { kind: 'line'; from: [string, string, string]; to: [string, string, string]; color: [number, number, number]; label?: string }
101+ | {
102+ kind: 'rect';
103+ corner: [string, string, string];
104+ du: [string, string, string];
105+ dv: [string, string, string];
106+ color: [number, number, number];
107+ label?: string;
108+ };
109+
110+export interface VolumeSpec {
111+ buffer: string;
112+ /** 'solid' shades an opaque surface where the value crosses 0.5 (a body
113+ * mask); 'cloud' accumulates the value as a translucent density (a
114+ * coupling profile). */
115+ style: 'solid' | 'cloud';
116+ /** Draw 1 - value instead (a wall mask's body is 1 - wall). */
117+ invert?: boolean;
118+ color: [number, number, number];
119+}
120+
121+/** How to draw the instrument: run `setup` (typically the medium pass) over
122+ * `buffers` at the current parameters, raymarch `volumes` on the grid the
123+ * named dims/extents describe, overlay `markers`. `partials` tells a
124+ * spectrum view where each rendered mode's pole sits in the slots buffer,
125+ * so measured peaks can be compared against the scheme's own partials. */
126+export interface VisualSpec {
127+ dims: [string, string, string];
128+ extents: [string, string, string];
129+ buffers: { name: string; count: string }[];
130+ setup: PassSpec[];
131+ volumes: VolumeSpec[];
132+ markers: MarkerSpec[];
133+ partials?: { buffer: string; stride: number; zRe: number; zIm: number };
134+}
135+
136+export interface InstrumentFile {
137+ instrawave: 1;
138+ name: string;
139+ description: string;
140+ params: ParamSpec[];
141+ derived: Record<string, string>;
142+ shaders: Record<string, ShaderSpec>;
143+ precompute: PrecomputeSpec;
144+ render: RenderSpec;
145+ visual?: VisualSpec;
146+}
147+
148+/** One stimulus: an excitation at time t (seconds), with any of the
149+ * instrument's eventParams overridden for this event alone. */
150+export interface StimulusEvent {
151+ t: number;
152+ set?: Record<string, number>;
153+}
154+
155+export class InstrumentError extends Error {
156+ constructor(message: string) {
157+ super(`not a valid instrawave instrument: ${message}`);
158+ this.name = 'InstrumentError';
159+ }
160+}
161+
162+const isRecord = (v: unknown): v is Record<string, unknown> =>
163+ typeof v === 'object' && v !== null && !Array.isArray(v);
164+
165+const need = (cond: boolean, msg: string): void => {
166+ if (!cond) throw new InstrumentError(msg);
167+};
168+
169+function parsePass(v: unknown, where: string): PassSpec {
170+ need(isRecord(v), `${where} must be an object`);
171+ const p = v as Record<string, unknown>;
172+ need(typeof p.shader === 'string', `${where}.shader must be a string`);
173+ need(
174+ Array.isArray(p.buffers) && p.buffers.every((b) => typeof b === 'string'),
175+ `${where}.buffers must be a string array`,
176+ );
177+ need(typeof p.dispatch === 'string', `${where}.dispatch must be an expression`);
178+ return { shader: p.shader as string, buffers: p.buffers as string[], dispatch: p.dispatch as string };
179+}
180+
181+function parsePhase(v: unknown, where: string): PhaseSpec {
182+ need(isRecord(v), `missing "${where}"`);
183+ const ph = v as Record<string, unknown>;
184+ need(Array.isArray(ph.buffers), `${where}.buffers must be an array`);
185+ const buffers = (ph.buffers as unknown[]).map((b, i) => {
186+ need(isRecord(b), `${where}.buffers[${i}] must be an object`);
187+ const r = b as Record<string, unknown>;
188+ need(typeof r.name === 'string', `${where}.buffers[${i}].name must be a string`);
189+ need(typeof r.count === 'string', `${where}.buffers[${i}].count must be an expression`);
190+ return { name: r.name as string, count: r.count as string };
191+ });
192+ need(Array.isArray(ph.setup), `${where}.setup must be an array`);
193+ const setup = (ph.setup as unknown[]).map((p, i) => parsePass(p, `${where}.setup[${i}]`));
194+ let loop: LoopSpec | undefined;
195+ if (ph.loop !== undefined) {
196+ need(isRecord(ph.loop), `${where}.loop must be an object`);
197+ const l = ph.loop as Record<string, unknown>;
198+ need(typeof l.count === 'string', `${where}.loop.count must be an expression`);
199+ need(Array.isArray(l.body), `${where}.loop.body must be an array`);
200+ loop = {
201+ count: l.count as string,
202+ batch: typeof l.batch === 'number' ? l.batch : undefined,
203+ rotate:
204+ l.rotate === undefined
205+ ? undefined
206+ : (l.rotate as string[][]).map((g, i) => {
207+ need(
208+ Array.isArray(g) && g.length >= 2 && g.every((n) => typeof n === 'string'),
209+ `${where}.loop.rotate[${i}] must be 2+ buffer names`,
210+ );
211+ return g;
212+ }),
213+ body: (l.body as unknown[]).map((p, i) => parsePass(p, `${where}.loop.body[${i}]`)),
214+ };
215+ }
216+ return { buffers, setup, loop };
217+}
218+
219+export function parseInstrument(text: string): InstrumentFile {
220+ let raw: unknown;
221+ try {
222+ raw = JSON.parse(text);
223+ } catch (e) {
224+ throw new InstrumentError(`not JSON (${e instanceof Error ? e.message : e})`);
225+ }
226+ need(isRecord(raw), 'the top level must be an object');
227+ const r = raw as Record<string, unknown>;
228+ need(r.instrawave === 1, 'missing or unsupported format marker (want "instrawave": 1)');
229+ need(typeof r.name === 'string' && r.name.length > 0, 'missing "name"');
230+ need(typeof r.description === 'string', 'missing "description"');
231+
232+ need(Array.isArray(r.params), '"params" must be an array');
233+ const seen = new Set<string>();
234+ const params = (r.params as unknown[]).map((p, i) => {
235+ need(isRecord(p), `params[${i}] must be an object`);
236+ const q = p as Record<string, unknown>;
237+ need(
238+ typeof q.key === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(q.key),
239+ `params[${i}] needs an identifier "key"`,
240+ );
241+ need(!seen.has(q.key as string), `duplicate parameter '${q.key}'`);
242+ seen.add(q.key as string);
243+ for (const k of ['value', 'min', 'max'] as const) {
244+ need(typeof q[k] === 'number' && Number.isFinite(q[k] as number), `params '${q.key}'.${k} must be a number`);
245+ }
246+ need(
247+ q.class === 'geometry' || q.class === 'accuracy' || q.class === 'tuning',
248+ `params '${q.key}'.class must be geometry, accuracy or tuning`,
249+ );
250+ return {
251+ key: q.key as string,
252+ label: typeof q.label === 'string' ? q.label : (q.key as string),
253+ unit: typeof q.unit === 'string' ? q.unit : '1',
254+ value: q.value as number,
255+ min: q.min as number,
256+ max: q.max as number,
257+ step: typeof q.step === 'number' ? q.step : undefined,
258+ class: q.class as ParamClass,
259+ hint: typeof q.hint === 'string' ? q.hint : undefined,
260+ };
261+ });
262+
263+ need(isRecord(r.derived), '"derived" must be an object of expressions');
264+ for (const [k, v] of Object.entries(r.derived as Record<string, unknown>)) {
265+ need(typeof v === 'string', `derived.${k} must be an expression string`);
266+ }
267+
268+ need(isRecord(r.shaders), '"shaders" must be an object');
269+ const shaders: Record<string, ShaderSpec> = {};
270+ for (const [k, v] of Object.entries(r.shaders as Record<string, unknown>)) {
271+ need(isRecord(v), `shaders.${k} must be an object`);
272+ const s = v as Record<string, unknown>;
273+ need(typeof s.code === 'string', `shaders.${k}.code must be WGSL text`);
274+ need(
275+ Array.isArray(s.constants) && s.constants.every((c) => typeof c === 'string'),
276+ `shaders.${k}.constants must be a string array`,
277+ );
278+ shaders[k] = { code: s.code as string, constants: s.constants as string[] };
279+ }
280+
281+ const pre = parsePhase(r.precompute, 'precompute') as PrecomputeSpec;
282+ need(
283+ Array.isArray((r.precompute as Record<string, unknown>).outputs),
284+ 'precompute.outputs must name the buffers baked',
285+ );
286+ pre.outputs = (r.precompute as Record<string, unknown>).outputs as string[];
287+
288+ const ren = parsePhase(r.render, 'render') as RenderSpec;
289+ const rr = r.render as Record<string, unknown>;
290+ need(Array.isArray(rr.inputs), 'render.inputs must name the bake arrays uploaded');
291+ ren.inputs = rr.inputs as string[];
292+ need(Array.isArray(rr.eventParams), 'render.eventParams must be a string array');
293+ ren.eventParams = rr.eventParams as string[];
294+ need(typeof rr.trace === 'string', 'render.trace must name the trace buffer');
295+ ren.trace = rr.trace as string;
296+ if (rr.derived !== undefined) {
297+ need(isRecord(rr.derived), 'render.derived must be an object of expressions');
298+ ren.derived = rr.derived as Record<string, string>;
299+ }
300+
301+ // Every pass must reference a declared shader; every eventParam a
302+ // tuning parameter. Buffer names are checked at run time, where the
303+ // built-ins (__tick, __events) are in scope.
304+ const passes = [
305+ ...pre.setup, ...(pre.loop?.body ?? []),
306+ ...ren.setup, ...(ren.loop?.body ?? []),
307+ ];
308+ for (const p of passes) {
309+ need(p.shader in shaders, `pass uses undeclared shader '${p.shader}'`);
310+ }
311+ for (const k of ren.eventParams) {
312+ const spec = params.find((p) => p.key === k);
313+ need(spec !== undefined && spec.class === 'tuning', `eventParam '${k}' must be a tuning parameter`);
314+ }
315+
316+ let visual: VisualSpec | undefined;
317+ if (r.visual !== undefined) {
318+ need(isRecord(r.visual), '"visual" must be an object');
319+ const v = r.visual as Record<string, unknown>;
320+ const triple = (x: unknown, what: string): [string, string, string] => {
321+ need(
322+ Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'string'),
323+ `${what} must be three expressions`,
324+ );
325+ return x as [string, string, string];
326+ };
327+ const color = (x: unknown, what: string): [number, number, number] => {
328+ need(
329+ Array.isArray(x) && x.length === 3 && x.every((e) => typeof e === 'number'),
330+ `${what} must be [r, g, b]`,
331+ );
332+ return x as [number, number, number];
333+ };
334+ const vp = parsePhase({ buffers: v.buffers, setup: v.setup }, 'visual');
335+ need(Array.isArray(v.volumes) && (v.volumes as unknown[]).length > 0, 'visual.volumes must be a non-empty array');
336+ const volumes = (v.volumes as unknown[]).map((q, i) => {
337+ need(isRecord(q), `visual.volumes[${i}] must be an object`);
338+ const w = q as Record<string, unknown>;
339+ need(typeof w.buffer === 'string', `visual.volumes[${i}].buffer must be a string`);
340+ need(w.style === 'solid' || w.style === 'cloud', `visual.volumes[${i}].style must be solid or cloud`);
341+ return {
342+ buffer: w.buffer as string,
343+ style: w.style as 'solid' | 'cloud',
344+ invert: w.invert === true ? true : undefined,
345+ color: color(w.color, `visual.volumes[${i}].color`),
346+ };
347+ });
348+ const markers = (Array.isArray(v.markers) ? (v.markers as unknown[]) : []).map((q, i) => {
349+ need(isRecord(q), `visual.markers[${i}] must be an object`);
350+ const w = q as Record<string, unknown>;
351+ const label = typeof w.label === 'string' ? w.label : undefined;
352+ const c = color(w.color, `visual.markers[${i}].color`);
353+ if (w.kind === 'point') return { kind: 'point' as const, at: triple(w.at, `visual.markers[${i}].at`), color: c, label };
354+ if (w.kind === 'line') {
355+ return {
356+ kind: 'line' as const,
357+ from: triple(w.from, `visual.markers[${i}].from`),
358+ to: triple(w.to, `visual.markers[${i}].to`),
359+ color: c,
360+ label,
361+ };
362+ }
363+ need(w.kind === 'rect', `visual.markers[${i}].kind must be point, line or rect`);
364+ return {
365+ kind: 'rect' as const,
366+ corner: triple(w.corner, `visual.markers[${i}].corner`),
367+ du: triple(w.du, `visual.markers[${i}].du`),
368+ dv: triple(w.dv, `visual.markers[${i}].dv`),
369+ color: c,
370+ label,
371+ };
372+ });
373+ let partials: VisualSpec['partials'];
374+ if (v.partials !== undefined) {
375+ need(isRecord(v.partials), 'visual.partials must be an object');
376+ const w = v.partials as Record<string, unknown>;
377+ need(
378+ typeof w.buffer === 'string' && typeof w.stride === 'number' &&
379+ typeof w.zRe === 'number' && typeof w.zIm === 'number',
380+ 'visual.partials wants {buffer, stride, zRe, zIm}',
381+ );
382+ partials = { buffer: w.buffer as string, stride: w.stride as number, zRe: w.zRe as number, zIm: w.zIm as number };
383+ }
384+ for (const p of vp.setup) need(p.shader in shaders, `visual pass uses undeclared shader '${p.shader}'`);
385+ visual = {
386+ dims: triple(v.dims, 'visual.dims'),
387+ extents: triple(v.extents, 'visual.extents'),
388+ buffers: vp.buffers,
389+ setup: vp.setup,
390+ volumes,
391+ markers,
392+ partials,
393+ };
394+ }
395+
396+ return {
397+ instrawave: 1,
398+ name: r.name as string,
399+ description: r.description as string,
400+ params,
401+ derived: r.derived as Record<string, string>,
402+ shaders,
403+ precompute: pre,
404+ render: ren,
405+ ...(visual ? { visual } : {}),
406+ };
407+}
408+
409+/** Parameter values by key, defaults filled from the spec. */
410+export function paramValues(inst: InstrumentFile, overrides: Record<string, number> = {}): Record<string, number> {
411+ const out: Record<string, number> = {};
412+ for (const p of inst.params) {
413+ out[p.key] = overrides[p.key] ?? p.value;
414+ // Worst-case values for stability sizing, so no tuning move ever
415+ // invalidates a grid: every parameter's range is in scope.
416+ out[`${p.key}__min`] = p.min;
417+ out[`${p.key}__max`] = p.max;
418+ }
419+ for (const k of Object.keys(overrides)) {
420+ if (!(k in out)) throw new InstrumentError(`unknown parameter '${k}'`);
421+ }
422+ return out;
423+}
424+
425+/** The subset of values that feed the precompute: geometry and accuracy. */
426+export function bakeRelevantValues(
427+ inst: InstrumentFile,
428+ values: Record<string, number>,
429+): Record<string, number> {
430+ const out: Record<string, number> = {};
431+ for (const p of inst.params) {
432+ if (p.class !== 'tuning') out[p.key] = values[p.key];
433+ }
434+ return out;
435+}
src/host/bake.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+/**
2+ * The bake: everything the precompute produced, in one binary file.
3+ *
4+ * A bake is valid for exactly one setting of the geometry and accuracy
5+ * parameters of exactly one instrument file. The key is a SHA-256 over the
6+ * instrument's name, its full precompute definition (derived expressions,
7+ * shaders, schedule), and the geometry and accuracy values; editing any of
8+ * those, including the instrument file itself, invalidates the bake, while
9+ * tuning values are deliberately absent from the hash.
10+ *
11+ * Container: an 8-byte magic, a u32 header length, a JSON header (the key,
12+ * the evaluated scope for reference, and each array's offset and length),
13+ * then the raw f32 data. Same bytes in the browser and on disk.
14+ */
15+import type { InstrumentFile } from '../format.ts';
16+import { bakeRelevantValues } from '../format.ts';
17+
18+const MAGIC = 'IWBAKE1\n';
19+
20+export interface Bake {
21+ key: string;
22+ instrument: string;
23+ /** The evaluated scope the precompute ran with, for reference. */
24+ scope: Record<string, number>;
25+ arrays: Record<string, Float32Array>;
26+}
27+
28+/** The bake key for an instrument under given parameter values. */
29+export async function bakeKey(
30+ inst: InstrumentFile,
31+ values: Record<string, number>,
32+): Promise<string> {
33+ const subject = JSON.stringify({
34+ name: inst.name,
35+ derived: inst.derived,
36+ shaders: inst.shaders,
37+ precompute: inst.precompute,
38+ values: bakeRelevantValues(inst, values),
39+ });
40+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(subject));
41+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
42+}
43+
44+export function serializeBake(bake: Bake): Uint8Array {
45+ const entries = Object.entries(bake.arrays);
46+ let offset = 0;
47+ const header = {
48+ key: bake.key,
49+ instrument: bake.instrument,
50+ scope: bake.scope,
51+ arrays: entries.map(([name, a]) => {
52+ const e = { name, offset, length: a.length };
53+ offset += a.length;
54+ return e;
55+ }),
56+ };
57+ // Padded to 4 bytes with spaces (JSON whitespace), so the f32 data that
58+ // follows is aligned and the header parses as-is.
59+ const head = new TextEncoder().encode(
60+ JSON.stringify(header) + ' '.repeat((4 - (JSON.stringify(header).length % 4)) % 4),
61+ );
62+ const padded = head.length;
63+ const total = MAGIC.length + 4 + padded + 4 * offset;
64+ const out = new Uint8Array(total);
65+ const view = new DataView(out.buffer);
66+ new TextEncoder().encodeInto(MAGIC, out);
67+ view.setUint32(MAGIC.length, padded, true);
68+ out.set(head, MAGIC.length + 4);
69+ let pos = MAGIC.length + 4 + padded;
70+ for (const [, a] of entries) {
71+ out.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), pos);
72+ pos += a.byteLength;
73+ }
74+ return out;
75+}
76+
77+export function parseBake(data: Uint8Array): Bake {
78+ const magic = new TextDecoder().decode(data.subarray(0, MAGIC.length));
79+ if (magic !== MAGIC) throw new Error('not an instrawave bake file');
80+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
81+ const headerLen = view.getUint32(MAGIC.length, true);
82+ const header = JSON.parse(
83+ new TextDecoder().decode(data.subarray(MAGIC.length + 4, MAGIC.length + 4 + headerLen)),
84+ ) as {
85+ key: string;
86+ instrument: string;
87+ scope: Record<string, number>;
88+ arrays: { name: string; offset: number; length: number }[];
89+ };
90+ const base = MAGIC.length + 4 + headerLen;
91+ const arrays: Record<string, Float32Array> = {};
92+ for (const a of header.arrays) {
93+ // Copy out so the result owns aligned storage whatever the container's
94+ // alignment was.
95+ const bytes = data.subarray(base + 4 * a.offset, base + 4 * (a.offset + a.length));
96+ const f = new Float32Array(a.length);
97+ new Uint8Array(f.buffer).set(bytes);
98+ arrays[a.name] = f;
99+ }
100+ return { key: header.key, instrument: header.instrument, scope: header.scope, arrays };
101+}
src/host/device.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+/**
2+ * The GPU device, requested the same way in the page and the CLI.
3+ *
4+ * The precompute holds its whole kernel bank in one storage buffer, which
5+ * at fine grids outgrows WebGPU's 128 MiB default binding size, so the
6+ * request asks for the adapter's actual maximum buffer and binding sizes.
7+ * That is a capacity request: if the adapter cannot give more, the request
8+ * still succeeds and a too-large bake fails with a clear buffer error.
9+ */
10+let heldAdapter: GPUAdapter | null = null;
11+
12+export async function requestDevice(): Promise<GPUDevice> {
13+ if (!navigator.gpu) {
14+ throw new Error(
15+ 'this environment has no WebGPU. In the browser: Chrome and Edge 113+, ' +
16+ 'Safari 26+; on Linux it may need enabling explicitly. In node: the ' +
17+ 'optional `webgpu` package (Google Dawn).',
18+ );
19+ }
20+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
21+ if (!adapter) throw new Error('WebGPU found no adapter on this machine.');
22+ heldAdapter = adapter;
23+ return adapter.requestDevice({
24+ requiredLimits: {
25+ maxBufferSize: adapter.limits.maxBufferSize,
26+ maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
27+ },
28+ });
29+}
30+
31+export const currentAdapter = (): GPUAdapter | null => heldAdapter;
src/host/engine.tsadded+239−0View file
@@ -0,0 +1,239 @@
1+/**
2+ * The generic runner: everything the host does with an instrument file's
3+ * WGSL, with no knowledge of what the shaders compute.
4+ *
5+ * A phase (precompute or render) runs as: create its f32 storage buffers at
6+ * the sizes the derived expressions give, upload any named inputs, build
7+ * one compute pipeline per shader with the scope values the shader lists as
8+ * override constants, run the setup passes, run the loop, and read back the
9+ * requested buffers. Two conveniences the schedules lean on:
10+ *
11+ * - `__tick`: a u32 the host zeroes before the loop and bumps after every
12+ * iteration, so a shader knows which timestep or tile it is in without
13+ * any per-iteration upload.
14+ * - `rotate`: buffer groups cycled one place per iteration, which is how
15+ * a leapfrog's state histories advance without copies. With a group
16+ * [p, pm, pn], the buffer a pass sees as `p` at iteration k is the one
17+ * it saw as `pn` at iteration k-1.
18+ *
19+ * Iterations are encoded in batches with an await between them, which
20+ * bounds how far the queue runs ahead and paces progress and cancellation.
21+ */
22+import { evalExpr } from '../expr.ts';
23+import type { PassSpec, PhaseSpec, ShaderSpec } from '../format.ts';
24+
25+export const WORKGROUP_SIZE = 64;
26+
27+/** Workgroup grids over 65535 spill into y; shaders linearize back with
28+ * `gid.x + gid.y * 4194240u` (65535 * 64, one x-row of threads). */
29+const MAX_WG_X = 65535;
30+
31+export class PhaseCancelled extends Error {
32+ constructor() {
33+ super('phase cancelled');
34+ }
35+}
36+
37+export interface PhaseRunOptions {
38+ device: GPUDevice;
39+ shaders: Record<string, ShaderSpec>;
40+ phase: PhaseSpec;
41+ /** Fully evaluated scope: params, derived, and any runtime values. */
42+ scope: Record<string, number>;
43+ /** Uploaded into same-named declared buffers before setup. */
44+ inputs?: Record<string, Float32Array>;
45+ /** Buffer names to read back when the phase completes. */
46+ outputs: string[];
47+ label: string;
48+ onProgress?: (done: number, total: number) => void;
49+ cancelled?: () => boolean;
50+}
51+
52+const bumpShader = `
53+@group(0) @binding(0) var<storage, read_write> tick: array<u32>;
54+@compute @workgroup_size(1)
55+fn main() { tick[0] = tick[0] + 1u; }
56+`;
57+
58+export async function runPhase(opts: PhaseRunOptions): Promise<Record<string, Float32Array>> {
59+ const { device, shaders, phase, scope, label } = opts;
60+ const owned: GPUBuffer[] = [];
61+ const mk = (name: string, bytes: number, extra: GPUBufferUsageFlags = 0): GPUBuffer => {
62+ const b = device.createBuffer({
63+ label: `${label}-${name}`,
64+ size: Math.max(4, bytes),
65+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST | extra,
66+ });
67+ owned.push(b);
68+ return b;
69+ };
70+
71+ try {
72+ // Buffers. Sizes are expressions; contents start zeroed (WebGPU
73+ // guarantees zero-initialized buffers), inputs overwrite by name.
74+ const buffers = new Map<string, GPUBuffer>();
75+ for (const b of phase.buffers) {
76+ const count = evalExpr(b.count, scope);
77+ if (!Number.isInteger(count) || count < 0) {
78+ throw new Error(`buffer '${b.name}': count '${b.count}' = ${count} is not a whole number`);
79+ }
80+ buffers.set(b.name, mk(b.name, 4 * count));
81+ }
82+ buffers.set('__tick', mk('__tick', 4));
83+ for (const [name, data] of Object.entries(opts.inputs ?? {})) {
84+ const buf = buffers.get(name);
85+ if (!buf) throw new Error(`input '${name}' has no declared buffer`);
86+ if (data.byteLength > buf.size) {
87+ throw new Error(`input '${name}' (${data.byteLength} B) exceeds its buffer (${buf.size} B)`);
88+ }
89+ device.queue.writeBuffer(buf, 0, data as Float32Array<ArrayBuffer>);
90+ }
91+
92+ // Pipelines: one per distinct shader used, override constants from the
93+ // scope. An unknown constant name is a real authoring error; surface it.
94+ const passes: PassSpec[] = [...phase.setup, ...(phase.loop?.body ?? [])];
95+ const pipelines = new Map<string, GPUComputePipeline>();
96+ device.pushErrorScope('validation');
97+ for (const p of passes) {
98+ if (pipelines.has(p.shader)) continue;
99+ const s = shaders[p.shader];
100+ const constants: Record<string, number> = {};
101+ for (const c of s.constants) {
102+ if (!(c in scope)) throw new Error(`shader '${p.shader}': constant '${c}' is not in scope`);
103+ constants[c] = scope[c];
104+ }
105+ pipelines.set(
106+ p.shader,
107+ await device.createComputePipelineAsync({
108+ label: `${label}-${p.shader}`,
109+ layout: 'auto',
110+ compute: {
111+ module: device.createShaderModule({ code: s.code, label: `${label}-${p.shader}` }),
112+ entryPoint: 'main',
113+ constants,
114+ },
115+ }),
116+ );
117+ }
118+ const bumpPipe = await device.createComputePipelineAsync({
119+ label: `${label}-bump`,
120+ layout: 'auto',
121+ compute: { module: device.createShaderModule({ code: bumpShader }), entryPoint: 'main' },
122+ });
123+ const pipeErr = await device.popErrorScope();
124+ if (pipeErr) throw new Error(`${label}: shader compilation failed: ${pipeErr.message}`);
125+
126+ // Rotation: per phase index, each logical name in a rotate group maps to
127+ // the group's physical buffer (index - k) mod n. Bind groups are built
128+ // per pass per rotation phase; passes outside the loop use phase 0.
129+ const rotate = phase.loop?.rotate ?? [];
130+ const nPhases = rotate.reduce((a, g) => lcm(a, g.length), 1);
131+ const physical = (name: string, k: number): GPUBuffer => {
132+ for (const g of rotate) {
133+ const l = g.indexOf(name);
134+ if (l >= 0) {
135+ const idx = ((l - k) % g.length + g.length) % g.length;
136+ const buf = buffers.get(g[idx]);
137+ if (!buf) throw new Error(`rotate group buffer '${g[idx]}' is not declared`);
138+ return buf;
139+ }
140+ }
141+ const buf = buffers.get(name);
142+ if (!buf) throw new Error(`pass binds undeclared buffer '${name}'`);
143+ return buf;
144+ };
145+ const bindOf = (p: PassSpec, k: number): GPUBindGroup =>
146+ device.createBindGroup({
147+ layout: pipelines.get(p.shader)!.getBindGroupLayout(0),
148+ entries: p.buffers.map((name, i) => ({
149+ binding: i,
150+ resource: { buffer: physical(name, k) },
151+ })),
152+ });
153+ const setupBinds = phase.setup.map((p) => bindOf(p, 0));
154+ const loopBinds = (phase.loop?.body ?? []).map((p) =>
155+ Array.from({ length: nPhases }, (_, k) => bindOf(p, k)),
156+ );
157+ const bumpBind = device.createBindGroup({
158+ layout: bumpPipe.getBindGroupLayout(0),
159+ entries: [{ binding: 0, resource: { buffer: buffers.get('__tick')! } }],
160+ });
161+
162+ const dispatchCount = new Map<PassSpec, number>();
163+ for (const p of passes) dispatchCount.set(p, Math.ceil(evalExpr(p.dispatch, scope) / WORKGROUP_SIZE));
164+
165+ const encodePass = (
166+ cp: GPUComputePassEncoder,
167+ p: PassSpec,
168+ bind: GPUBindGroup,
169+ ): void => {
170+ cp.setPipeline(pipelines.get(p.shader)!);
171+ cp.setBindGroup(0, bind);
172+ const wg = dispatchCount.get(p)!;
173+ cp.dispatchWorkgroups(Math.min(wg, MAX_WG_X), Math.ceil(wg / MAX_WG_X));
174+ };
175+
176+ // Setup.
177+ {
178+ const enc = device.createCommandEncoder({ label: `${label}-setup` });
179+ const cp = enc.beginComputePass();
180+ phase.setup.forEach((p, i) => encodePass(cp, p, setupBinds[i]));
181+ cp.end();
182+ device.queue.submit([enc.finish()]);
183+ }
184+
185+ // The loop, batched.
186+ if (phase.loop) {
187+ const total = evalExpr(phase.loop.count, scope);
188+ if (!Number.isInteger(total) || total < 0) {
189+ throw new Error(`loop count '${phase.loop.count}' = ${total} is not a whole number`);
190+ }
191+ const batch = phase.loop.batch ?? 256;
192+ const body = phase.loop.body;
193+ let done = 0;
194+ while (done < total) {
195+ if (opts.cancelled?.()) throw new PhaseCancelled();
196+ const n = Math.min(batch, total - done);
197+ const enc = device.createCommandEncoder({ label: `${label}-loop` });
198+ const cp = enc.beginComputePass();
199+ for (let j = 0; j < n; j++) {
200+ const k = (done + j) % nPhases;
201+ body.forEach((p, i) => encodePass(cp, p, loopBinds[i][k]));
202+ cp.setPipeline(bumpPipe);
203+ cp.setBindGroup(0, bumpBind);
204+ cp.dispatchWorkgroups(1);
205+ }
206+ cp.end();
207+ device.queue.submit([enc.finish()]);
208+ await device.queue.onSubmittedWorkDone();
209+ done += n;
210+ opts.onProgress?.(done, total);
211+ }
212+ }
213+
214+ // Readback.
215+ const out: Record<string, Float32Array> = {};
216+ for (const name of opts.outputs) {
217+ const src = buffers.get(name);
218+ if (!src) throw new Error(`output '${name}' has no declared buffer`);
219+ const rb = device.createBuffer({
220+ label: `${label}-read-${name}`,
221+ size: src.size,
222+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
223+ });
224+ owned.push(rb);
225+ const enc = device.createCommandEncoder();
226+ enc.copyBufferToBuffer(src, 0, rb, 0, src.size);
227+ device.queue.submit([enc.finish()]);
228+ await rb.mapAsync(GPUMapMode.READ);
229+ out[name] = new Float32Array(rb.getMappedRange().slice(0));
230+ rb.unmap();
231+ }
232+ return out;
233+ } finally {
234+ for (const b of owned) b.destroy();
235+ }
236+}
237+
238+const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
239+const lcm = (a: number, b: number): number => (a / gcd(a, b)) * b;
src/host/session.tsadded+175−0View file
@@ -0,0 +1,175 @@
1+/**
2+ * One instrument under one setting of its parameters: the scope, the bake,
3+ * the render. This is the whole public surface a CLI or a page needs.
4+ *
5+ * The cost model is the point. `precompute()` runs the instrument's
6+ * precompute schedule (for these instruments, the reciprocal room
7+ * recording) and depends only on geometry and accuracy parameters;
8+ * `render()` consumes the bake and the tuning parameters and is cheap. The
9+ * session checks the bake's key on every render, so a stale bake is an
10+ * error rather than a wrong sound.
11+ *
12+ * Reserved derived names the host itself reads: `dt` (the timestep,
13+ * seconds; sets the trace's sample rate). If `n_rows` and `ir_steps` are
14+ * defined and the bake holds `kernels`, `kernelDecay` reports how far the
15+ * room response has decayed where the recording truncates it.
16+ */
17+import { evalDerived } from '../expr.ts';
18+import type { InstrumentFile, StimulusEvent } from '../format.ts';
19+import { bakeKey, type Bake } from './bake.ts';
20+import { PhaseCancelled, runPhase } from './engine.ts';
21+
22+export { PhaseCancelled };
23+
24+/** Samples the synthesis processes per GPU pass; a whole trace is
25+ * ceil(total_steps / tile) such tiles. */
26+export const TILE = 16384;
27+
28+export interface RenderResult {
29+ trace: Float32Array;
30+ dt: number;
31+ totalSteps: number;
32+ /** Extra render buffers read back on request (e.g. the slots a spectrum
33+ * view marks predicted partials from). */
34+ probes: Record<string, Float32Array>;
35+}
36+
37+export class InstrumentSession {
38+ readonly device: GPUDevice;
39+ readonly inst: InstrumentFile;
40+ values: Record<string, number>;
41+
42+ constructor(device: GPUDevice, inst: InstrumentFile, values: Record<string, number>) {
43+ this.device = device;
44+ this.inst = inst;
45+ this.values = values;
46+ }
47+
48+ /** Params plus derived, fully evaluated. */
49+ scope(): Record<string, number> {
50+ return evalDerived(this.inst.derived, this.values);
51+ }
52+
53+ get dt(): number {
54+ const s = this.scope();
55+ if (!('dt' in s)) throw new Error(`instrument '${this.inst.name}' defines no derived 'dt'`);
56+ return s.dt;
57+ }
58+
59+ async bakeKey(): Promise<string> {
60+ return bakeKey(this.inst, this.values);
61+ }
62+
63+ async precompute(
64+ onProgress?: (done: number, total: number) => void,
65+ cancelled?: () => boolean,
66+ ): Promise<Bake> {
67+ const scope = this.scope();
68+ const arrays = await runPhase({
69+ device: this.device,
70+ shaders: this.inst.shaders,
71+ phase: this.inst.precompute,
72+ scope,
73+ outputs: this.inst.precompute.outputs,
74+ label: `${this.inst.name}-precompute`,
75+ onProgress,
76+ cancelled,
77+ });
78+ return {
79+ key: await this.bakeKey(),
80+ instrument: this.inst.name,
81+ scope,
82+ arrays,
83+ };
84+ }
85+
86+ async render(
87+ bake: Bake,
88+ seconds: number,
89+ events: StimulusEvent[] = [{ t: 0 }],
90+ probes: string[] = [],
91+ onProgress?: (done: number, total: number) => void,
92+ cancelled?: () => boolean,
93+ ): Promise<RenderResult> {
94+ if (bake.key !== (await this.bakeKey())) {
95+ throw new Error(
96+ 'the bake does not match the current geometry and accuracy parameters; precompute again',
97+ );
98+ }
99+ if (events.length === 0) throw new Error('a render needs at least one stimulus event');
100+ const base = this.scope();
101+ const dt = base.dt;
102+ const totalSteps = Math.max(1, Math.round(seconds / dt));
103+
104+ let scope: Record<string, number> = {
105+ ...base,
106+ n_events: events.length,
107+ total_steps: totalSteps,
108+ tile: TILE,
109+ n_tiles: Math.ceil(totalSteps / TILE),
110+ };
111+ if (this.inst.render.derived) scope = evalDerived(this.inst.render.derived, scope);
112+
113+ // The stimulus list, packed: one row per event, start_step then the
114+ // instrument's eventParams, defaulting to the current tuning values.
115+ const ep = this.inst.render.eventParams;
116+ const rows = new Float32Array(events.length * (1 + ep.length));
117+ events.forEach((ev, i) => {
118+ rows[i * (1 + ep.length)] = Math.max(0, Math.round(ev.t / dt));
119+ ep.forEach((k, j) => {
120+ const v = ev.set?.[k] ?? this.values[k];
121+ if (v === undefined) throw new Error(`event parameter '${k}' has no value`);
122+ rows[i * (1 + ep.length) + 1 + j] = v;
123+ });
124+ for (const k of Object.keys(ev.set ?? {})) {
125+ if (!ep.includes(k)) throw new Error(`event ${i} sets '${k}', not an event parameter`);
126+ }
127+ });
128+
129+ const inputs: Record<string, Float32Array> = { __events: rows };
130+ for (const name of this.inst.render.inputs) {
131+ const a = bake.arrays[name];
132+ if (!a) throw new Error(`the bake is missing '${name}'`);
133+ inputs[name] = a;
134+ }
135+
136+ const phase = this.inst.render;
137+ const hasEventsBuffer = phase.buffers.some((b) => b.name === '__events');
138+ const buffers = hasEventsBuffer
139+ ? phase.buffers
140+ : [...phase.buffers, { name: '__events', count: `${rows.length}` }];
141+ const out = await runPhase({
142+ device: this.device,
143+ shaders: this.inst.shaders,
144+ phase: { ...phase, buffers },
145+ scope,
146+ inputs,
147+ outputs: [phase.trace, ...probes.filter((p) => p !== phase.trace)],
148+ label: `${this.inst.name}-render`,
149+ onProgress,
150+ cancelled,
151+ });
152+ const probed: Record<string, Float32Array> = {};
153+ for (const p of probes) probed[p] = out[p];
154+ return { trace: out[phase.trace].subarray(0, totalSteps), dt, totalSteps, probes: probed };
155+ }
156+}
157+
158+/** Peak |kernel| in the recording's last tenth over the overall peak, if
159+ * the bake carries kernels the reserved names describe. */
160+export function kernelDecay(bake: Bake): number | null {
161+ const { n_rows, ir_steps } = bake.scope;
162+ const k = bake.arrays.kernels;
163+ if (!k || !Number.isInteger(n_rows) || !Number.isInteger(ir_steps)) return null;
164+ let peak = 0;
165+ let tailPeak = 0;
166+ const tailFrom = Math.floor(0.9 * ir_steps);
167+ for (let q = 0; q < n_rows; q++) {
168+ for (let t = 0; t < ir_steps; t++) {
169+ const v = Math.abs(k[q * ir_steps + t]);
170+ if (v > peak) peak = v;
171+ if (t >= tailFrom && v > tailPeak) tailPeak = v;
172+ }
173+ }
174+ return peak > 0 ? tailPeak / peak : 0;
175+}
src/host/view.tsadded+477−0View file
@@ -0,0 +1,477 @@
1+/**
2+ * The scene viewer: the instrument drawn from its own fields.
3+ *
4+ * A `visual` section names the passes that compute the medium (normally the
5+ * same medium shader the precompute runs) and which buffers to draw. The
6+ * viewer runs those passes at the current parameter values and raymarches
7+ * the results on the GPU: a 'solid' volume (the body mask) is shaded as an
8+ * opaque surface where it crosses one half, a 'cloud' volume (a coupling
9+ * profile) accumulates as a translucent density. Because the fields are the
10+ * medium shader's own output on the actual air grid, the picture is what
11+ * the solver sees, grid effects included: coarsen nx and the sound hole
12+ * staircases, thin a wall under two cells and it visibly stops sealing.
13+ *
14+ * Markers (an object's outline, the microphone) are the one declarative
15+ * part: crisp annotations whose coordinates are expressions over the scope,
16+ * drawn on a 2D overlay with the same camera.
17+ */
18+import { evalExpr } from '../expr.ts';
19+import type { InstrumentFile, VisualSpec } from '../format.ts';
20+
21+export interface Camera {
22+ /** Orbit angles in radians and distance in metres, about the origin. */
23+ yaw: number;
24+ pitch: number;
25+ dist: number;
26+}
27+
28+const WG = 64;
29+
30+interface Vec3 {
31+ x: number;
32+ y: number;
33+ z: number;
34+}
35+
36+const basis = (cam: Camera): { eye: Vec3; right: Vec3; up: Vec3; fwd: Vec3 } => {
37+ const cp = Math.cos(cam.pitch);
38+ const eye = {
39+ x: cam.dist * cp * Math.cos(cam.yaw),
40+ y: cam.dist * cp * Math.sin(cam.yaw),
41+ z: cam.dist * Math.sin(cam.pitch),
42+ };
43+ const norm = (v: Vec3): Vec3 => {
44+ const l = Math.hypot(v.x, v.y, v.z) || 1;
45+ return { x: v.x / l, y: v.y / l, z: v.z / l };
46+ };
47+ const fwd = norm({ x: -eye.x, y: -eye.y, z: -eye.z });
48+ const right = norm({ x: -fwd.y, y: fwd.x, z: 0 }); // cross(fwd, z-up)
49+ const up = {
50+ x: right.y * fwd.z - right.z * fwd.y,
51+ y: right.z * fwd.x - right.x * fwd.z,
52+ z: right.x * fwd.y - right.y * fwd.x,
53+ };
54+ return { eye, right, up: { x: -up.x, y: -up.y, z: -up.z }, fwd };
55+};
56+
57+const TAN_HALF = Math.tan((35 * Math.PI) / 180 / 2);
58+
59+/** The raymarch fragment shader, generated for this instrument's volumes.
60+ * Exported so the test suite can compile-check it headless. */
61+export function raymarchWgsl(visual: VisualSpec): string {
62+ const n = visual.volumes.length;
63+ const binds = visual.volumes
64+ .map((_, i) => `@group(0) @binding(${i + 1}) var<storage, read> f${i}: array<f32>;`)
65+ .join('\n');
66+ const samplers = visual.volumes
67+ .map(
68+ (v, i) => `
69+fn tri${i}(g: vec3f) -> f32 {
70+ let gc = clamp(g, vec3f(0.0), vec3f(f32(NX) - 1.001, f32(NY) - 1.001, f32(NZ) - 1.001));
71+ let i0 = vec3u(floor(gc));
72+ let fr = gc - floor(gc);
73+ let s = i0.x + NX * (i0.y + NY * i0.z);
74+ let v000 = f${i}[s];
75+ let v100 = f${i}[s + 1u];
76+ let v010 = f${i}[s + NX];
77+ let v110 = f${i}[s + NX + 1u];
78+ let v001 = f${i}[s + NX * NY];
79+ let v101 = f${i}[s + NX * NY + 1u];
80+ let v011 = f${i}[s + NX * NY + NX];
81+ let v111 = f${i}[s + NX * NY + NX + 1u];
82+ let a = mix(mix(v000, v100, fr.x), mix(v010, v110, fr.x), fr.y);
83+ let b = mix(mix(v001, v101, fr.x), mix(v011, v111, fr.x), fr.y);
84+ let v = mix(a, b, fr.z);
85+ return ${v.invert ? '1.0 - v' : 'v'};
86+}`,
87+ )
88+ .join('\n');
89+ const marchBody = visual.volumes
90+ .map((v, i) => {
91+ const col = `vec3f(${v.color.join(', ')})`;
92+ if (v.style === 'solid') {
93+ return `
94+ {
95+ let v = tri${i}(g);
96+ // A mask that never saturates (a wall thinner than the grid can
97+ // carry) draws as a haze rather than vanishing: seeing the leak is
98+ // the point of drawing the real fields.
99+ if (v <= 0.5) {
100+ let a = clamp(v, 0.0, 0.5) * 0.25;
101+ accum += trans * a * ${col};
102+ trans *= 1.0 - a;
103+ } else {
104+ let e = 1.0;
105+ let grad = vec3f(
106+ tri${i}(g + vec3f(e, 0.0, 0.0)) - tri${i}(g - vec3f(e, 0.0, 0.0)),
107+ tri${i}(g + vec3f(0.0, e, 0.0)) - tri${i}(g - vec3f(0.0, e, 0.0)),
108+ tri${i}(g + vec3f(0.0, 0.0, e)) - tri${i}(g - vec3f(0.0, 0.0, e)));
109+ var nrm = -grad;
110+ if (dot(nrm, nrm) < 1e-12) { nrm = vec3f(0.0, 0.0, 1.0); }
111+ nrm = normalize(nrm);
112+ let light = normalize(vec3f(0.45, -0.6, 0.75));
113+ let shade = 0.35 + 0.65 * max(dot(nrm, light), 0.0);
114+ let depth = 1.0 - 0.3 * clamp((t - t0) / (t1 - t0 + 1e-6), 0.0, 1.0);
115+ accum += trans * ${col} * shade * depth;
116+ trans = 0.0;
117+ break;
118+ }
119+ }`;
120+ }
121+ return `
122+ {
123+ let vn = tri${i}(g) * u[6][${i}];
124+ let a = 1.0 - exp(-vn * 30.0 / u[4].w);
125+ accum += trans * a * ${col};
126+ trans *= 1.0 - a;
127+ }`;
128+ })
129+ .join('\n');
130+
131+ return `// Raymarch the instrument's medium fields. Uniforms u:
132+// 0: eye.xyz, tanHalf 1: right.xyz, aspect 2: up.xyz 3: fwd.xyz
133+// 4: halfExtents.xyz, steps 5: (unused) 6: per-cloud value scales
134+override NX: u32;
135+override NY: u32;
136+override NZ: u32;
137+@group(0) @binding(0) var<uniform> u: array<vec4f, 7>;
138+${binds}
139+${samplers}
140+
141+struct VOut {
142+ @builtin(position) pos: vec4f,
143+ @location(0) ndc: vec2f,
144+}
145+
146+@vertex
147+fn vmain(@builtin(vertex_index) vi: u32) -> VOut {
148+ let xy = vec2f(f32((vi << 1u) & 2u), f32(vi & 2u)) * 2.0 - 1.0;
149+ return VOut(vec4f(xy, 0.0, 1.0), xy);
150+}
151+
152+@fragment
153+fn fmain(v: VOut) -> @location(0) vec4f {
154+ let eye = u[0].xyz;
155+ let dir = normalize(u[3].xyz + v.ndc.x * u[1].w * u[0].w * u[1].xyz + v.ndc.y * u[0].w * u[2].xyz);
156+ let half = u[4].xyz;
157+ // Slab intersection with the domain box.
158+ let inv = 1.0 / dir;
159+ let tA = (-half - eye) * inv;
160+ let tB = (half - eye) * inv;
161+ let tmin = min(tA, tB);
162+ let tmax = max(tA, tB);
163+ let t0 = max(max(tmin.x, tmin.y), max(tmin.z, 0.0));
164+ let t1 = min(min(tmax.x, tmax.y), tmax.z);
165+ let bg = mix(vec3f(0.10, 0.11, 0.13), vec3f(0.16, 0.17, 0.20), 0.5 * (v.ndc.y + 1.0));
166+ if (t1 <= t0) { return vec4f(bg, 1.0); }
167+ var accum = vec3f(0.0);
168+ var trans = 1.0;
169+ let steps = u[4].w;
170+ let dt = (t1 - t0) / steps;
171+ for (var k = 0.0; k < steps; k += 1.0) {
172+ let t = t0 + (k + 0.5) * dt;
173+ let p = eye + t * dir;
174+ let g = (p + half) / (2.0 * half) * vec3f(f32(NX), f32(NY), f32(NZ)) - 0.5;
175+${marchBody}
176+ if (trans < 0.02) { break; }
177+ }
178+ return vec4f(accum + trans * bg, 1.0);
179+}
180+`;
181+}
182+
183+export class Viewer {
184+ #device: GPUDevice;
185+ #inst: InstrumentFile;
186+ #visual: VisualSpec;
187+ #canvas: HTMLCanvasElement;
188+ #overlay: HTMLCanvasElement;
189+ #ctx: GPUCanvasContext;
190+ #format: GPUTextureFormat;
191+ #buffers = new Map<string, GPUBuffer>();
192+ #uniform: GPUBuffer;
193+ #pipeline: GPURenderPipeline | null = null;
194+ #bind: GPUBindGroup | null = null;
195+ #scope: Record<string, number> = {};
196+ #scales: number[] = [1, 1, 1, 1];
197+ #dims: [number, number, number] = [1, 1, 1];
198+ #half: [number, number, number] = [0.5, 0.25, 0.25];
199+
200+ constructor(device: GPUDevice, inst: InstrumentFile, canvas: HTMLCanvasElement, overlay: HTMLCanvasElement) {
201+ if (!inst.visual) throw new Error(`instrument '${inst.name}' has no visual section`);
202+ this.#device = device;
203+ this.#inst = inst;
204+ this.#visual = inst.visual;
205+ this.#canvas = canvas;
206+ this.#overlay = overlay;
207+ this.#format = navigator.gpu.getPreferredCanvasFormat();
208+ const ctx = canvas.getContext('webgpu');
209+ if (!ctx) throw new Error('no webgpu canvas context');
210+ this.#ctx = ctx;
211+ ctx.configure({ device, format: this.#format, alphaMode: 'opaque' });
212+ this.#uniform = device.createBuffer({
213+ label: 'view-uniforms',
214+ size: 7 * 16,
215+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
216+ });
217+ }
218+
219+ /** Run the visual passes at these values and refresh normalization. */
220+ async update(scope: Record<string, number>): Promise<void> {
221+ const device = this.#device;
222+ const v = this.#visual;
223+ this.#scope = scope;
224+ this.#dims = v.dims.map((d) => evalExpr(d, scope)) as [number, number, number];
225+ this.#half = v.extents.map((e) => evalExpr(e, scope) / 2) as [number, number, number];
226+
227+ for (const b of v.buffers) {
228+ const bytes = 4 * Math.max(1, evalExpr(b.count, scope));
229+ const have = this.#buffers.get(b.name);
230+ if (!have || have.size !== bytes) {
231+ have?.destroy();
232+ this.#buffers.set(
233+ b.name,
234+ device.createBuffer({
235+ label: `view-${b.name}`,
236+ size: bytes,
237+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
238+ }),
239+ );
240+ }
241+ }
242+
243+ device.pushErrorScope('validation');
244+ const enc = device.createCommandEncoder({ label: 'view-update' });
245+ const cp = enc.beginComputePass();
246+ for (const pass of v.setup) {
247+ const s = this.#inst.shaders[pass.shader];
248+ const constants: Record<string, number> = {};
249+ for (const c of s.constants) {
250+ if (!(c in scope)) throw new Error(`visual pass '${pass.shader}': constant '${c}' not in scope`);
251+ constants[c] = scope[c];
252+ }
253+ const pipe = await device.createComputePipelineAsync({
254+ label: `view-${pass.shader}`,
255+ layout: 'auto',
256+ compute: {
257+ module: device.createShaderModule({ code: s.code, label: `view-${pass.shader}` }),
258+ entryPoint: 'main',
259+ constants,
260+ },
261+ });
262+ cp.setPipeline(pipe);
263+ cp.setBindGroup(
264+ 0,
265+ device.createBindGroup({
266+ layout: pipe.getBindGroupLayout(0),
267+ entries: pass.buffers.map((name, i) => {
268+ const buf = this.#buffers.get(name);
269+ if (!buf) throw new Error(`visual pass binds undeclared buffer '${name}'`);
270+ return { binding: i, resource: { buffer: buf } };
271+ }),
272+ }),
273+ );
274+ const wg = Math.ceil(evalExpr(pass.dispatch, scope) / WG);
275+ cp.dispatchWorkgroups(Math.min(wg, 65535), Math.ceil(wg / 65535));
276+ }
277+ cp.end();
278+ device.queue.submit([enc.finish()]);
279+
280+ // Normalize each cloud volume to its own peak.
281+ for (let i = 0; i < v.volumes.length; i++) {
282+ const vol = v.volumes[i];
283+ if (vol.style !== 'cloud') {
284+ this.#scales[i] = 1;
285+ continue;
286+ }
287+ const src = this.#buffers.get(vol.buffer)!;
288+ const rb = device.createBuffer({ size: src.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
289+ const e2 = device.createCommandEncoder();
290+ e2.copyBufferToBuffer(src, 0, rb, 0, src.size);
291+ device.queue.submit([e2.finish()]);
292+ await rb.mapAsync(GPUMapMode.READ);
293+ const data = new Float32Array(rb.getMappedRange());
294+ let peak = 0;
295+ for (const x of data) peak = Math.max(peak, Math.abs(x));
296+ rb.unmap();
297+ rb.destroy();
298+ this.#scales[i] = peak > 0 ? 1 / peak : 1;
299+ }
300+
301+ // The raymarch pipeline, rebuilt because the dims are baked in.
302+ const module = device.createShaderModule({ code: raymarchWgsl(v), label: 'view-raymarch' });
303+ this.#pipeline = await device.createRenderPipelineAsync({
304+ label: 'view-raymarch',
305+ layout: 'auto',
306+ vertex: { module, entryPoint: 'vmain' },
307+ fragment: {
308+ module,
309+ entryPoint: 'fmain',
310+ targets: [{ format: this.#format }],
311+ constants: { NX: this.#dims[0], NY: this.#dims[1], NZ: this.#dims[2] },
312+ },
313+ primitive: { topology: 'triangle-list' },
314+ });
315+ this.#bind = device.createBindGroup({
316+ layout: this.#pipeline.getBindGroupLayout(0),
317+ entries: [
318+ { binding: 0, resource: { buffer: this.#uniform } },
319+ ...v.volumes.map((vol, i) => ({ binding: i + 1, resource: { buffer: this.#buffers.get(vol.buffer)! } })),
320+ ],
321+ });
322+ const err = await device.popErrorScope();
323+ if (err) throw new Error(`viewer: ${err.message}`);
324+ }
325+
326+ draw(cam: Camera): void {
327+ if (!this.#pipeline || !this.#bind) return;
328+ const device = this.#device;
329+ const { eye, right, up, fwd } = basis(cam);
330+ const w = this.#canvas.width;
331+ const h = this.#canvas.height;
332+ const steps = 2 * Math.max(...this.#dims);
333+ const u = new Float32Array(7 * 4);
334+ u.set([eye.x, eye.y, eye.z, TAN_HALF], 0);
335+ u.set([right.x, right.y, right.z, w / h], 4);
336+ u.set([up.x, up.y, up.z, 0], 8);
337+ u.set([fwd.x, fwd.y, fwd.z, 0], 12);
338+ u.set([...this.#half, steps], 16);
339+ u.set(this.#scales, 24);
340+ device.queue.writeBuffer(this.#uniform, 0, u);
341+ const enc = device.createCommandEncoder({ label: 'view-draw' });
342+ const rp = enc.beginRenderPass({
343+ colorAttachments: [
344+ {
345+ view: this.#ctx.getCurrentTexture().createView(),
346+ loadOp: 'clear',
347+ storeOp: 'store',
348+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
349+ },
350+ ],
351+ });
352+ rp.setPipeline(this.#pipeline);
353+ rp.setBindGroup(0, this.#bind);
354+ rp.draw(3);
355+ rp.end();
356+ device.queue.submit([enc.finish()]);
357+ this.#drawOverlay(cam);
358+ }
359+
360+ /** Project a world point to overlay pixels; null when behind the eye. */
361+ #project(cam: ReturnType<typeof basis>, p: [number, number, number]): [number, number] | null {
362+ const d = { x: p[0] - cam.eye.x, y: p[1] - cam.eye.y, z: p[2] - cam.eye.z };
363+ const dot = (a: Vec3): number => a.x * d.x + a.y * d.y + a.z * d.z;
364+ const z = dot(cam.fwd);
365+ if (z <= 1e-4) return null;
366+ const w = this.#overlay.width;
367+ const h = this.#overlay.height;
368+ const x = dot(cam.right) / (z * TAN_HALF * (w / h));
369+ const y = dot(cam.up) / (z * TAN_HALF);
370+ return [(x * 0.5 + 0.5) * w, (1 - (y * 0.5 + 0.5)) * h];
371+ }
372+
373+ #drawOverlay(cam: Camera): void {
374+ const b = basis(cam);
375+ const g = this.#overlay.getContext('2d');
376+ if (!g) return;
377+ const w = this.#overlay.width;
378+ const h = this.#overlay.height;
379+ g.clearRect(0, 0, w, h);
380+ const seg = (p: [number, number, number], q: [number, number, number]): void => {
381+ const a = this.#project(b, p);
382+ const c = this.#project(b, q);
383+ if (!a || !c) return;
384+ g.beginPath();
385+ g.moveTo(a[0], a[1]);
386+ g.lineTo(c[0], c[1]);
387+ g.stroke();
388+ };
389+ // The domain box, faint.
390+ const [hx, hy, hz] = this.#half;
391+ g.strokeStyle = 'rgba(255,255,255,0.18)';
392+ g.lineWidth = 1;
393+ for (const [i, j] of [[0, 1], [0, 2], [1, 3], [2, 3]] as const) {
394+ const c = (k: number): [number, number] => [k & 1 ? hx : -hx, k & 2 ? hy : -hy];
395+ const [xi, yi] = c(i);
396+ const [xj, yj] = c(j);
397+ seg([xi, yi, -hz], [xj, yj, -hz]);
398+ seg([xi, yi, hz], [xj, yj, hz]);
399+ seg([xi, yi, -hz], [xi, yi, hz]);
400+ }
401+ const ev = (e: [string, string, string]): [number, number, number] =>
402+ e.map((x) => evalExpr(x, this.#scope)) as [number, number, number];
403+ for (const m of this.#visual.markers) {
404+ const col = `rgb(${m.color.map((c) => Math.round(255 * c)).join(',')})`;
405+ g.strokeStyle = col;
406+ g.fillStyle = col;
407+ g.lineWidth = 1.5;
408+ let anchor: [number, number] | null = null;
409+ if (m.kind === 'point') {
410+ const p = this.#project(b, ev(m.at));
411+ if (p) {
412+ g.beginPath();
413+ g.arc(p[0], p[1], 4, 0, 2 * Math.PI);
414+ g.fill();
415+ anchor = p;
416+ }
417+ } else if (m.kind === 'line') {
418+ const from = ev(m.from);
419+ const to = ev(m.to);
420+ seg(from, to);
421+ anchor = this.#project(b, to);
422+ } else {
423+ const c0 = ev(m.corner);
424+ const du = ev(m.du);
425+ const dv = ev(m.dv);
426+ const add = (a: [number, number, number], ...rest: [number, number, number][]): [number, number, number] =>
427+ rest.reduce((s, r) => [s[0] + r[0], s[1] + r[1], s[2] + r[2]] as [number, number, number], a);
428+ const c1 = add(c0, du);
429+ const c2 = add(c0, du, dv);
430+ const c3 = add(c0, dv);
431+ seg(c0, c1);
432+ seg(c1, c2);
433+ seg(c2, c3);
434+ seg(c3, c0);
435+ anchor = this.#project(b, c2);
436+ }
437+ if (m.label && anchor) {
438+ g.font = '11px system-ui, sans-serif';
439+ g.fillText(m.label, anchor[0] + 6, anchor[1] - 5);
440+ }
441+ }
442+ }
443+
444+ destroy(): void {
445+ for (const b of this.#buffers.values()) b.destroy();
446+ this.#buffers.clear();
447+ this.#uniform.destroy();
448+ }
449+}
450+
451+/** Predicted partial frequencies read off a probed slots buffer: one entry
452+ * per underdamped root, in Hz with a relative weight, deduplicated. */
453+export function slotPartials(
454+ slots: Float32Array,
455+ layout: { stride: number; zRe: number; zIm: number },
456+ dt: number,
457+): { freq: number; weight: number }[] {
458+ const byFreq = new Map<number, number>();
459+ for (let s = 0; s + layout.stride <= slots.length; s += layout.stride) {
460+ const zRe = slots[s + layout.zRe];
461+ const zIm = slots[s + layout.zIm];
462+ const cAbs = Math.hypot(slots[s], slots[s + 1]);
463+ if (zIm <= 0 || cAbs === 0) continue;
464+ const w = Math.abs(Math.atan2(zIm, zRe)) / dt;
465+ const freq = w / (2 * Math.PI);
466+ // Weight by rough displacement (the coefficients are acceleration
467+ // series, which flatter high modes); this only sets tick opacity.
468+ const key = Math.round(freq * 2) / 2;
469+ byFreq.set(key, (byFreq.get(key) ?? 0) + cAbs / Math.max(w * w, 1));
470+ }
471+ const out = [...byFreq.entries()].map(([freq, weight]) => ({ freq, weight }));
472+ const max = out.reduce((a, p) => Math.max(a, p.weight), 0);
473+ return out
474+ .filter((p) => p.weight > 1e-4 * max)
475+ .map((p) => ({ freq: p.freq, weight: p.weight / max }))
476+ .sort((a, b) => a.freq - b.freq);
477+}
src/main.tsadded+420−0View file
@@ -0,0 +1,420 @@
1+/**
2+ * The page: load a bundled instrument, move its parameters, bake, render,
3+ * listen. The cost model is on screen: tuning renders in about a second
4+ * from a kept bake; a geometry or accuracy edit updates the 3D view at once
5+ * (the medium pass is cheap) but marks the bake stale, and Precompute with
6+ * its progress bar is the price of the change. After a render, the trace
7+ * and its spectrum are drawn, with each rendered mode's own discrete
8+ * frequency marked over the measured peaks.
9+ */
10+import { requestDevice } from './host/device.ts';
11+import { InstrumentSession, kernelDecay, PhaseCancelled } from './host/session.ts';
12+import { Viewer, slotPartials, type Camera } from './host/view.ts';
13+import { spectrumOf } from './fft.ts';
14+import { parseInstrument, paramValues, type InstrumentFile, type ParamSpec, type StimulusEvent } from './format.ts';
15+import { traceToWavBytes } from './wav.ts';
16+import type { Bake } from './host/bake.ts';
17+import drumJson from '../instruments/drum.instrument.json';
18+import dulcimerJson from '../instruments/dulcimer.instrument.json';
19+import chimeJson from '../instruments/chime.instrument.json';
20+
21+const el = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
22+const instrumentSel = el<HTMLSelectElement>('instrument');
23+const tuningDiv = el<HTMLDivElement>('tuning');
24+const geometryDiv = el<HTMLDivElement>('geometry');
25+const eventsBox = el<HTMLTextAreaElement>('events');
26+const bakeState = el<HTMLSpanElement>('bakestate');
27+const gridInfo = el<HTMLSpanElement>('gridinfo');
28+const progress = el<HTMLProgressElement>('progress');
29+const status = el<HTMLParagraphElement>('status');
30+const volumeCanvas = el<HTMLCanvasElement>('volume');
31+const overlayCanvas = el<HTMLCanvasElement>('overlay');
32+const traceCanvas = el<HTMLCanvasElement>('trace');
33+const spectrumCanvas = el<HTMLCanvasElement>('spectrum');
34+const buttons = {
35+ precompute: el<HTMLButtonElement>('precompute'),
36+ cancel: el<HTMLButtonElement>('cancel'),
37+ render: el<HTMLButtonElement>('render'),
38+ play: el<HTMLButtonElement>('play'),
39+ save: el<HTMLButtonElement>('save'),
40+};
41+
42+const instruments: InstrumentFile[] = [drumJson, dulcimerJson, chimeJson].map((j) =>
43+ parseInstrument(JSON.stringify(j)),
44+);
45+for (const inst of instruments) {
46+ const o = document.createElement('option');
47+ o.value = inst.name;
48+ o.textContent = inst.name;
49+ instrumentSel.append(o);
50+}
51+
52+let device: GPUDevice;
53+let session: InstrumentSession;
54+let bake: Bake | null = null;
55+let viewer: Viewer | null = null;
56+let cancelFlag = false;
57+let last: { trace: Float32Array; dt: number; partials: { freq: number; weight: number }[] } | null = null;
58+let audioCtx: AudioContext | null = null;
59+const cam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 };
60+
61+const fmt = (v: number): string => {
62+ const a = Math.abs(v);
63+ if (v === 0) return '0';
64+ if (a >= 1000 || a < 0.01) return v.toExponential(2);
65+ return `${Math.round(v * 10000) / 10000}`;
66+};
67+
68+/* ------------------------------------------------------- scene view --- */
69+
70+function sizeCanvas(c: HTMLCanvasElement): void {
71+ const r = c.getBoundingClientRect();
72+ const dpr = Math.min(devicePixelRatio || 1, 2);
73+ const w = Math.max(1, Math.round(r.width * dpr));
74+ const h = Math.max(1, Math.round(r.height * dpr));
75+ if (c.width !== w || c.height !== h) {
76+ c.width = w;
77+ c.height = h;
78+ }
79+}
80+
81+let viewTimer: number | null = null;
82+function scheduleViewUpdate(): void {
83+ if (viewTimer !== null) clearTimeout(viewTimer);
84+ viewTimer = window.setTimeout(() => {
85+ viewTimer = null;
86+ void (async () => {
87+ try {
88+ await viewer?.update(session.scope());
89+ viewer?.draw(cam);
90+ } catch (e) {
91+ status.textContent = `scene view: ${e instanceof Error ? e.message : e}`;
92+ }
93+ })();
94+ }, 180);
95+}
96+
97+{
98+ let dragging = false;
99+ let px = 0;
100+ let py = 0;
101+ overlayCanvas.addEventListener('pointerdown', (e) => {
102+ dragging = true;
103+ px = e.clientX;
104+ py = e.clientY;
105+ overlayCanvas.setPointerCapture(e.pointerId);
106+ });
107+ overlayCanvas.addEventListener('pointermove', (e) => {
108+ if (!dragging) return;
109+ cam.yaw -= (e.clientX - px) * 0.008;
110+ cam.pitch = Math.min(1.45, Math.max(-1.45, cam.pitch + (e.clientY - py) * 0.008));
111+ px = e.clientX;
112+ py = e.clientY;
113+ viewer?.draw(cam);
114+ });
115+ overlayCanvas.addEventListener('pointerup', () => {
116+ dragging = false;
117+ });
118+ overlayCanvas.addEventListener(
119+ 'wheel',
120+ (e) => {
121+ e.preventDefault();
122+ cam.dist = Math.min(6, Math.max(0.3, cam.dist * Math.exp(e.deltaY * 0.001)));
123+ viewer?.draw(cam);
124+ },
125+ { passive: false },
126+ );
127+}
128+
129+/* ---------------------------------------------------------- plots ----- */
130+
131+function drawTrace(trace: Float32Array, dt: number): void {
132+ sizeCanvas(traceCanvas);
133+ traceCanvas.hidden = false;
134+ const g = traceCanvas.getContext('2d')!;
135+ const w = traceCanvas.width;
136+ const h = traceCanvas.height;
137+ g.fillStyle = '#14161a';
138+ g.fillRect(0, 0, w, h);
139+ let peak = 0;
140+ for (const v of trace) peak = Math.max(peak, Math.abs(v));
141+ const scale = peak > 0 ? (0.46 * h) / peak : 0;
142+ g.strokeStyle = '#7fb4e6';
143+ g.lineWidth = 1;
144+ g.beginPath();
145+ for (let x = 0; x < w; x++) {
146+ const i0 = Math.floor((x / w) * trace.length);
147+ const i1 = Math.min(trace.length, Math.floor(((x + 1) / w) * trace.length) + 1);
148+ let lo = Infinity;
149+ let hi = -Infinity;
150+ for (let i = i0; i < i1; i++) {
151+ if (trace[i] < lo) lo = trace[i];
152+ if (trace[i] > hi) hi = trace[i];
153+ }
154+ g.moveTo(x + 0.5, h / 2 - hi * scale);
155+ g.lineTo(x + 0.5, h / 2 - lo * scale + 1);
156+ }
157+ g.stroke();
158+ // Second ticks.
159+ g.fillStyle = 'rgba(255,255,255,0.55)';
160+ g.font = `${11 * (traceCanvas.width / traceCanvas.getBoundingClientRect().width)}px system-ui`;
161+ const seconds = trace.length * dt;
162+ const tick = seconds > 12 ? 2 : seconds > 5 ? 1 : 0.5;
163+ for (let t = 0; t <= seconds; t += tick) {
164+ const x = (t / seconds) * w;
165+ g.fillRect(x, h - 6, 1, 6);
166+ if (t > 0) g.fillText(`${t}s`, x + 3, h - 4);
167+ }
168+}
169+
170+function drawSpectrum(
171+ trace: Float32Array,
172+ dt: number,
173+ partials: { freq: number; weight: number }[],
174+ honestHz: number,
175+): void {
176+ sizeCanvas(spectrumCanvas);
177+ spectrumCanvas.hidden = false;
178+ const g = spectrumCanvas.getContext('2d')!;
179+ const w = spectrumCanvas.width;
180+ const h = spectrumCanvas.height;
181+ const dpr = spectrumCanvas.width / spectrumCanvas.getBoundingClientRect().width;
182+ g.fillStyle = '#14161a';
183+ g.fillRect(0, 0, w, h);
184+ const { db, binHz } = spectrumOf(trace, dt);
185+ const fLo = 30;
186+ const fHi = Math.min(1 / (2 * dt), 12000);
187+ const xOf = (f: number): number => (Math.log(f / fLo) / Math.log(fHi / fLo)) * w;
188+ const dbFloor = -100;
189+ const yOf = (d: number): number => ((-d / -dbFloor) * (h - 18));
190+
191+ // The band the grid does not resolve, shaded.
192+ if (honestHz < fHi) {
193+ g.fillStyle = 'rgba(255,255,255,0.06)';
194+ g.fillRect(xOf(honestHz), 0, w - xOf(honestHz), h);
195+ g.fillStyle = 'rgba(255,255,255,0.4)';
196+ g.font = `${10 * dpr}px system-ui`;
197+ g.fillText('above the grid’s resolution', xOf(honestHz) + 4 * dpr, 12 * dpr);
198+ }
199+
200+ // Decade grid and labels.
201+ g.strokeStyle = 'rgba(255,255,255,0.12)';
202+ g.fillStyle = 'rgba(255,255,255,0.55)';
203+ g.font = `${11 * dpr}px system-ui`;
204+ g.lineWidth = 1;
205+ for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000]) {
206+ if (f < fLo || f > fHi) continue;
207+ const x = xOf(f);
208+ g.beginPath();
209+ g.moveTo(x, 0);
210+ g.lineTo(x, h);
211+ g.stroke();
212+ g.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x + 2 * dpr, h - 4 * dpr);
213+ }
214+
215+ // Predicted partials: the discrete scheme's own frequencies, from the
216+ // rendered slots. Ticks from the top, opacity by source strength.
217+ for (const p of partials) {
218+ if (p.freq < fLo || p.freq > fHi) continue;
219+ const x = xOf(p.freq);
220+ g.strokeStyle = `rgba(255, 200, 80, ${0.25 + 0.75 * Math.min(1, Math.sqrt(p.weight))})`;
221+ g.lineWidth = 1.5;
222+ g.beginPath();
223+ g.moveTo(x, 14 * dpr);
224+ g.lineTo(x, 34 * dpr);
225+ g.stroke();
226+ }
227+
228+ // The measured spectrum.
229+ g.strokeStyle = '#7fb4e6';
230+ g.lineWidth = 1.2;
231+ g.beginPath();
232+ let started = false;
233+ for (let x = 0; x < w; x++) {
234+ const f0 = fLo * Math.pow(fHi / fLo, x / w);
235+ const f1 = fLo * Math.pow(fHi / fLo, (x + 1) / w);
236+ const i0 = Math.max(1, Math.floor(f0 / binHz));
237+ const i1 = Math.max(i0 + 1, Math.ceil(f1 / binHz));
238+ let best = dbFloor;
239+ for (let i = i0; i < Math.min(i1, db.length); i++) best = Math.max(best, db[i]);
240+ const y = yOf(Math.max(best, dbFloor));
241+ if (started) g.lineTo(x, y);
242+ else g.moveTo(x, y);
243+ started = true;
244+ }
245+ g.stroke();
246+}
247+
248+/* ------------------------------------------------------- lifecycle ---- */
249+
250+async function refreshBakeState(): Promise<void> {
251+ const s = session.scope();
252+ gridInfo.textContent =
253+ `air ${s.nx}×${s.ny}×${s.nz}, dt ${(1e6 * s.dt).toFixed(2)} µs, ` +
254+ `${s.n_rows} kernel rows × ${s.ir_steps} steps ` +
255+ `(${((s.n_rows * s.ir_steps * 4) / 1e6).toFixed(0)} MB bake)`;
256+ const key = await session.bakeKey();
257+ const ok = bake !== null && bake.key === key;
258+ bakeState.textContent = ok ? 'bake ready' : 'bake needed';
259+ bakeState.className = ok ? 'ready' : 'stale';
260+ buttons.render.disabled = !ok;
261+}
262+
263+function slider(p: ParamSpec, into: HTMLElement): void {
264+ const div = document.createElement('div');
265+ div.className = 'param';
266+ const label = document.createElement('label');
267+ const name = document.createElement('span');
268+ // Tuning labels carry the key, which is what a stimulus "set" uses.
269+ name.textContent =
270+ `${p.label}${p.unit !== '1' ? ` (${p.unit})` : ''}` +
271+ (p.class === 'tuning' && p.key !== p.label ? ` · ${p.key}` : '');
272+ if (p.hint) label.title = p.hint;
273+ const value = document.createElement('span');
274+ const input = document.createElement('input');
275+ input.type = 'range';
276+ input.min = `${p.min}`;
277+ input.max = `${p.max}`;
278+ input.step = `${p.step ?? (p.max - p.min) / 200}`;
279+ input.value = `${session.values[p.key]}`;
280+ value.textContent = fmt(session.values[p.key]);
281+ input.oninput = () => {
282+ session.values[p.key] = Number(input.value);
283+ value.textContent = fmt(Number(input.value));
284+ void refreshBakeState();
285+ if (p.class !== 'tuning') scheduleViewUpdate();
286+ };
287+ label.append(name, value);
288+ div.append(label, input);
289+ into.append(div);
290+}
291+
292+function loadInstrument(name: string): void {
293+ const inst = instruments.find((i) => i.name === name)!;
294+ session = new InstrumentSession(device, inst, paramValues(inst));
295+ bake = null;
296+ last = null;
297+ buttons.play.disabled = true;
298+ buttons.save.disabled = true;
299+ traceCanvas.hidden = true;
300+ spectrumCanvas.hidden = true;
301+ tuningDiv.replaceChildren();
302+ geometryDiv.replaceChildren();
303+ for (const p of inst.params) slider(p, p.class === 'tuning' ? tuningDiv : geometryDiv);
304+ status.textContent = inst.description;
305+ el<HTMLSpanElement>('evhint').textContent =
306+ `JSON list; "set" may override: ${inst.render.eventParams.join(', ')}`;
307+ viewer?.destroy();
308+ viewer = null;
309+ if (inst.visual) {
310+ sizeCanvas(volumeCanvas);
311+ sizeCanvas(overlayCanvas);
312+ viewer = new Viewer(device, inst, volumeCanvas, overlayCanvas);
313+ scheduleViewUpdate();
314+ }
315+ void refreshBakeState();
316+}
317+
318+buttons.precompute.onclick = async () => {
319+ cancelFlag = false;
320+ progress.hidden = false;
321+ buttons.cancel.hidden = false;
322+ buttons.precompute.disabled = true;
323+ const t0 = performance.now();
324+ try {
325+ bake = await session.precompute(
326+ (done, total) => {
327+ progress.value = done / total;
328+ },
329+ () => cancelFlag,
330+ );
331+ const decay = kernelDecay(bake);
332+ status.textContent =
333+ `precomputed in ${((performance.now() - t0) / 1000).toFixed(1)} s` +
334+ (decay !== null
335+ ? `; room response at truncation ${decay > 0 ? `${(20 * Math.log10(decay)).toFixed(0)} dB` : 'silent'}` +
336+ (decay > 1e-3 ? ' (still ringing; a longer room response length would carry it)' : '')
337+ : '');
338+ } catch (e) {
339+ status.textContent = e instanceof PhaseCancelled ? 'precompute cancelled' : `precompute failed: ${e}`;
340+ } finally {
341+ progress.hidden = true;
342+ buttons.cancel.hidden = true;
343+ buttons.precompute.disabled = false;
344+ void refreshBakeState();
345+ }
346+};
347+buttons.cancel.onclick = () => {
348+ cancelFlag = true;
349+};
350+
351+buttons.render.onclick = async () => {
352+ if (!bake) return;
353+ let events: StimulusEvent[];
354+ try {
355+ events = JSON.parse(eventsBox.value) as StimulusEvent[];
356+ if (!Array.isArray(events) || events.some((e) => typeof e.t !== 'number')) {
357+ throw new Error('want a list of {"t": seconds, "set": {...}}');
358+ }
359+ } catch (e) {
360+ status.textContent = `stimuli: ${e instanceof Error ? e.message : e}`;
361+ return;
362+ }
363+ const seconds = Number(el<HTMLInputElement>('seconds').value);
364+ buttons.render.disabled = true;
365+ const t0 = performance.now();
366+ try {
367+ const vp = session.inst.visual?.partials;
368+ const r = await session.render(bake, seconds, events, vp ? [vp.buffer] : []);
369+ const partials = vp ? slotPartials(r.probes[vp.buffer], vp, r.dt) : [];
370+ last = { trace: r.trace, dt: r.dt, partials };
371+ status.textContent =
372+ `rendered ${events.length} event${events.length === 1 ? '' : 's'}, ` +
373+ `${r.trace.length.toLocaleString()} samples in ${((performance.now() - t0) / 1000).toFixed(1)} s`;
374+ buttons.play.disabled = false;
375+ buttons.save.disabled = false;
376+ const s = session.scope();
377+ drawTrace(r.trace, r.dt);
378+ drawSpectrum(r.trace, r.dt, partials, s.c0 / (8 * s.h));
379+ } catch (e) {
380+ status.textContent = `render failed: ${e}`;
381+ } finally {
382+ buttons.render.disabled = false;
383+ }
384+};
385+
386+buttons.play.onclick = async () => {
387+ if (!last) return;
388+ audioCtx ??= new AudioContext();
389+ const bytes = traceToWavBytes(last.trace, last.dt);
390+ const buf = await audioCtx.decodeAudioData(bytes.buffer.slice(0) as ArrayBuffer);
391+ const src = audioCtx.createBufferSource();
392+ src.buffer = buf;
393+ src.connect(audioCtx.destination);
394+ src.start();
395+};
396+
397+buttons.save.onclick = () => {
398+ if (!last) return;
399+ const bytes = traceToWavBytes(last.trace, last.dt);
400+ const url = URL.createObjectURL(new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'audio/wav' }));
401+ const a = document.createElement('a');
402+ a.href = url;
403+ a.download = `${session.inst.name}.wav`;
404+ a.click();
405+ URL.revokeObjectURL(url);
406+};
407+
408+instrumentSel.onchange = () => loadInstrument(instrumentSel.value);
409+window.addEventListener('resize', () => {
410+ sizeCanvas(volumeCanvas);
411+ sizeCanvas(overlayCanvas);
412+ scheduleViewUpdate();
413+});
414+
415+try {
416+ device = await requestDevice();
417+ loadInstrument(instruments[0].name);
418+} catch (e) {
419+ status.textContent = `${e instanceof Error ? e.message : e}`;
420+}
src/wav.tsadded+54−0View file
@@ -0,0 +1,54 @@
1+/**
2+ * A rendered trace as .wav bytes: resampled from the solver's rate (1/dt,
3+ * around 114 kHz at the default grid) to 48 kHz by linear interpolation,
4+ * which is harmless here because the content is band-limited far below
5+ * either rate. Normalized to peak and faded a few milliseconds at each end
6+ * so the file does not open and close with a click.
7+ */
8+export const WAV_RATE = 48000;
9+
10+export function traceToWavBytes(trace: Float32Array, dt: number): Uint8Array {
11+ const n = Math.max(1, Math.round(trace.length * dt * WAV_RATE));
12+ let peak = 0;
13+ for (const v of trace) peak = Math.max(peak, Math.abs(v));
14+ const gain = peak > 0 ? 0.9 / peak : 0;
15+
16+ const samples = new Float32Array(n);
17+ for (let i = 0; i < n; i++) {
18+ const s = i / (WAV_RATE * dt);
19+ const k = Math.min(trace.length - 2, Math.floor(s));
20+ const f = Math.min(1, s - k);
21+ samples[i] = gain * ((1 - f) * trace[k] + f * trace[k + 1]);
22+ }
23+ const fade = Math.min(Math.round(0.005 * WAV_RATE), Math.floor(n / 2));
24+ for (let i = 0; i < fade; i++) {
25+ const w = i / fade;
26+ samples[i] *= w;
27+ samples[n - 1 - i] *= w;
28+ }
29+
30+ const bytes = 44 + 2 * n;
31+ const buf = new ArrayBuffer(bytes);
32+ const view = new DataView(buf);
33+ const str = (off: number, s: string): void => {
34+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
35+ };
36+ str(0, 'RIFF');
37+ view.setUint32(4, bytes - 8, true);
38+ str(8, 'WAVE');
39+ str(12, 'fmt ');
40+ view.setUint32(16, 16, true);
41+ view.setUint16(20, 1, true);
42+ view.setUint16(22, 1, true);
43+ view.setUint32(24, WAV_RATE, true);
44+ view.setUint32(28, 2 * WAV_RATE, true);
45+ view.setUint16(32, 2, true);
46+ view.setUint16(34, 16, true);
47+ str(36, 'data');
48+ view.setUint32(40, 2 * n, true);
49+ for (let i = 0; i < n; i++) {
50+ const v = Math.max(-1, Math.min(1, samples[i]));
51+ view.setInt16(44 + 2 * i, Math.round(v * 32767), true);
52+ }
53+ return new Uint8Array(buf);
54+}
tsconfig.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "allowImportingTsExtensions": true,
7+ "resolveJsonModule": true,
8+ "strict": true,
9+ "noUncheckedIndexedAccess": false,
10+ "noEmit": true,
11+ "skipLibCheck": true,
12+ "types": [
13+ "@webgpu/types",
14+ "vite/client",
15+ "node"
16+ ]
17+ },
18+ "include": [
19+ "src",
20+ "author",
21+ "scripts"
22+ ],
23+ "exclude": [
24+ "scripts/test-node.ts"
25+ ]
26+}
vite.config.tsadded+26−0View file
@@ -0,0 +1,26 @@
1+import { defineConfig } from 'vite';
2+import { realpathSync } from 'node:fs';
3+import { resolve } from 'node:path';
4+
5+// The test suite validates against soundbox (a sibling checkout, the vault
6+// layout), whose sources reach numbl's compiler internals through a
7+// `numbl-src` alias; mirror it so those sources load here too. The page
8+// itself uses neither.
9+const soundbox = resolve(import.meta.dirname, '../soundbox');
10+let numblSrc: string | null = null;
11+try {
12+ numblSrc = realpathSync(resolve(soundbox, 'node_modules/numbl/src'));
13+} catch {
14+ // No soundbox checkout: the app still builds; only the tests need it.
15+}
16+
17+export default defineConfig({
18+ base: './',
19+ resolve: {
20+ alias: numblSrc ? { 'numbl-src': numblSrc } : {},
21+ },
22+ server: {
23+ fs: { allow: [import.meta.dirname, ...(numblSrc ? [soundbox, numblSrc] : [])] },
24+ },
25+ build: { target: 'es2022' },
26+});