soundbox: a scene-composition studio over the dulcimer engine
Generalize the MATLAB-to-WebGPU pipeline from two hardcoded grids to N
object grids with per-object op instances, generate the model and scene .m
files from a scene description (strings, bars, membranes over a box, drum
shell, or open air), and split the interface into Play / Build / Lab.
Physics checks run against desktop WebGPU.
46 changed files+11740−0
.github/workflows/deploy.ymladded+66−0View file
@@ -0,0 +1,66 @@
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+ environment:
20+ name: github-pages
21+ url: ${{ steps.deployment.outputs.page_url }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: actions/setup-node@v4
25+ with:
26+ node-version: 24
27+ cache: npm
28+ # numbl is a `file:../../numbl` dependency: we use its compiler internals
29+ # (parser, lowerer, IR, inline pass) and its interpreter, which its
30+ # published package `exports` do not expose. Clone it where that relative
31+ # path expects it. Pinned to the exact commit this project was built and
32+ # tested against, so CI builds exactly what was verified locally rather
33+ # than whatever numbl's main happens to be on the day it runs.
34+ #
35+ # numbl's own dependencies are NOT needed: the slice we import is
36+ # self-contained TypeScript. One file in it is generated rather than
37+ # committed, though — the interpreter's stdlib bundle, which numbl
38+ # gitignores — so a bare checkout is missing it and `executeCode.ts`
39+ # (used to evaluate scene .m files on the CPU) fails to resolve it. Its
40+ # generator only reads .m files off disk, so plain `node`
41+ # (type-stripping, unflagged since 22.18) runs it without installing
42+ # anything.
43+ - name: Check out numbl (sibling dependency)
44+ env:
45+ NUMBL_REF: 19034a6ae3df6bd62866f56856c54b8e4c53b3d9
46+ run: |
47+ git clone --filter=blob:none --no-checkout \
48+ https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
49+ git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
50+ node "$GITHUB_WORKSPACE/../../numbl/scripts/bundle-stdlib.ts"
51+ # --ignore-scripts: npm runs a linked package's `prepare` script, and
52+ # numbl's is husky, which is not installed here.
53+ - run: npm ci --ignore-scripts
54+ # The checks compile MATLAB to compute shaders, so they need a GPU;
55+ # --skip-without-gpu lets this runner (which has none) say so and move on.
56+ - run: npm run test:node -- --skip-without-gpu
57+ - run: npm run build
58+ # Pages must already be enabled with "GitHub Actions" as the source; the
59+ # workflow token cannot create the site itself (`enablement: true` fails
60+ # with "Resource not accessible by integration").
61+ - uses: actions/configure-pages@v5
62+ - uses: actions/upload-pages-artifact@v3
63+ with:
64+ path: dist
65+ - id: deployment
66+ uses: actions/deploy-pages@v4
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+141−0View file
@@ -0,0 +1,141 @@
1+# soundbox
2+
3+A small studio for instruments made of wave equations, running in the
4+browser. You compose a scene out of vibrating objects (strings, stiff bars,
5+membranes), a rigid body for them to sound over (a box with a sound hole, a
6+drum shell, or nothing), and a microphone in the air. The app writes the
7+coupled PDEs out as two MATLAB files, [numbl](https://numbl.org) compiles the
8+solver to WebGPU compute shaders on the spot, and the microphone records the
9+simulated pressure at one grid point every timestep, which you then listen
10+to at its real pitch.
11+
12+This is the next step after
13+[dulcimer](https://github.com/concept-collection/dulcimer), which simulates
14+exactly one scene: a plucked string over a box. A limitation of that project
15+is that the scene is the program; hearing a drum instead of a dulcimer means
16+rewriting the model by hand, and the interface exposes every physical
17+constant at once, which is flexible but confusing. Here we introduce a scene
18+description layer above the same compiler pipeline: the dulcimer becomes one
19+preset among several, objects can be added and combined freely, and the
20+interface is split into three altitudes so that each kind of user meets only
21+the controls they came for.
22+
23+## Three altitudes
24+
25+**Play** is presets and musical knobs. Pick a scene (dulcimer, drum, chime,
26+a duet of strings, a drum-and-chime pair), excite it, render a clip, listen,
27+save a .wav. The sliders here are the handful a player would reach for:
28+pitch, decay, brightness, where the pluck lands.
29+
30+**Build** is the studio. Add strings, bars and membranes; move each one in
31+the air; choose and shape the body; place the microphone; set the air grid.
32+Two costs are worth knowing. Pitches, decays and gains are GPU uniforms and
33+free to move. Positions and body shape re-evaluate the scene file on the CPU
34+and re-upload a few arrays, with no recompile. Only structure (objects added
35+or removed, a length changed, the grid) triggers a fresh compile.
36+
37+**Lab** is the truth. The two generated MATLAB files are shown and editable;
38+an edit compiles through exactly the same pipeline the generator feeds, so
39+the generated code is a starting point rather than an internal detail. Next
40+to the code, the compiled dispatch list, and a spectrum of the recording
41+with every object's predicted partials overlaid.
42+
43+## What is simulated
44+
45+Every object is advanced by an explicit leapfrog scheme on its own small
46+grid, all sharing the timestep the air's CFL condition fixes:
47+
48+- a **string** carries `u_tt = cs² u_xx − kap² u_xxxx − 2 sig0 u_t + 2 sig1 (u_xx)_t`
49+ (the standard stiff damped string; Bilbao, *Numerical Sound Synthesis*,
50+ ch. 7), pinned at both ends, plucked by a triangular initial displacement;
51+- a **bar** is the tension-free case of the same equation, struck with a
52+ mallet-shaped initial velocity, its partials at n²·f1;
53+- a **membrane** carries the 2D wave equation with the same two damping
54+ terms, pinned at its rim, struck like the bar, with the inharmonic mode
55+ set that makes a drum thud rather than sound a chord.
56+
57+The air carries `p_tt + 2 sig p_t = c² lapw(p, wall) + s` on a 3D grid.
58+`lapw` is a wall-masked Laplacian: each stencil face is scaled by the mask at
59+the neighbour it reads, which is the discrete sound-hard (Neumann) condition
60+in divergence form, so a body is rigid at no cost to the timestep. The
61+source `s` collects each object's contribution: its acceleration, spread
62+over a profile the scene builds around wherever the object sits (a tube
63+around a string, a sheet at a membrane's plane), plus, for strings, the pull
64+at the bridge end driving a patch of air above the plate. Because the
65+profiles carry the placement, moving an object costs a scene re-evaluation
66+rather than a recompile.
67+
68+The coupling is one-way: the air does not push back on the objects. At
69+these amplitudes the back-reaction is far below everything else the model
70+already neglects, but it does mean there is no sympathetic resonance between
71+objects, and no air loading on the membrane.
72+
73+## The generated files
74+
75+A scene description (a list of typed objects plus a body) is turned by
76+`src/studio/codegen.ts` into a model file, with an `init` and a `step` in
77+which every object keeps its own suffixed variables (`u_s1`, `cs_s1`), and a
78+scene file defining `medium(x, y, z, …)`, which paints the body's mask, the
79+absorbing boundary layer, and one coupling profile per object. The stencil
80+and coupling operations each object needs (`dxx_s1`, `lap_m1`, `rad_m1`,
81+`brg_s1`) are instantiated per object with the grid geometry baked into
82+their WGSL; everything else in the model compiles to element-wise kernels,
83+one per source line, exactly as in dulcimer. The model is compiled by
84+numbl's JIT front end and this project's WGSL backend; the scene runs once
85+through numbl's CPU interpreter, so it has the full MATLAB subset available.
86+
87+## Hearing the numerics
88+
89+The spectrum view draws two predictions per object. Dashed markers are the
90+continuous equations' partials. Solid markers are the discrete scheme's own:
91+the compiled operators have sine eigenmodes with eigenvalue
92+`lam_n = (4/h²) sin²(nπh/2L)` per axis, so mode n of a string rings at
93+`sqrt(cs² lam_n + kap² lam_n²)/2π` exactly, and likewise for bars and
94+membranes. The measured peaks land on the solid markers; the gap between
95+solid and dashed is the numerical dispersion. On a string's fine grid the
96+two nearly coincide. On a bar's deliberately coarse grid (an explicit stiff
97+scheme is only stable on a coarse grid) the upper partials land well flat of
98+n²·f1, and the difference is plainly audible. Making that error visible and
99+audible, rather than hiding it, is part of the point of the project.
100+
101+One consequence of taking this seriously is in the fourth-difference
102+operator: it uses antisymmetric ghost points, so it is exactly the square of
103+the pinned second difference and the modes are exact sines. With zero ghost
104+points instead (the obvious implementation) the ends are effectively
105+clamped, and a bar tuned to 780 Hz sounds near 1150 Hz.
106+
107+## Honest limitations
108+
109+The air grid resolves sound to roughly 4 kHz at the default size, so the top
110+of every timbre is absent. Bodies are rigid masks with a tunable absorbing
111+skin; they have no modes of their own, so a "wood" box colours the sound
112+only through its cavity and hole. The bar is pinned at its ends, whereas a
113+real glockenspiel bar is free at both, which changes the partial ratios (a
114+free-free bar's lowest modes are not n² multiples); the free boundary needs
115+a different ghost-point treatment that this project does not yet carry. A
116+membrane does not seal the cavity below it, since it enters the air only as
117+a source, so a drum here is a head sounding over an open-topped vessel. The
118+coupling gains are physically arbitrary; the equations are linear, so they
119+set balance, not absolute loudness. And each excitation is an initial
120+condition, so there is no bowing, blowing, or any other sustained drive, and
121+no event timeline yet: one excitation per recording.
122+
123+## Running it
124+
125+```
126+npm install
127+npm run dev # local dev server
128+npm test # solver checks against desktop WebGPU (Google Dawn)
129+npm run smoke # headless-browser check of the built page (environment permitting)
130+```
131+
132+numbl is a `file:../../numbl` dependency, expected as a sibling checkout two
133+directories up (the layout of the concept-collection vault).
134+
135+`npm test` compiles generated scenes to actual shaders and checks physics:
136+the string's measured pitch and decay time, the bar landing on its discrete
137+dispersion relation, the membrane's (1,1) mode, that a sealed box keeps
138+sound out and a sound hole lets the cavity speak, that both strings of a
139+duet reach the microphone, that moving an object moves its coupling profile
140+without a recompile, and that the planner's kernel splitting changes nothing
141+but the dispatch count.
index.htmladded+394−0View file
@@ -0,0 +1,394 @@
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+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 fill=%22%23f2f2f2%22/><rect x=%2212%22 y=%2254%22 width=%2250%22 height=%2228%22 rx=%224%22 fill=%22%23a07040%22/><circle cx=%2237%22 cy=%2268%22 r=%226%22 fill=%22%23f2f2f2%22/><path d=%22M12 44 Q 37 30 62 44%22 stroke=%22%23b40426%22 stroke-width=%224%22 fill=%22none%22/><circle cx=%2278%22 cy=%2266%22 r=%2214%22 stroke=%22%233b4cc0%22 stroke-width=%224%22 fill=%22none%22/><path d=%22M70 30 L86 30%22 stroke=%22%233b4cc0%22 stroke-width=%224%22/><path d=%22M78 30 L78 52%22 stroke=%22%233b4cc0%22 stroke-width=%224%22/></svg>" />
7+ <title>soundbox — build instruments out of wave equations</title>
8+ <style>
9+ :root {
10+ --bg: #ffffff;
11+ --ink: #1f2328;
12+ --ink-2: #57606a;
13+ --line: #d0d7de;
14+ --accent: #0969da;
15+ --panel-bg: #f4f6f8;
16+ --tok-com: #6e7781;
17+ --tok-str: #0a3069;
18+ --tok-num: #0550ae;
19+ --tok-kw: #cf222e;
20+ --tok-ext: #8250df;
21+ color-scheme: light dark;
22+ }
23+ @media (prefers-color-scheme: dark) {
24+ :root {
25+ --bg: #14171a;
26+ --ink: #e6e9ec;
27+ --ink-2: #9aa4af;
28+ --line: #333b44;
29+ --accent: #58a6ff;
30+ --panel-bg: #14161c;
31+ --tok-com: #8b949e;
32+ --tok-str: #a5d6ff;
33+ --tok-num: #79c0ff;
34+ --tok-kw: #ff7b72;
35+ --tok-ext: #d2a8ff;
36+ }
37+ }
38+ body {
39+ margin: 0;
40+ background: var(--bg);
41+ color: var(--ink);
42+ font: 15px/1.5 system-ui, -apple-system, sans-serif;
43+ }
44+ main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 48px; }
45+ h1 { font-size: 20px; margin: 0 0 2px; }
46+ .sub { color: var(--ink-2); margin: 0 0 10px; font-size: 13px; }
47+ .sub a { color: var(--accent); }
48+ .controls {
49+ display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
50+ padding: 5px 0;
51+ }
52+ .controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
53+ select, input[type="number"], button {
54+ font: inherit; font-size: 13px;
55+ color: var(--ink); background: var(--bg);
56+ border: 1px solid var(--line); border-radius: 6px;
57+ padding: 4px 8px;
58+ }
59+ input[type="range"] { width: 8em; vertical-align: middle; accent-color: var(--accent); }
60+ button { cursor: pointer; }
61+ button:hover { border-color: var(--accent); }
62+ button:disabled { cursor: default; opacity: 0.5; }
63+ button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; min-width: 5.5em; }
64+ .sliders {
65+ display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
66+ gap: 2px 16px; padding: 4px 0;
67+ }
68+ .slider { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }
69+ .slider > span:first-child { flex: 0 0 9.5em; text-align: right; overflow: hidden; text-overflow: ellipsis; }
70+ .slider > output { flex: 0 0 4.5em; font-variant-numeric: tabular-nums; color: var(--ink); }
71+ .group-title {
72+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;
73+ color: var(--ink-2); margin: 10px 0 0;
74+ }
75+ progress { width: 10em; height: 0.9em; }
76+
77+ /* tabs */
78+ .tabs { display: flex; gap: 2px; margin-top: 10px; border-bottom: 1px solid var(--line); }
79+ .tabs button {
80+ border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0;
81+ background: none; color: var(--ink-2); padding: 6px 14px; font-size: 14px;
82+ }
83+ .tabs button.active {
84+ border-color: var(--line); background: var(--panel-bg); color: var(--ink); font-weight: 600;
85+ }
86+ .tabpanel { display: none; padding: 8px 0 4px; }
87+ .tabpanel.active { display: block; }
88+
89+ /* preset cards */
90+ .presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; padding: 6px 0; }
91+ .preset {
92+ text-align: left; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
93+ background: var(--bg); cursor: pointer; font: inherit; color: var(--ink);
94+ }
95+ .preset b { display: block; font-size: 14px; }
96+ .preset span { font-size: 12px; color: var(--ink-2); display: block; margin-top: 2px; }
97+ .preset.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
98+
99+ /* object cards */
100+ .objcard {
101+ border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px 8px; margin-top: 8px;
102+ }
103+ .objcard-head { display: flex; align-items: center; gap: 10px; }
104+ .objcard-head b { font-size: 14px; }
105+ .objcard-head .swatch { width: 10px; height: 10px; border-radius: 5px; display: inline-block; }
106+ .objcard-head span { flex: 1; }
107+ .objcard-head button { padding: 1px 8px; font-size: 12px; }
108+
109+ #stringbox {
110+ margin-top: 12px; border: 1px solid var(--line); border-radius: 8px;
111+ background: var(--panel-bg); position: relative;
112+ }
113+ #objplot { width: 100%; height: 92px; display: block; }
114+ #objlabel {
115+ position: absolute; top: 4px; left: 8px; font-size: 11px;
116+ color: var(--ink-2); font-variant-numeric: tabular-nums;
117+ }
118+ #objpick { position: absolute; top: 2px; right: 6px; font-size: 11px; padding: 1px 4px; }
119+ #stage {
120+ display: flex; gap: 14px; margin-top: 12px; align-items: stretch;
121+ border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
122+ }
123+ .canvas-box {
124+ flex: 1; aspect-ratio: 16 / 10; max-height: 70vh; position: relative;
125+ background: #0b0e12;
126+ }
127+ #view { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
128+ #overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
129+ .colorbar {
130+ display: flex; flex-direction: column; align-items: center; justify-content: center;
131+ gap: 4px; padding: 8px 4px; background: var(--panel-bg);
132+ width: 60px; flex: none; box-sizing: border-box;
133+ }
134+ .colorbar canvas { border: 1px solid var(--line); border-radius: 2px; }
135+ .colorbar-label { font-size: 11px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
136+ .readout { font-variant-numeric: tabular-nums; color: var(--ink); }
137+ .warn { color: #b35900; font-weight: 600; }
138+ .stats { margin-top: 8px; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
139+ .stats b { color: var(--ink); font-weight: 600; }
140+ #err {
141+ color: #b35900; white-space: pre-wrap; font-size: 13px;
142+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
143+ }
144+ #specbox {
145+ margin-top: 10px; border: 1px solid var(--line); border-radius: 8px;
146+ background: var(--panel-bg); position: relative;
147+ }
148+ #spectrum { width: 100%; height: 170px; display: block; }
149+ #speclegend {
150+ position: absolute; top: 4px; right: 8px; font-size: 11px; color: var(--ink-2);
151+ display: flex; gap: 10px; flex-wrap: wrap;
152+ }
153+ #speclegend i { font-style: normal; }
154+ .editor {
155+ margin-top: 12px; border: 1px solid var(--line); border-radius: 8px;
156+ overflow: hidden;
157+ }
158+ .editor-head {
159+ display: flex; gap: 10px; align-items: center; justify-content: space-between;
160+ padding: 6px 10px; font-size: 12px; color: var(--ink-2);
161+ background: var(--panel-bg); border-bottom: 1px solid var(--line);
162+ }
163+ .editor-head button { padding: 2px 10px; font-size: 12px; }
164+ /* The fixed height lives on the row, not on either child: sizing the row
165+ makes both children stretch to one shared pixel height regardless of
166+ either's font size. */
167+ .editor-body { display: flex; align-items: stretch; height: 32em; }
168+ .editor-code { position: relative; flex: 1 1 62%; min-width: 0; }
169+ /* The overlay and the textarea must agree on every metric that affects
170+ where a character lands. Keep these two rules together. */
171+ .editor-code > pre,
172+ .editor-code > textarea {
173+ margin: 0; padding: 10px 12px; border: 0;
174+ box-sizing: border-box; width: 100%; height: 100%;
175+ font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
176+ tab-size: 2;
177+ white-space: pre; overflow-wrap: normal;
178+ }
179+ #highlight {
180+ position: absolute; inset: 0; overflow: hidden;
181+ pointer-events: none; background: var(--bg); color: var(--ink);
182+ }
183+ #source {
184+ position: relative; z-index: 1; display: block;
185+ resize: none; overflow: auto;
186+ background: transparent; color: transparent; caret-color: var(--ink);
187+ }
188+ #source:focus { outline: none; }
189+ /* Transparent text means the selection must be see-through, or selected
190+ code would be invisible. */
191+ #source::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
192+ #compiled {
193+ flex: 1 1 38%; min-width: 0; margin: 0; padding: 10px 12px; overflow: auto;
194+ border-left: 1px solid var(--line); background: var(--panel-bg);
195+ font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
196+ color: var(--ink-2); white-space: pre;
197+ }
198+ @media (max-width: 860px) {
199+ .editor-body { flex-direction: column; height: auto; }
200+ .editor-code { flex: none; height: 26em; }
201+ #compiled { border-left: 0; border-top: 1px solid var(--line); max-height: 12em; }
202+ }
203+ .tok-com { color: var(--tok-com); }
204+ .tok-str { color: var(--tok-str); }
205+ .tok-num { color: var(--tok-num); }
206+ .tok-kw { color: var(--tok-kw); font-weight: 600; }
207+ .tok-ext { color: var(--tok-ext); }
208+ </style>
209+ </head>
210+ <body>
211+ <main>
212+ <h1>soundbox</h1>
213+ <p class="sub">
214+ A small studio for instruments made of wave equations. Compose strings,
215+ bars, membranes and a resonating body into a scene; the app writes the
216+ coupled PDEs as MATLAB, <a href="https://numbl.org">numbl</a> compiles
217+ them to WebGPU compute shaders in your browser, and a microphone in the
218+ simulated air records what you then listen to. Play with a preset,
219+ build your own in the Build tab, or open the Lab to read (and edit) the
220+ equations and see the spectrum against where the partials should land.
221+ </p>
222+ <p class="sub" id="domaininfo"></p>
223+
224+ <div class="controls" id="transport">
225+ <button id="excite" class="primary">Excite</button>
226+ <button id="runpause" class="primary">Run</button>
227+ <label title="Timesteps taken between display frames while watching live. Below 1×, one step is taken every few frames.">speed
228+ <select id="spf">
229+ <option value="0.25">¼×</option>
230+ <option value="0.5">½×</option>
231+ <option value="1">1×</option>
232+ <option value="4">4×</option>
233+ <option value="16" selected>16×</option>
234+ <option value="64">64×</option>
235+ <option value="256">256×</option>
236+ </select>
237+ </label>
238+ <span style="flex:1"></span>
239+ <label>render
240+ <select id="renderdur">
241+ <option value="1">1 s</option>
242+ <option value="2" selected>2 s</option>
243+ <option value="3">3 s</option>
244+ <option value="5">5 s</option>
245+ </select>
246+ </label>
247+ <button id="renderclip" title="Excite, then run the solver flat out with no display until this much audio is recorded, then play it">Render clip</button>
248+ <progress id="renderprog" value="0" max="1" hidden></progress>
249+ <button id="listen" title="Play what the microphone has recorded since the last excite, in real time at its real pitch">Listen</button>
250+ <button id="download" title="Save the recording as a 48 kHz .wav">Save .wav</button>
251+ <span class="stats" id="recinfo"></span>
252+ </div>
253+
254+ <div class="tabs">
255+ <button id="tab-play" class="active">Play</button>
256+ <button id="tab-build">Build</button>
257+ <button id="tab-lab">Lab</button>
258+ </div>
259+
260+ <section id="panel-play" class="tabpanel active">
261+ <div class="presets" id="presets"></div>
262+ <p class="group-title">the knobs a player would reach for</p>
263+ <div class="sliders" id="musicalparams"></div>
264+ </section>
265+
266+ <section id="panel-build" class="tabpanel">
267+ <div class="controls">
268+ <span class="group-title" style="margin:0">objects</span>
269+ <button id="add-string">+ string</button>
270+ <button id="add-bar">+ bar</button>
271+ <button id="add-membrane">+ membrane</button>
272+ <span style="flex:1"></span>
273+ <label title="Air grid points along the long axis; across and up get half each. Changing it recompiles.">air grid
274+ <select id="gridsize">
275+ <option value="64">64 (draft)</option>
276+ <option value="96" selected>96</option>
277+ <option value="128">128</option>
278+ <option value="160">160</option>
279+ </select>
280+ </label>
281+ </div>
282+ <div id="objects"></div>
283+ <div class="controls" style="margin-top:10px">
284+ <span class="group-title" style="margin:0">body</span>
285+ <select id="bodytype">
286+ <option value="none">open air</option>
287+ <option value="box">box</option>
288+ <option value="shell">drum shell</option>
289+ </select>
290+ <span class="sub" id="bodyblurb" style="margin:0"></span>
291+ </div>
292+ <div class="sliders" id="bodyparams"></div>
293+ <p class="group-title">microphone</p>
294+ <div class="sliders" id="micparams"></div>
295+ </section>
296+
297+ <section id="panel-lab" class="tabpanel">
298+ <div id="specbox">
299+ <canvas id="spectrum"></canvas>
300+ <div id="speclegend"></div>
301+ </div>
302+ <p class="sub" id="specinfo" style="margin:4px 0 0">
303+ The measured spectrum of the recording (analyzed after Listen or
304+ Render clip), against each object's predicted partials: solid marks
305+ are the discrete scheme's own modes, dashed the continuous
306+ equations'. The gap between them is the numerical dispersion — on a
307+ coarse bar grid you can hear it.
308+ </p>
309+ <div class="editor">
310+ <div class="editor-head">
311+ <span>
312+ <select id="editor-file" aria-label="file to edit">
313+ <option value="model">model (.m) — the solvers, compiled to WebGPU</option>
314+ <option value="scene">scene (.m) — bodies and coupling, CPU-evaluated</option>
315+ </select>
316+ <span id="editor-title"></span>
317+ </span>
318+ <span>
319+ <button id="recompile" type="button">Run edits</button>
320+ <button id="revert" type="button" title="Throw away hand edits and regenerate this file from the scene the Build tab describes">Regenerate</button>
321+ </span>
322+ </div>
323+ <div class="editor-body">
324+ <div class="editor-code">
325+ <pre id="highlight" aria-hidden="true"></pre>
326+ <textarea
327+ id="source"
328+ spellcheck="false"
329+ autocomplete="off"
330+ autocapitalize="off"
331+ aria-label="source (MATLAB)"
332+ ></textarea>
333+ </div>
334+ <pre id="compiled"></pre>
335+ </div>
336+ </div>
337+ </section>
338+
339+ <div class="controls">
340+ <label>colormap
341+ <select id="colormap"></select>
342+ </label>
343+ <label title="Pressure the colour scale saturates at. Auto follows the field.">scale
344+ <select id="scalemode">
345+ <option value="auto" selected>auto</option>
346+ <option value="fixed">hold</option>
347+ </select>
348+ </label>
349+ <label>opacity
350+ <input type="range" id="opacity" min="0.2" max="6" step="0.1" value="2" />
351+ </label>
352+ <label>contrast
353+ <input type="range" id="contrast" min="0.5" max="3" step="0.1" value="1.6" />
354+ </label>
355+ <label title="Ray-march samples per pixel ray">quality
356+ <select id="quality">
357+ <option value="96">96</option>
358+ <option value="192" selected>192</option>
359+ <option value="320">320</option>
360+ </select>
361+ </label>
362+ <label title="Cut the picture open: everything on the far side of this y-plane is hidden">clip
363+ <input type="range" id="clipy" min="-0.5" max="0.5" step="0.01" value="0.5" />
364+ </label>
365+ <label title="How many times larger than life displacements are drawn in the 3D view">motion ×
366+ <input type="range" id="exaggerate" min="1" max="100" step="1" value="25" />
367+ </label>
368+ <label><input type="checkbox" id="showfield" checked /> sound</label>
369+ <label><input type="checkbox" id="showbody" checked /> body</label>
370+ <label><input type="checkbox" id="showobjects" checked /> objects</label>
371+ <label><input type="checkbox" id="showwire" checked /> outline</label>
372+ <label><input type="checkbox" id="showmic" checked /> mic</label>
373+ </div>
374+
375+ <div id="stage">
376+ <div class="canvas-box">
377+ <canvas id="view"></canvas>
378+ <canvas id="overlay"></canvas>
379+ </div>
380+ <div id="colorbar"></div>
381+ </div>
382+
383+ <div id="stringbox">
384+ <canvas id="objplot"></canvas>
385+ <span id="objlabel"></span>
386+ <select id="objpick"></select>
387+ </div>
388+
389+ <p class="stats" id="stats"></p>
390+ <p id="err"></p>
391+ </main>
392+ <script type="module" src="/src/main.ts"></script>
393+ </body>
394+</html>
package-lock.jsonadded+3035−0View file
This diff is 3,040 lines long and is not shown.
package.jsonadded+31−0View file
@@ -0,0 +1,31 @@
1+{
2+ "name": "soundbox",
3+ "version": "0.1.0",
4+ "description": "A small studio for building simulated instruments: compose strings, bars, membranes and a resonating body into a scene, generate the coupled wave equations as MATLAB, compile them to WebGPU, and listen",
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+ "test:node": "vite-node scripts/test-node.ts",
14+ "smoke": "vite build && node scripts/smoke.mjs",
15+ "test": "npm run test:node"
16+ },
17+ "dependencies": {
18+ "numbl": "file:../../numbl"
19+ },
20+ "optionalDependencies": {
21+ "webgpu": "^0.4.0"
22+ },
23+ "devDependencies": {
24+ "@types/node": "^26.1.1",
25+ "@webgpu/types": "^0.1.44",
26+ "puppeteer-core": "^23.11.1",
27+ "typescript": "^5.5.0",
28+ "vite": "^5.4.0",
29+ "vite-node": "^6.0.0"
30+ }
31+}
scripts/nodeWebGpu.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+/**
2+ * Desktop WebGPU for the command-line scripts, via the optional `webgpu`
3+ * package (prebuilt Google Dawn).
4+ *
5+ * Installs Dawn under the globals the transform code expects (navigator.gpu,
6+ * GPUBufferUsage, ...) so everything under src/ runs here unchanged —
7+ * including requestShtDevice(), which makes the same device request the
8+ * browser makes.
9+ */
10+
11+export const errMsg = (e: unknown): string =>
12+ e instanceof Error ? e.message : String(e);
13+
14+/**
15+ * Returns a human-readable runtime description. The import specifier is
16+ * indirect so typechecking does not require the optional package.
17+ */
18+export async function installWebGpu(): Promise<string> {
19+ const specifier = 'webgpu';
20+ let mod: {
21+ create: (flags: string[]) => GPU;
22+ globals: Record<string, unknown>;
23+ };
24+ try {
25+ mod = await import(specifier);
26+ } catch (e) {
27+ // Distinguish "not installed" from "installed but the prebuilt Dawn binary
28+ // will not load" — the second is what a machine missing a system library
29+ // looks like, and reporting it as the first sends people in circles.
30+ const detail = errMsg(e);
31+ if (/Cannot find (package|module) '?webgpu'?/.test(detail)) {
32+ throw new Error(
33+ 'desktop WebGPU needs the optional `webgpu` package (prebuilt Google Dawn):\n' +
34+ ' npm install webgpu\n' +
35+ 'It is an optionalDependency, so npm can skip it silently — `npm ls webgpu`\n' +
36+ 'says whether it is there.',
37+ );
38+ }
39+ const glibc = /GLIBC_([0-9.]+)/.exec(detail);
40+ throw new Error(
41+ `the \`webgpu\` package is installed but did not load:\n ${detail}\n` +
42+ (glibc
43+ ? `Dawn's prebuilt binary wants glibc ${glibc[1]} or newer and this host is older\n` +
44+ '(`ldd --version` says how old). No flag bridges that — use a container with a\n' +
45+ 'newer base image, or a newer host.\n'
46+ : 'That is usually the prebuilt Dawn binary missing a system library.\n'),
47+ );
48+ }
49+ Object.assign(globalThis, mod.globals);
50+ // DAWN_FLAGS is ';'-separated because individual Dawn options take
51+ // comma-separated lists, e.g. 'enable-dawn-features=allow_unsafe_apis,...'
52+ const dawnFlags = process.env.DAWN_FLAGS?.split(';').filter(Boolean) ?? [];
53+ Object.defineProperty(globalThis, 'navigator', {
54+ value: { gpu: mod.create(dawnFlags) },
55+ configurable: true,
56+ writable: true,
57+ });
58+ const { version } = await import(`${specifier}/package.json`, {
59+ with: { type: 'json' },
60+ }).then(
61+ (m) => m.default as { version: string },
62+ () => ({ version: '?' }),
63+ );
64+ return `node-webgpu ${version} (Google Dawn)`;
65+}
66+
67+/** The hint to print when Dawn loads but finds no adapter. */
68+export const NO_ADAPTER_HINT =
69+ ' Dawn reaches the GPU through Vulkan on Linux and Windows, Metal on macOS,\n' +
70+ " so a headless box may have no adapter at all. DAWN_FLAGS='backend=vulkan'\n" +
71+ ' makes it explain itself.';
scripts/smoke.mjsadded+182−0View file
@@ -0,0 +1,182 @@
1+/**
2+ * Does the page actually run in a browser?
3+ *
4+ * Serves dist/ and opens it in headless Chrome (hardware WebGPU if there is
5+ * any, SwiftShader otherwise), then waits for the compile to finish, starts
6+ * the run, and checks that steps are being taken, the microphone is filling,
7+ * a body edit survives, and switching to another preset recompiles cleanly.
8+ * Any console error, page error or failed request fails the run.
9+ *
10+ * This checks that the app *works*, not that it looks right — a headless
11+ * browser has no opinion about whether the drum is in the right place. The
12+ * solver physics is covered by `npm run test:node` against desktop WebGPU.
13+ * Run it after `vite build`: node scripts/smoke.mjs
14+ */
15+import { createServer } from 'node:http';
16+import { readFile } from 'node:fs/promises';
17+import { extname, join } from 'node:path';
18+import puppeteer from 'puppeteer-core';
19+
20+const DIST = new URL('../dist/', import.meta.url).pathname;
21+const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome';
22+const MIME = {
23+ '.html': 'text/html',
24+ '.js': 'text/javascript',
25+ '.css': 'text/css',
26+ '.json': 'application/json',
27+};
28+
29+const server = createServer(async (req, res) => {
30+ try {
31+ const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
32+ const data = await readFile(join(DIST, path));
33+ res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
34+ res.end(data);
35+ } catch {
36+ res.writeHead(404);
37+ res.end('not found');
38+ }
39+});
40+await new Promise((r) => server.listen(0, '127.0.0.1', r));
41+const port = server.address().port;
42+
43+const flagSets = [
44+ ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
45+ [
46+ '--headless=new',
47+ '--no-sandbox',
48+ '--enable-unsafe-webgpu',
49+ '--use-webgpu-adapter=swiftshader',
50+ '--enable-unsafe-swiftshader',
51+ ],
52+];
53+
54+let ok = false;
55+let lastFailure = 'never ran';
56+for (const flags of flagSets) {
57+ const browser = await puppeteer.launch({
58+ executablePath: CHROME,
59+ args: [...flags],
60+ protocolTimeout: 600_000,
61+ });
62+ const problems = [];
63+ try {
64+ const page = await browser.newPage();
65+ page.on('console', (m) => {
66+ if (m.type() === 'error') problems.push(`console: ${m.text()}`);
67+ });
68+ page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
69+ page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
70+
71+ await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' });
72+
73+ // The page loads paused. Wait for the first compile's op list.
74+ await page.waitForFunction(
75+ () => (document.getElementById('compiled')?.textContent ?? '').includes('external'),
76+ { timeout: 180_000, polling: 500 },
77+ );
78+ const errText = () => page.$eval('#err', (n) => n.textContent ?? '');
79+ if (await errText()) problems.push(`compile reported: ${await errText()}`);
80+
81+ // The default (dulcimer) scene's plan must carry its coupling ops.
82+ const compiled = await page.$eval('#compiled', (n) => n.textContent ?? '');
83+ for (const op of ['lapw(p, wall)', 'rad_s1(acc_s1)', 'brg_s1(un_s1)', 'dxxxx_s1(u_s1)']) {
84+ if (!compiled.includes(op)) problems.push(`compiled plan is missing ${op}`);
85+ }
86+
87+ // Drop to the draft grid so a software rasterizer can take steps at all.
88+ await page.click('#tab-build');
89+ await page.select('#gridsize', '64');
90+ await page.waitForFunction(
91+ () => /64×32×32/.test(document.getElementById('domaininfo')?.textContent ?? ''),
92+ { timeout: 180_000, polling: 250 },
93+ );
94+
95+ // A paused page reports no frame rate.
96+ if (/ms\/frame/.test(await page.$eval('#stats', (n) => n.textContent ?? ''))) {
97+ problems.push('a paused page reported a frame rate');
98+ }
99+ await page.click('#runpause');
100+
101+ /** The stats line reports the step count, so waiting on it says both that
102+ * the solver ran and that the frame loop is turning. */
103+ const running = async (label) => {
104+ await page.waitForFunction(
105+ () => {
106+ const m = /step ([\d,]+)/.exec(document.getElementById('stats')?.textContent ?? '');
107+ return m ? Number(m[1].replace(/,/g, '')) > 50 : false;
108+ },
109+ { timeout: 180_000, polling: 500 },
110+ );
111+ console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`);
112+ };
113+ await running('start');
114+
115+ // The microphone must be filling, one sample per timestep.
116+ await page.waitForFunction(
117+ () => /recorded/.test(document.getElementById('recinfo')?.textContent ?? ''),
118+ { timeout: 60_000, polling: 250 },
119+ );
120+ console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`);
121+
122+ // A body edit re-evaluates the scene .m without recompiling: shrinking
123+ // the sound hole to nothing must not error and must keep stepping.
124+ await page.evaluate(() => {
125+ for (const row of document.querySelectorAll('#bodyparams label.slider')) {
126+ if (!row.querySelector('span')?.textContent?.startsWith('sound hole radius')) continue;
127+ const input = row.querySelector('input');
128+ input.value = '0';
129+ input.dispatchEvent(new Event('input'));
130+ }
131+ });
132+ await new Promise((r) => setTimeout(r, 1500));
133+ if (await errText()) problems.push(`scene edit reported: ${await errText()}`);
134+ await running('after body edit');
135+
136+ // Switching preset rebuilds a different scene (a struck bar, no body).
137+ await page.click('#tab-play');
138+ await page.evaluate(() => {
139+ for (const card of document.querySelectorAll('#presets .preset')) {
140+ if (card.querySelector('b')?.textContent === 'Chime') card.click();
141+ }
142+ });
143+ await page.waitForFunction(
144+ () => (document.getElementById('compiled')?.textContent ?? '').includes('dxxxx_b1'),
145+ { timeout: 180_000, polling: 500 },
146+ );
147+ if (await errText()) problems.push(`preset switch reported: ${await errText()}`);
148+ await running('after preset switch');
149+
150+ const report = await page.evaluate(() => ({
151+ stats: document.getElementById('stats')?.textContent ?? '',
152+ err: document.getElementById('err')?.textContent ?? '',
153+ painted: (() => {
154+ const canvas = document.getElementById('view');
155+ return canvas instanceof HTMLCanvasElement && canvas.width > 0;
156+ })(),
157+ }));
158+
159+ console.log(`flags: ${flags.join(' ')}`);
160+ console.log(report.stats.trim());
161+ if (report.err) problems.push(`page error box: ${report.err}`);
162+ if (!report.painted) problems.push('canvas was never sized');
163+ if (problems.length === 0) {
164+ ok = true;
165+ } else {
166+ lastFailure = problems.join('\n');
167+ }
168+ } catch (e) {
169+ lastFailure = [`${e}`, ...problems].join('\n');
170+ } finally {
171+ await browser.close();
172+ }
173+ if (ok) break;
174+}
175+
176+server.close();
177+if (!ok) {
178+ console.error(`smoke: FAILED\n${lastFailure}`);
179+ process.exit(1);
180+}
181+console.log('smoke: the page runs');
182+process.exit(0);
scripts/test-node.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+/**
2+ * The suite on desktop WebGPU (Google Dawn), against the real pipeline:
3+ * SceneSpec -> generated MATLAB -> numbl lowering -> generated WGSL -> GPU.
4+ *
5+ * Run through vite-node, which is what resolves numbl's compiler sources and
6+ * the `?raw` .m imports:
7+ *
8+ * npm run test:node
9+ */
10+import { installWebGpu, errMsg, NO_ADAPTER_HINT } from './nodeWebGpu.ts';
11+import { requestAcousticDevice } from '../src/device.ts';
12+import {
13+ planChecks,
14+ codegenChecks,
15+ stringChecks,
16+ barChecks,
17+ membraneChecks,
18+ wallChecks,
19+ duetChecks,
20+ placementChecks,
21+ spectrumChecks,
22+ splitChecks,
23+} from '../test/checks.ts';
24+
25+let failures = 0;
26+const check = (name: string, ok: boolean, detail: string): void => {
27+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — ${detail}`);
28+ if (!ok) failures++;
29+};
30+const log = (s: string): void => console.log(s);
31+
32+/**
33+ * These checks compile MATLAB to compute shaders, so they need a GPU.
34+ * `--skip-without-gpu` lets a runner that has none say so and move on; a plain
35+ * local run still fails loudly, so a missing GPU is never mistaken for a pass.
36+ */
37+const skipWithoutGpu = process.argv.includes('--skip-without-gpu');
38+
39+let runtime: string;
40+let device: GPUDevice;
41+try {
42+ runtime = await installWebGpu();
43+ device = await requestAcousticDevice();
44+} catch (e) {
45+ const detail = `${errMsg(e)}\n${NO_ADAPTER_HINT}`;
46+ if (skipWithoutGpu) {
47+ console.log(`SKIP no WebGPU available here, so these checks did not run.\n${detail}`);
48+ process.exit(0);
49+ }
50+ console.error(`test-node: ${detail}`);
51+ process.exit(1);
52+}
53+console.log(`soundbox tests — ${runtime}\n`);
54+
55+await planChecks(device, check, log);
56+await codegenChecks(device, check, log);
57+await stringChecks(device, check, log);
58+await barChecks(device, check, log);
59+await membraneChecks(device, check, log);
60+await wallChecks(device, check, log);
61+await duetChecks(device, check, log);
62+await placementChecks(device, check, log);
63+await spectrumChecks(device, check, log);
64+await splitChecks(device, check, log);
65+
66+console.log(failures ? `\n${failures} failure(s)` : '\nall checks passed');
67+process.exit(failures ? 1 : 0);
src/audio/play.tsadded+98−0View file
@@ -0,0 +1,98 @@
1+/**
2+ * Turning a recorded trace into something you can hear.
3+ *
4+ * Because the solver is in real SI units, `dt` is a real duration in seconds
5+ * — so the natural playback rate is just 1/dt, sample for sample. Nothing is
6+ * reinterpreted: the trace plays back at the same real-time pace, and the
7+ * same pitch, that a microphone sitting at the probe point would have heard.
8+ * This is more than a convenience. A Courant-limited timestep on a grid fine
9+ * enough to resolve audible frequencies lands, by construction, in the same
10+ * range as an audio sample rate — the app's default settings give dt around
11+ * 20 microseconds, a rate near 48 kHz, which is not a coincidence: both are
12+ * set by "resolve a few centimetres of wave at audio frequency."
13+ *
14+ * `dt` can still land outside what a browser's AudioContext will accept, at
15+ * an unusual grid or CFL setting, so the rate is clamped and the caller is
16+ * told when that happened — the trace then plays sped up or slowed down
17+ * rather than at its real pace, which is worth knowing rather than hiding.
18+ *
19+ * Nothing here is a physical claim about loudness. The trace is normalized so
20+ * that whatever was recorded is audible, which discards exactly the quantity
21+ * (absolute amplitude) that the colour scale already shows.
22+ */
23+
24+/** What a browser will accept as an AudioBuffer sample rate. The spec's range
25+ * is wider than any of this needs; these bounds keep the derived rate inside
26+ * what every implementation supports. */
27+const MIN_RATE = 8000;
28+const MAX_RATE = 192000;
29+
30+export interface PlaybackPlan {
31+ /** Samples per second the trace is played back at. */
32+ rate: number;
33+ /** Seconds of audio. */
34+ duration: number;
35+ /** True if `rate` is the real 1/dt — false if it had to be clamped, in
36+ * which case playback runs faster or slower than the simulation did. */
37+ realTime: boolean;
38+}
39+
40+/** How a recorded trace of `samples` taken at timestep `dt` would be played. */
41+export function planPlayback(samples: number, dt: number): PlaybackPlan {
42+ const wanted = 1 / Math.max(dt, 1e-12);
43+ const rate = Math.min(MAX_RATE, Math.max(MIN_RATE, wanted));
44+ return { rate, duration: samples / rate, realTime: rate === wanted };
45+}
46+
47+let context: AudioContext | null = null;
48+let playing: AudioBufferSourceNode | null = null;
49+
50+/**
51+ * Play a recorded trace. Returns what was actually played, so the caller can
52+ * report it.
53+ *
54+ * Normalized to peak amplitude, and with a few milliseconds of fade at each
55+ * end: a trace that starts or ends away from zero is a step, and a step is a
56+ * click that has nothing to do with the simulation.
57+ */
58+export async function playTrace(
59+ trace: Float32Array,
60+ plan: PlaybackPlan,
61+): Promise<PlaybackPlan> {
62+ if (trace.length === 0) throw new Error('nothing has been recorded yet');
63+ context ??= new AudioContext();
64+ if (context.state === 'suspended') await context.resume();
65+
66+ const buffer = context.createBuffer(1, trace.length, plan.rate);
67+ const channel = buffer.getChannelData(0);
68+ let peak = 0;
69+ for (const v of trace) peak = Math.max(peak, Math.abs(v));
70+ const gain = peak > 0 ? 0.9 / peak : 0;
71+ for (let i = 0; i < trace.length; i++) channel[i] = trace[i] * gain;
72+
73+ const fade = Math.min(Math.round(0.005 * plan.rate), Math.floor(trace.length / 2));
74+ for (let i = 0; i < fade; i++) {
75+ const w = i / fade;
76+ channel[i] *= w;
77+ channel[trace.length - 1 - i] *= w;
78+ }
79+
80+ stop();
81+ const source = context.createBufferSource();
82+ source.buffer = buffer;
83+ source.connect(context.destination);
84+ source.onended = () => {
85+ if (playing === source) playing = null;
86+ };
87+ source.start();
88+ playing = source;
89+ return plan;
90+}
91+
92+export function stop(): void {
93+ if (!playing) return;
94+ playing.stop();
95+ playing = null;
96+}
97+
98+export const isPlaying = (): boolean => playing !== null;
src/audio/recorder.tsadded+231−0View file
@@ -0,0 +1,231 @@
1+/**
2+ * A microphone: the pressure at one grid point, sampled every timestep.
3+ *
4+ * The obvious implementation — read the field back and pick out one number —
5+ * costs a GPU-to-CPU round trip per step, which is more than the step itself.
6+ * So the trace is written on the GPU instead, by a one-thread dispatch that
7+ * runs after each step and appends `p` at the probe point to a buffer. The
8+ * whole trace comes back to the CPU once, when there is something to listen
9+ * to.
10+ *
11+ * One sample per timestep is the natural rate: it is every value the
12+ * simulation has, and nothing is being resampled or interpolated on the way
13+ * in. What that means in seconds is decided at playback (src/audio/play.ts),
14+ * because the simulation has no seconds in it — only model time.
15+ */
16+
17+/** Samples the trace holds: about 7 seconds of audio at the default
18+ * timestep, and 4 MB of GPU memory. Recording stops when it is full rather
19+ * than wrapping, so what you hear always starts where the pluck did. */
20+export const TRACE_CAPACITY = 1 << 20;
21+
22+const SHADER = `
23+struct Probe {
24+ index: u32,
25+ capacity: u32,
26+};
27+
28+@group(0) @binding(0) var<storage, read_write> trace: array<f32>;
29+@group(0) @binding(1) var<storage, read_write> head: array<u32>;
30+@group(0) @binding(2) var<storage, read> field: array<f32>;
31+@group(0) @binding(3) var<uniform> probe: Probe;
32+
33+// One invocation, so the read-modify-write of the head needs no atomic.
34+@compute @workgroup_size(1)
35+fn main() {
36+ let i = head[0];
37+ if (i < probe.capacity) {
38+ trace[i] = field[probe.index];
39+ head[0] = i + 1u;
40+ }
41+}
42+`;
43+
44+export interface RecorderOptions {
45+ device: GPUDevice;
46+ /** The pressure buffer to sample — the host-owned one, which is what both
47+ * `init` and `step` leave their result in. */
48+ field: GPUBuffer;
49+ nx: number;
50+ ny: number;
51+ nz: number;
52+}
53+
54+export class Recorder {
55+ readonly capacity = TRACE_CAPACITY;
56+
57+ #device: GPUDevice;
58+ #pipeline: GPUComputePipeline | null = null;
59+ #layout: GPUBindGroupLayout;
60+ #bindGroup: GPUBindGroup | null = null;
61+ #trace: GPUBuffer;
62+ #head: GPUBuffer;
63+ #probe: GPUBuffer;
64+ #readback: GPUBuffer;
65+ #field: GPUBuffer;
66+ #nx: number;
67+ #ny: number;
68+ #nz: number;
69+ /** Samples written since the last clear, as far as the host knows. Counted
70+ * here rather than read back from the GPU: the dispatch runs once per step
71+ * and the host knows exactly how many steps it asked for. */
72+ #count = 0;
73+ #reading = false;
74+
75+ private constructor(init: {
76+ device: GPUDevice;
77+ layout: GPUBindGroupLayout;
78+ trace: GPUBuffer;
79+ head: GPUBuffer;
80+ probe: GPUBuffer;
81+ readback: GPUBuffer;
82+ field: GPUBuffer;
83+ nx: number;
84+ ny: number;
85+ nz: number;
86+ }) {
87+ this.#device = init.device;
88+ this.#layout = init.layout;
89+ this.#trace = init.trace;
90+ this.#head = init.head;
91+ this.#probe = init.probe;
92+ this.#readback = init.readback;
93+ this.#field = init.field;
94+ this.#nx = init.nx;
95+ this.#ny = init.ny;
96+ this.#nz = init.nz;
97+ }
98+
99+ static async create(opts: RecorderOptions): Promise<Recorder> {
100+ const { device } = opts;
101+ const layout = device.createBindGroupLayout({
102+ label: 'recorder',
103+ entries: [
104+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
105+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
106+ {
107+ binding: 2,
108+ visibility: GPUShaderStage.COMPUTE,
109+ buffer: { type: 'read-only-storage' },
110+ },
111+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
112+ ],
113+ });
114+ const trace = device.createBuffer({
115+ label: 'recorder-trace',
116+ size: 4 * TRACE_CAPACITY,
117+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
118+ });
119+ const head = device.createBuffer({
120+ label: 'recorder-head',
121+ size: 4,
122+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
123+ });
124+ const probe = device.createBuffer({
125+ label: 'recorder-probe',
126+ size: 8,
127+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
128+ });
129+ const readback = device.createBuffer({
130+ label: 'recorder-readback',
131+ size: 4 * TRACE_CAPACITY,
132+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
133+ });
134+
135+ const rec = new Recorder({
136+ device, layout, trace, head, probe, readback,
137+ field: opts.field, nx: opts.nx, ny: opts.ny, nz: opts.nz,
138+ });
139+ rec.#pipeline = await device.createComputePipelineAsync({
140+ label: 'recorder',
141+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
142+ compute: {
143+ module: device.createShaderModule({ code: SHADER, label: 'recorder' }),
144+ entryPoint: 'main',
145+ },
146+ });
147+ rec.#bindGroup = device.createBindGroup({
148+ layout,
149+ entries: [
150+ { binding: 0, resource: { buffer: trace } },
151+ { binding: 1, resource: { buffer: head } },
152+ { binding: 2, resource: { buffer: opts.field } },
153+ { binding: 3, resource: { buffer: probe } },
154+ ],
155+ });
156+ rec.clear();
157+ return rec;
158+ }
159+
160+ /** Samples recorded so far. Stops rising once the trace is full. */
161+ get count(): number {
162+ return Math.min(this.#count, this.capacity);
163+ }
164+
165+ get full(): boolean {
166+ return this.#count >= this.capacity;
167+ }
168+
169+ /** Put the microphone at the grid point nearest (ix, iy, iz). Free: the
170+ * probe index is a uniform, so moving it disturbs neither the run nor the
171+ * recording already made. */
172+ setProbe(ix: number, iy: number, iz: number): void {
173+ const cx = Math.max(0, Math.min(this.#nx - 1, Math.round(ix)));
174+ const cy = Math.max(0, Math.min(this.#ny - 1, Math.round(iy)));
175+ const cz = Math.max(0, Math.min(this.#nz - 1, Math.round(iz)));
176+ this.#device.queue.writeBuffer(
177+ this.#probe,
178+ 0,
179+ new Uint32Array([cx + this.#nx * (cy + this.#ny * cz), this.capacity]),
180+ );
181+ }
182+
183+ /** Start again from an empty trace. */
184+ clear(): void {
185+ this.#count = 0;
186+ this.#device.queue.writeBuffer(this.#head, 0, new Uint32Array([0]));
187+ }
188+
189+ /** Record one sample. Called once per timestep, inside the step's own
190+ * submission, so no extra work crosses to the host. */
191+ encode(encoder: GPUCommandEncoder): void {
192+ if (!this.#pipeline || !this.#bindGroup || this.full) return;
193+ const pass = encoder.beginComputePass({ label: 'recorder' });
194+ pass.setPipeline(this.#pipeline);
195+ pass.setBindGroup(0, this.#bindGroup);
196+ pass.dispatchWorkgroups(1);
197+ pass.end();
198+ this.#count++;
199+ }
200+
201+ /** The recorded trace. The only readback the microphone ever does. */
202+ async read(): Promise<Float32Array> {
203+ const n = this.count;
204+ if (n === 0) return new Float32Array(0);
205+ if (this.#reading) throw new Error('a trace readback is already in flight');
206+ this.#reading = true;
207+ try {
208+ const enc = this.#device.createCommandEncoder({ label: 'recorder-read' });
209+ enc.copyBufferToBuffer(this.#trace, 0, this.#readback, 0, 4 * n);
210+ this.#device.queue.submit([enc.finish()]);
211+ await this.#readback.mapAsync(GPUMapMode.READ, 0, 4 * n);
212+ const out = new Float32Array(this.#readback.getMappedRange(0, 4 * n).slice(0));
213+ this.#readback.unmap();
214+ return out;
215+ } finally {
216+ this.#reading = false;
217+ }
218+ }
219+
220+ /** The pressure buffer this microphone listens to. */
221+ get field(): GPUBuffer {
222+ return this.#field;
223+ }
224+
225+ destroy(): void {
226+ this.#trace.destroy();
227+ this.#head.destroy();
228+ this.#probe.destroy();
229+ this.#readback.destroy();
230+ }
231+}
src/audio/wav.tsadded+59−0View file
@@ -0,0 +1,59 @@
1+/**
2+ * A recorded trace as a .wav file.
3+ *
4+ * The trace holds one sample per timestep, 1/dt of them a second — around
5+ * 150 kHz at the default grid, which is a legal but eccentric rate for a
6+ * .wav. It is resampled to 48 kHz by linear interpolation, which is harmless
7+ * here: the content is band-limited far below either rate (the grid stops
8+ * resolving sound around 5 kHz). Normalized to peak, like playback, and
9+ * faded a few milliseconds at each end so the file does not open and close
10+ * with a click.
11+ */
12+
13+export const WAV_RATE = 48000;
14+
15+export function traceToWav(trace: Float32Array, dt: number): Blob {
16+ const n = Math.max(1, Math.round(trace.length * dt * WAV_RATE));
17+ let peak = 0;
18+ for (const v of trace) peak = Math.max(peak, Math.abs(v));
19+ const gain = peak > 0 ? 0.9 / peak : 0;
20+
21+ const samples = new Float32Array(n);
22+ for (let i = 0; i < n; i++) {
23+ const s = i / (WAV_RATE * dt);
24+ const k = Math.min(trace.length - 2, Math.floor(s));
25+ const f = Math.min(1, s - k);
26+ samples[i] = gain * ((1 - f) * trace[k] + f * trace[k + 1]);
27+ }
28+ const fade = Math.min(Math.round(0.005 * WAV_RATE), Math.floor(n / 2));
29+ for (let i = 0; i < fade; i++) {
30+ const w = i / fade;
31+ samples[i] *= w;
32+ samples[n - 1 - i] *= w;
33+ }
34+
35+ const bytes = 44 + 2 * n;
36+ const buf = new ArrayBuffer(bytes);
37+ const view = new DataView(buf);
38+ const str = (off: number, s: string): void => {
39+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
40+ };
41+ str(0, 'RIFF');
42+ view.setUint32(4, bytes - 8, true);
43+ str(8, 'WAVE');
44+ str(12, 'fmt ');
45+ view.setUint32(16, 16, true); // PCM chunk size
46+ view.setUint16(20, 1, true); // PCM
47+ view.setUint16(22, 1, true); // mono
48+ view.setUint32(24, WAV_RATE, true);
49+ view.setUint32(28, 2 * WAV_RATE, true); // byte rate
50+ view.setUint16(32, 2, true); // block align
51+ view.setUint16(34, 16, true); // bits per sample
52+ str(36, 'data');
53+ view.setUint32(40, 2 * n, true);
54+ for (let i = 0; i < n; i++) {
55+ const v = Math.max(-1, Math.min(1, samples[i]));
56+ view.setInt16(44 + 2 * i, Math.round(v * 32767), true);
57+ }
58+ return new Blob([buf], { type: 'audio/wav' });
59+}
src/device.tsadded+45−0View file
@@ -0,0 +1,45 @@
1+/**
2+ * The GPU device, requested the same way everywhere (app, tests, scripts).
3+ *
4+ * One limit matters here. A fused kernel binds one storage buffer per distinct
5+ * field its line reads, plus its output and the parameter block, and the air
6+ * update reads a lot of fields at once — the two pressure histories, the
7+ * medium, the wall mask, the Laplacian, the coupling source. WebGPU only
8+ * guarantees 8 storage buffers per compute stage, so we ask for whatever the
9+ * adapter will give up to 16. When that is not enough the planner splits the
10+ * kernel instead of failing (see `fitToBudget` in plan.ts), so this is a
11+ * performance request rather than a requirement.
12+ */
13+export const MAX_STORAGE_BUFFERS = 16;
14+
15+/** The adapter the device came from, kept so its limits and info stay
16+ * available for as long as the device is in use. */
17+let heldAdapter: GPUAdapter | null = null;
18+
19+export async function requestAcousticDevice(): Promise<GPUDevice> {
20+ if (!navigator.gpu) {
21+ throw new Error(
22+ 'this browser has no WebGPU. Chrome and Edge 113+, Safari 26+, and ' +
23+ 'Firefox 141+ on Windows have it; on Linux Firefox and Chrome may need ' +
24+ 'it enabled explicitly.',
25+ );
26+ }
27+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
28+ if (!adapter) throw new Error('WebGPU found no adapter on this machine.');
29+ heldAdapter = adapter;
30+ const want = Math.min(
31+ MAX_STORAGE_BUFFERS,
32+ adapter.limits.maxStorageBuffersPerShaderStage ?? 8,
33+ );
34+ return adapter.requestDevice({
35+ requiredLimits: { maxStorageBuffersPerShaderStage: want },
36+ });
37+}
38+
39+/** The adapter the current device came from, if there is one. */
40+export const currentAdapter = (): GPUAdapter | null => heldAdapter;
41+
42+/** Grid fields one kernel may read, given the device's binding limit: every
43+ * binding but the output and the parameter block. */
44+export const kernelOperandBudget = (device: GPUDevice): number =>
45+ Math.max(2, (device.limits.maxStorageBuffersPerShaderStage ?? 8) - 2);
src/editor/codeEditor.tsadded+92−0View file
@@ -0,0 +1,92 @@
1+/**
2+ * A textarea with syntax highlighting, by overlay.
3+ *
4+ * A textarea cannot colour its own text, so the highlighted source is rendered
5+ * into a <pre> underneath and the textarea sits on top with transparent text and
6+ * a visible caret. The two must agree on every metric that affects layout —
7+ * font, line height, padding, tab size, wrapping — and their scroll offsets are
8+ * kept in sync, or the colours drift away from the characters.
9+ */
10+import { highlightMatlab } from './matlab.ts';
11+
12+export interface CodeEditorOptions {
13+ textarea: HTMLTextAreaElement;
14+ /** The <pre> behind it, holding the highlighted copy. */
15+ overlay: HTMLElement;
16+ /** Names to mark as host-provided operations. */
17+ external?: ReadonlySet<string>;
18+ /** Called on every edit. */
19+ onInput?: (value: string) => void;
20+}
21+
22+export class CodeEditor {
23+ #textarea: HTMLTextAreaElement;
24+ #overlay: HTMLElement;
25+ #external: ReadonlySet<string>;
26+
27+ constructor(opts: CodeEditorOptions) {
28+ this.#textarea = opts.textarea;
29+ this.#overlay = opts.overlay;
30+ this.#external = opts.external ?? new Set();
31+
32+ this.#textarea.addEventListener('input', () => {
33+ this.#repaint();
34+ opts.onInput?.(this.#textarea.value);
35+ });
36+ // Keep the colours under the characters while scrolling.
37+ this.#textarea.addEventListener('scroll', () => this.#syncScroll());
38+ // Tab should indent rather than leave the editor.
39+ this.#textarea.addEventListener('keydown', (e) => this.#onKeyDown(e));
40+ this.#repaint();
41+ }
42+
43+ get value(): string {
44+ return this.#textarea.value;
45+ }
46+
47+ set value(next: string) {
48+ this.#textarea.value = next;
49+ this.#repaint();
50+ }
51+
52+ focus(): void {
53+ this.#textarea.focus();
54+ }
55+
56+ /** Select a character range, scrolling it into view. */
57+ select(start: number, end: number): void {
58+ this.#textarea.focus();
59+ this.#textarea.setSelectionRange(start, end);
60+ // setSelectionRange does not always scroll; nudge the line into view.
61+ const line = this.#textarea.value.slice(0, start).split('\n').length - 1;
62+ const lineHeight = this.#textarea.scrollHeight / Math.max(1, this.#lineCount());
63+ const target = line * lineHeight - this.#textarea.clientHeight / 2;
64+ this.#textarea.scrollTop = Math.max(0, target);
65+ this.#syncScroll();
66+ }
67+
68+ #lineCount(): number {
69+ return this.#textarea.value.split('\n').length + 1; // +1 for the trailing line
70+ }
71+
72+ #onKeyDown(e: KeyboardEvent): void {
73+ if (e.key !== 'Tab' || e.ctrlKey || e.metaKey || e.altKey) return;
74+ e.preventDefault();
75+ const el = this.#textarea;
76+ const { selectionStart: s, selectionEnd: t, value } = el;
77+ el.value = `${value.slice(0, s)} ${value.slice(t)}`;
78+ el.selectionStart = el.selectionEnd = s + 2;
79+ // Let the input listener repaint and notify, as for any other edit.
80+ el.dispatchEvent(new Event('input'));
81+ }
82+
83+ #repaint(): void {
84+ this.#overlay.innerHTML = highlightMatlab(this.#textarea.value, this.#external);
85+ this.#syncScroll();
86+ }
87+
88+ #syncScroll(): void {
89+ this.#overlay.scrollTop = this.#textarea.scrollTop;
90+ this.#overlay.scrollLeft = this.#textarea.scrollLeft;
91+ }
92+}
src/editor/matlab.tsadded+213−0View file
@@ -0,0 +1,213 @@
1+/**
2+ * A small MATLAB tokenizer, for syntax highlighting the model editor.
3+ *
4+ * Only what highlighting needs — comments, literals, numbers, keywords — and
5+ * deliberately not a parser: numbl does the real parsing, and reports errors
6+ * with positions. Tokens preserve the source text exactly, character for
7+ * character, because the highlighted output is overlaid on a textarea and any
8+ * dropped or added character would shift the two out of alignment.
9+ */
10+
11+export type TokenClass = 'com' | 'str' | 'num' | 'kw' | 'ext';
12+
13+export interface Token {
14+ text: string;
15+ cls: TokenClass | null;
16+}
17+
18+const KEYWORDS = new Set([
19+ 'break', 'case', 'catch', 'classdef', 'continue', 'else', 'elseif', 'end',
20+ 'for', 'function', 'global', 'if', 'otherwise', 'parfor', 'persistent',
21+ 'return', 'spmd', 'switch', 'try', 'while',
22+]);
23+
24+const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
25+const isIdent = (c: string): boolean => /[A-Za-z0-9_]/.test(c);
26+const isDigit = (c: string): boolean => c >= '0' && c <= '9';
27+
28+/**
29+ * In MATLAB `'` is both the transpose operator and the char-literal delimiter.
30+ * It opens a literal unless it directly follows something that can be
31+ * transposed — a value, a closing bracket, or another transpose.
32+ */
33+function quoteIsTranspose(src: string, at: number): boolean {
34+ for (let i = at - 1; i >= 0; i--) {
35+ const c = src[i];
36+ if (c === ' ' || c === '\t') continue;
37+ return isIdent(c) || c === ')' || c === ']' || c === '}' || c === '.' || c === "'";
38+ }
39+ return false;
40+}
41+
42+/**
43+ * Tokenize `src`. `external` names (the operations the host provides, e.g.
44+ * `synth` / `analys`) get their own class so the boundary between the model and
45+ * what it is given is visible in the editor.
46+ */
47+export function tokenizeMatlab(
48+ src: string,
49+ external: ReadonlySet<string> = new Set(),
50+): Token[] {
51+ const out: Token[] = [];
52+ const push = (text: string, cls: TokenClass | null): void => {
53+ if (!text) return;
54+ const last = out[out.length - 1];
55+ if (last && last.cls === cls) last.text += text;
56+ else out.push({ text, cls });
57+ };
58+
59+ let i = 0;
60+ let atLineStart = true;
61+ let inBlockComment = false;
62+
63+ while (i < src.length) {
64+ const c = src[i];
65+
66+ // Block comments: `%{` and `%}` each alone on their line.
67+ if (atLineStart) {
68+ const eol = src.indexOf('\n', i);
69+ const lineEnd = eol === -1 ? src.length : eol;
70+ const line = src.slice(i, lineEnd);
71+ const trimmed = line.trim();
72+ if (!inBlockComment && trimmed === '%{') inBlockComment = true;
73+ else if (inBlockComment && trimmed === '%}') {
74+ push(line, 'com');
75+ i = lineEnd;
76+ inBlockComment = false;
77+ atLineStart = false;
78+ continue;
79+ }
80+ if (inBlockComment) {
81+ push(line, 'com');
82+ i = lineEnd;
83+ atLineStart = false;
84+ continue;
85+ }
86+ }
87+
88+ if (c === '\n') {
89+ push(c, null);
90+ i++;
91+ atLineStart = true;
92+ continue;
93+ }
94+ if (c === ' ' || c === '\t') {
95+ push(c, null);
96+ i++;
97+ continue;
98+ }
99+ atLineStart = false;
100+
101+ // Line comment, including MATLAB's `%%` section markers.
102+ if (c === '%') {
103+ const eol = src.indexOf('\n', i);
104+ const end = eol === -1 ? src.length : eol;
105+ push(src.slice(i, end), 'com');
106+ i = end;
107+ continue;
108+ }
109+
110+ // Line continuation is an operator, but any trailing text is a comment.
111+ if (c === '.' && src.startsWith('...', i)) {
112+ const eol = src.indexOf('\n', i);
113+ const end = eol === -1 ? src.length : eol;
114+ push('...', null);
115+ push(src.slice(i + 3, end), 'com');
116+ i = end;
117+ continue;
118+ }
119+
120+ // Char literal (or transpose).
121+ if (c === "'") {
122+ if (quoteIsTranspose(src, i)) {
123+ push("'", null);
124+ i++;
125+ continue;
126+ }
127+ let j = i + 1;
128+ while (j < src.length && src[j] !== '\n') {
129+ if (src[j] === "'") {
130+ if (src[j + 1] === "'") j += 2; // escaped quote
131+ else {
132+ j++;
133+ break;
134+ }
135+ } else j++;
136+ }
137+ push(src.slice(i, j), 'str');
138+ i = j;
139+ continue;
140+ }
141+
142+ // Double-quoted string.
143+ if (c === '"') {
144+ let j = i + 1;
145+ while (j < src.length && src[j] !== '\n') {
146+ if (src[j] === '"') {
147+ if (src[j + 1] === '"') j += 2;
148+ else {
149+ j++;
150+ break;
151+ }
152+ } else j++;
153+ }
154+ push(src.slice(i, j), 'str');
155+ i = j;
156+ continue;
157+ }
158+
159+ // Number: 12, 1.5, .5, 1e-3, 2i
160+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
161+ let j = i;
162+ while (j < src.length && isDigit(src[j])) j++;
163+ if (src[j] === '.') {
164+ j++;
165+ while (j < src.length && isDigit(src[j])) j++;
166+ }
167+ if (src[j] === 'e' || src[j] === 'E') {
168+ let k = j + 1;
169+ if (src[k] === '+' || src[k] === '-') k++;
170+ if (isDigit(src[k])) {
171+ k++;
172+ while (k < src.length && isDigit(src[k])) k++;
173+ j = k;
174+ }
175+ }
176+ if (src[j] === 'i' || src[j] === 'j') j++;
177+ push(src.slice(i, j), 'num');
178+ i = j;
179+ continue;
180+ }
181+
182+ // Identifier / keyword / external operation.
183+ if (isIdentStart(c)) {
184+ let j = i;
185+ while (j < src.length && isIdent(src[j])) j++;
186+ const word = src.slice(i, j);
187+ push(word, KEYWORDS.has(word) ? 'kw' : external.has(word) ? 'ext' : null);
188+ i = j;
189+ continue;
190+ }
191+
192+ push(c, null);
193+ i++;
194+ }
195+
196+ return out;
197+}
198+
199+const escapeHtml = (s: string): string =>
200+ s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
201+
202+/** Highlighted HTML for `src`, safe to assign to innerHTML. */
203+export function highlightMatlab(
204+ src: string,
205+ external: ReadonlySet<string> = new Set(),
206+): string {
207+ const html = tokenizeMatlab(src, external)
208+ .map((t) => (t.cls ? `<span class="tok-${t.cls}">${escapeHtml(t.text)}</span>` : escapeHtml(t.text)))
209+ .join('');
210+ // A trailing newline keeps the last line's box height stable, so the overlay
211+ // and the textarea scroll to the same extent.
212+ return `${html}\n`;
213+}
src/grids.tsadded+227−0View file
@@ -0,0 +1,227 @@
1+/**
2+ * The computational grids: one rectangular box of air, and one small grid per
3+ * vibrating object — a line of nodes for a string or bar, a rectangle of
4+ * nodes for a membrane.
5+ *
6+ * The air grid is cell-centred and uniform, with the same spacing h along
7+ * every axis; the box is twice as long in x as in y and z, so nx = 2*ny =
8+ * 2*nz and a field is nx*ny*nz f32 values, flattened x-fastest: the point
9+ * (ix, iy, iz) is element `ix + nx*(iy + ny*iz)`. Cell-centred so that no
10+ * point sits exactly on the outer boundary: the stencil takes the field
11+ * outside the domain to be zero, and the absorbing layer is meant to have
12+ * swallowed the wave before it gets there.
13+ *
14+ * Object grids are node-centred, endpoints inclusive, so their edges land
15+ * exactly on the terminations that pin them. Node coordinates are local —
16+ * 0..L1 along the first axis (and 0..L2 along the second, for a rectangle) —
17+ * because that is what the initial conditions (pluck shapes, strike bumps)
18+ * are naturally written in. In the air's world coordinates every object is
19+ * centred: a line runs from -L1/2 to +L1/2 along x, a rectangle spans
20+ * (-L1/2..L1/2) x (-L2/2..L2/2) in the horizontal plane. Where an object
21+ * sits in y and z is *not* baked in here: the coupling profiles the scene
22+ * builds carry that, so moving an object is a scene re-evaluation rather
23+ * than a recompile.
24+ *
25+ * The air's CFL condition fixes the timestep for everyone (see stableDt);
26+ * each object then chooses the finest node spacing that its own scheme is
27+ * stable at for anything the parameter sliders can reach, so moving a slider
28+ * never forces a recompile — see makeLineGrid / makeRectGrid.
29+ */
30+import { CFL } from './units.ts';
31+
32+export interface AirGrid {
33+ nx: number;
34+ ny: number;
35+ nz: number;
36+ npts: number;
37+ /** Domain extents in metres, centred on the origin. */
38+ Lx: number;
39+ Ly: number;
40+ Lz: number;
41+ /** Grid spacing, the same in all three directions. */
42+ h: number;
43+ /** Coordinates of every grid point, npts each, x fastest — as the shaders
44+ * see them (f32) and as the scene .m is evaluated at (f64). */
45+ x: Float32Array;
46+ y: Float32Array;
47+ z: Float32Array;
48+ x64: Float64Array;
49+ y64: Float64Array;
50+ z64: Float64Array;
51+}
52+
53+export function makeAirGrid(nx: number, Lx: number): AirGrid {
54+ const ny = nx / 2;
55+ const nz = nx / 2;
56+ if (!Number.isInteger(ny)) throw new Error(`grid nx = ${nx} must be even`);
57+ const h = Lx / nx;
58+ const Ly = ny * h;
59+ const Lz = nz * h;
60+ const npts = nx * ny * nz;
61+ const x64 = new Float64Array(npts);
62+ const y64 = new Float64Array(npts);
63+ const z64 = new Float64Array(npts);
64+ for (let iz = 0; iz < nz; iz++) {
65+ const zv = -Lz / 2 + (iz + 0.5) * h;
66+ for (let iy = 0; iy < ny; iy++) {
67+ const yv = -Ly / 2 + (iy + 0.5) * h;
68+ const base = nx * (iy + ny * iz);
69+ for (let ix = 0; ix < nx; ix++) {
70+ const k = base + ix;
71+ x64[k] = -Lx / 2 + (ix + 0.5) * h;
72+ y64[k] = yv;
73+ z64[k] = zv;
74+ }
75+ }
76+ }
77+ return {
78+ nx, ny, nz, npts, Lx, Ly, Lz, h,
79+ x: new Float32Array(x64),
80+ y: new Float32Array(y64),
81+ z: new Float32Array(z64),
82+ x64, y64, z64,
83+ };
84+}
85+
86+/**
87+ * The timestep the explicit leapfrog on the air grid is stable at, seconds.
88+ *
89+ * Leapfrog on p_tt = c^2 lap(p) is stable while dt^2 c^2 |lap|max <= 4, and
90+ * the 7-point Laplacian's extreme eigenvalue is 12/h^2, so the condition is
91+ * c*dt/h <= 1/sqrt(3). `cmax` is the fastest sound speed anywhere in the
92+ * medium; since bodies are masks rather than fast materials (see
93+ * src/mgpu/ops.ts), cmax is normally just the speed of air.
94+ */
95+export function stableDt(h: number, cmax: number, cfl = CFL): number {
96+ return (cfl * h) / (Math.sqrt(3) * Math.max(cmax, 1e-12));
97+}
98+
99+/** One vibrating object's grid: a line of nodes, or a rectangle of them. */
100+export interface ObjectGrid {
101+ kind: 'line' | 'rect';
102+ /** Nodes along each axis; a line has n2 = 1. */
103+ n1: number;
104+ n2: number;
105+ npts: number;
106+ /** Extents in metres; a line has L2 = 0. */
107+ L1: number;
108+ L2: number;
109+ /** Node spacing along each axis; a line has h2 = 0. */
110+ h1: number;
111+ h2: number;
112+ /** Local node coordinates, npts each, first axis fastest: 0..L1 and
113+ * 0..L2 inclusive. A line's ys is all zeros. */
114+ xs: Float32Array;
115+ ys: Float32Array;
116+ xs64: Float64Array;
117+ ys64: Float64Array;
118+ /** 1 at interior nodes, 0 at the pinned boundary (the two ends of a line,
119+ * the whole rim of a rectangle). The compiled step multiplies its update
120+ * by this, which is what terminates the object. */
121+ pin: Float32Array;
122+}
123+
124+/**
125+ * The coarsest node spacing the 1D stiff scheme demands at timestep dt.
126+ *
127+ * The scheme is the standard explicit leapfrog for
128+ * u_tt = cs^2 u_xx - kap^2 u_xxxx - 2 sig0 u_t + 2 sig1 (u_xx)_t
129+ * whose stability condition (Bilbao, Numerical Sound Synthesis, ch. 7) is
130+ * hs^2 >= (a + sqrt(a^2 + 16 kap^2 dt^2)) / 2, a = cs^2 dt^2 + 4 sig1 dt.
131+ * A bar is the cs = 0 case; an ideal string the kap = 0 case.
132+ */
133+export function stableH1(dt: number, cs: number, kap2: number, sig1: number): number {
134+ const a = cs * cs * dt * dt + 4 * sig1 * dt;
135+ return Math.sqrt((a + Math.sqrt(a * a + 16 * kap2 * dt * dt)) / 2);
136+}
137+
138+/**
139+ * The coarsest spacing the 2D membrane scheme demands at timestep dt,
140+ * assuming (conservatively) the same spacing on both axes.
141+ *
142+ * For u_tt = c^2 lap(u) - 2 sig0 u_t + 2 sig1 (lap u)_t on the 5-point
143+ * stencil, the extreme eigenvalue is 4/h1^2 + 4/h2^2, and stability needs
144+ * (c^2 dt^2 + 4 sig1 dt)(1/h1^2 + 1/h2^2) <= 1. Requiring both spacings to
145+ * be at least the h returned here (which satisfies the bound with 2/h^2) is
146+ * therefore always safe.
147+ */
148+export function stableH2(dt: number, c: number, sig1: number): number {
149+ return Math.sqrt(2 * (c * c * dt * dt + 4 * sig1 * dt));
150+}
151+
152+/** Node-count caps. Objects are tiny beside the air grid; the caps exist so
153+ * a worst-case slider range cannot ask for an absurd grid. */
154+const MAX_LINE_NODES = 256;
155+const MAX_RECT_NODES = 96;
156+
157+/**
158+ * Build a line grid (string or bar) of length L1 at timestep dt, sized so
159+ * the scheme is stable for every value the parameter sliders can reach.
160+ *
161+ * The air dictates dt, so an object has no say in the timestep; what it gets
162+ * to choose is its own spacing, and the finest stable spacing is what
163+ * maximizes its bandwidth. Sizing for the sliders' worst case (the stiffest,
164+ * fastest, most damped settings) rather than their current values means
165+ * moving a slider never forces a recompile: the grid stays valid, merely a
166+ * little coarser than that setting alone would need.
167+ */
168+export function makeLineGrid(
169+ L1: number,
170+ dt: number,
171+ worst: { cs: number; kap2: number; sig1: number },
172+): ObjectGrid {
173+ // 0.95: a little margin under the exact limit, as CFL is for the air.
174+ const hMin = stableH1(dt, worst.cs, worst.kap2, worst.sig1) / 0.95;
175+ const n1 = Math.max(5, Math.min(MAX_LINE_NODES, Math.floor(L1 / hMin) + 1));
176+ const h1 = L1 / (n1 - 1);
177+ const xs64 = new Float64Array(n1);
178+ for (let i = 0; i < n1; i++) xs64[i] = i * h1;
179+ const pin = new Float32Array(n1).fill(1);
180+ pin[0] = 0;
181+ pin[n1 - 1] = 0;
182+ return {
183+ kind: 'line',
184+ n1, n2: 1, npts: n1,
185+ L1, L2: 0, h1, h2: 0,
186+ xs: new Float32Array(xs64), ys: new Float32Array(n1),
187+ xs64, ys64: new Float64Array(n1),
188+ pin,
189+ };
190+}
191+
192+/** Build a rectangular membrane grid of extent L1 x L2 at timestep dt, sized
193+ * for the sliders' worst case like makeLineGrid. */
194+export function makeRectGrid(
195+ L1: number,
196+ L2: number,
197+ dt: number,
198+ worst: { c: number; sig1: number },
199+): ObjectGrid {
200+ const hMin = stableH2(dt, worst.c, worst.sig1) / 0.95;
201+ const size = (L: number): number =>
202+ Math.max(5, Math.min(MAX_RECT_NODES, Math.floor(L / hMin) + 1));
203+ const n1 = size(L1);
204+ const n2 = size(L2);
205+ const h1 = L1 / (n1 - 1);
206+ const h2 = L2 / (n2 - 1);
207+ const npts = n1 * n2;
208+ const xs64 = new Float64Array(npts);
209+ const ys64 = new Float64Array(npts);
210+ const pin = new Float32Array(npts).fill(1);
211+ for (let j = 0; j < n2; j++) {
212+ for (let i = 0; i < n1; i++) {
213+ const k = i + n1 * j;
214+ xs64[k] = i * h1;
215+ ys64[k] = j * h2;
216+ if (i === 0 || i === n1 - 1 || j === 0 || j === n2 - 1) pin[k] = 0;
217+ }
218+ }
219+ return {
220+ kind: 'rect',
221+ n1, n2, npts,
222+ L1, L2, h1, h2,
223+ xs: new Float32Array(xs64), ys: new Float32Array(ys64),
224+ xs64, ys64,
225+ pin,
226+ };
227+}
src/main.tsadded+1002−0View file
@@ -0,0 +1,1002 @@
1+/**
2+ * The app: a scene you compose, the MATLAB it generates, a GPU, and a
3+ * microphone.
4+ *
5+ * Three altitudes, one simulation. *Play* is presets and the handful of
6+ * musical knobs. *Build* is the studio: add strings, bars and membranes,
7+ * pick a body, place the microphone. *Lab* is the truth: the generated (and
8+ * editable) .m files, what they compiled to, and the recording's spectrum
9+ * against where each object's partials should land.
10+ *
11+ * Everything the page does falls into three motions, and the tabs are
12+ * honest about them. Moving a *model parameter* (a pitch, a decay) writes a
13+ * GPU uniform, which is free. Moving a *placement or body parameter*
14+ * re-evaluates the scene .m on the CPU and re-uploads a few arrays, which is
15+ * cheap and needs no recompile. Changing the *structure* — objects added or
16+ * removed, a size, the air grid, or either file's text — recompiles: a fresh
17+ * session, from source to shaders.
18+ */
19+import { requestAcousticDevice } from './device.ts';
20+import { StudioSession, specParams } from './studio/session.ts';
21+import { formatFailure, ModelCompileError } from './mgpu/errors.ts';
22+import { OBJECT_TEMPLATES, templateOf } from './studio/objects.ts';
23+import { BODY_TEMPLATES } from './studio/bodies.ts';
24+import { PRESETS, cloneSpec, presetByKey } from './studio/presets.ts';
25+import {
26+ defaultsOf, suffixed,
27+ type ObjectType, type ParamSpec, type Params, type SceneSpec,
28+} from './studio/types.ts';
29+import { suffixedSpecs } from './studio/codegen.ts';
30+import {
31+ drawSpectrum, objectColor, predictedPartials, type PartialSet,
32+} from './studio/spectrum.ts';
33+import { VolumeView, cameraFrame, type Camera } from './render/volume.ts';
34+import { Overlay, type OverlayObject } from './render/overlay.ts';
35+import { StringPlot } from './render/stringplot.ts';
36+import { Colorbar, fmtValue } from './render/colorbar.ts';
37+import { colormaps, colormapNames } from './render/colormaps.ts';
38+import { CodeEditor } from './editor/codeEditor.ts';
39+import { planPlayback, playTrace, stop as stopAudio } from './audio/play.ts';
40+import { traceToWav } from './audio/wav.ts';
41+import { C_AIR, POOR_RESOLUTION, fmtLength, fmtTime } from './units.ts';
42+
43+const el = <T extends HTMLElement>(id: string): T => {
44+ const node = document.getElementById(id);
45+ if (!node) throw new Error(`missing element #${id}`);
46+ return node as T;
47+};
48+
49+const errBox = el<HTMLParagraphElement>('err');
50+const showError = (e: unknown, source: string): void => {
51+ errBox.textContent = formatFailure(e, source);
52+};
53+const clearError = (): void => {
54+ errBox.textContent = '';
55+};
56+
57+/* ---------------------------------------------------------------- state -- */
58+
59+const initialPreset =
60+ presetByKey(location.hash.replace(/^#preset\//, '')) ?? PRESETS[0];
61+let presetKey: string | null = initialPreset.key;
62+let spec: SceneSpec = cloneSpec(initialPreset.spec);
63+let { params, sceneParams } = specParams(spec);
64+/** The editor's hand-edited copies; null means "as generated". */
65+const sources: { model: string | null; scene: string | null } = {
66+ model: null,
67+ scene: null,
68+};
69+let gridNx = 96;
70+/** Timesteps per display frame. May be fractional — ¼× means one step every
71+ * fourth frame — which is what `stepDebt` accumulates toward. */
72+let stepsPerFrame = 16;
73+let stepDebt = 0;
74+const mic = { ...initialPreset.mic };
75+/** Paused until asked. The page compiles and draws its silent initial state
76+ * on load and nothing moves until Run (or Render clip). */
77+let running = false;
78+let session: StudioSession | null = null;
79+
80+const camera: Camera = { az: -2.2, el: 0.45, dist: 1.35 };
81+const viewState = { opacity: 2, contrast: 1.6, quality: 192, clipFrac: 0.5, exaggerate: 25 };
82+const show = { field: true, body: true, objects: true, wire: true, mic: true };
83+
84+/** Pressure the colormap saturates at, and whether it follows the field. */
85+let scale = 1e-6;
86+let autoScale = true;
87+let peakSeen = 0;
88+let colormapName = colormapNames[0];
89+/** The last displacement read back per object, for the overlay and plot. */
90+const lastFields = new Map<string, Float32Array>();
91+/** Per-object plot scale: the largest |u| seen since the last excite. */
92+const plotPeak = new Map<string, number>();
93+let plotId: string | null = null;
94+/** The last recording analyzed, so the spectrum can be redrawn. */
95+let lastTrace: { trace: Float32Array; dt: number } | null = null;
96+let dirty = true;
97+let renderingClip = false;
98+
99+/* ------------------------------------------------------------- the page -- */
100+
101+const device = await requestAcousticDevice().catch((e: unknown) => {
102+ showError(e, '');
103+ return null;
104+});
105+if (!device) throw new Error('no GPU');
106+
107+void device.lost.then((info) => {
108+ showError(
109+ new Error(
110+ `the GPU device was lost (${info.reason}): ${info.message}\n` +
111+ 'Reload the page to start again.',
112+ ),
113+ '',
114+ );
115+});
116+
117+const canvas = el<HTMLCanvasElement>('view');
118+const view = new VolumeView(device, canvas);
119+view.setColormap(colormaps[colormapName]);
120+const overlay = new Overlay(el<HTMLCanvasElement>('overlay'));
121+const plot = new StringPlot(el<HTMLCanvasElement>('objplot'));
122+const colorbar = new Colorbar(el('colorbar'));
123+colorbar.setColormap(colormaps[colormapName]);
124+const darkMedia = matchMedia('(prefers-color-scheme: dark)');
125+
126+new ResizeObserver(() => {
127+ view.resize();
128+ overlay.resize();
129+ plot.resize();
130+ dirty = true;
131+}).observe(canvas);
132+
133+const editorFile = el<HTMLSelectElement>('editor-file');
134+const editor = new CodeEditor({
135+ textarea: el<HTMLTextAreaElement>('source'),
136+ overlay: el('highlight'),
137+ external: new Set(['sponge3']),
138+ onInput: (value) => {
139+ sources[editorFile.value as 'model' | 'scene'] = value;
140+ el<HTMLButtonElement>('recompile').classList.add('primary');
141+ },
142+});
143+
144+const colormapSelect = el<HTMLSelectElement>('colormap');
145+for (const name of colormapNames) colormapSelect.append(new Option(name, name));
146+colormapSelect.value = colormapName;
147+
148+/* ---------------------------------------------------------------- utils -- */
149+
150+const objectIds = (): string[] => spec.objects.map((o) => o.id);
151+
152+function nextId(type: ObjectType): string {
153+ const prefix = type === 'string' ? 's' : type === 'bar' ? 'b' : 'm';
154+ for (let n = 1; ; n++) {
155+ const id = `${prefix}${n}`;
156+ if (!objectIds().includes(id)) return id;
157+ }
158+}
159+
160+/** What the excite button should say for this scene. */
161+function verb(): string {
162+ const verbs = new Set(spec.objects.map((o) => templateOf(o).verb));
163+ return verbs.size === 1 ? [...verbs][0] : 'Excite';
164+}
165+
166+/** Reconcile the parameter dicts with the current spec: defaults for new
167+ * keys, current values kept where they still apply. */
168+function syncParams(): void {
169+ const d = specParams(spec);
170+ params = { ...d.params, ...pick(params, Object.keys(d.params)) };
171+ sceneParams = { ...d.sceneParams, ...pick(sceneParams, Object.keys(d.sceneParams)) };
172+}
173+const pick = (o: Params, keys: string[]): Params =>
174+ Object.fromEntries(keys.filter((k) => k in o).map((k) => [k, o[k]]));
175+
176+/** Structure changed: keep the builder's values in the spec so presets and
177+ * future codegen see them. */
178+function writeBack(): void {
179+ for (const o of spec.objects) {
180+ const t = templateOf(o);
181+ o.params = Object.fromEntries(
182+ t.paramSpecs.map((s) => [s.key, params[suffixed(s.key, o.id)] ?? s.value]),
183+ );
184+ o.place = Object.fromEntries(
185+ t.placeSpecs.map((s) => [s.key, sceneParams[suffixed(s.key, o.id)] ?? s.value]),
186+ );
187+ }
188+ spec.body.params = pick(
189+ sceneParams,
190+ BODY_TEMPLATES[spec.body.type].paramSpecs.map((s) => s.key),
191+ );
192+}
193+
194+/* ------------------------------------------------------------- controls -- */
195+
196+/** One slider per parameter spec. */
197+function buildSliders(
198+ host: HTMLElement,
199+ specs: ParamSpec[],
200+ values: Params,
201+ onChange: (key: string, value: number) => void,
202+): void {
203+ host.textContent = '';
204+ addSliders(host, specs, values, onChange);
205+}
206+
207+function addSliders(
208+ host: HTMLElement,
209+ specs: ParamSpec[],
210+ values: Params,
211+ onChange: (key: string, value: number) => void,
212+): void {
213+ for (const spec of specs) {
214+ const row = document.createElement('label');
215+ row.className = 'slider';
216+ if (spec.hint) row.title = spec.hint;
217+ const name = document.createElement('span');
218+ name.textContent = spec.label;
219+ const input = document.createElement('input');
220+ input.type = 'range';
221+ input.min = String(spec.min);
222+ input.max = String(spec.max);
223+ input.step = String(spec.step);
224+ input.value = String(values[spec.key] ?? spec.value);
225+ const out = document.createElement('output');
226+ out.textContent = fmtValue(values[spec.key] ?? spec.value);
227+ input.addEventListener('input', () => {
228+ const v = Number(input.value);
229+ out.textContent = fmtValue(v);
230+ onChange(spec.key, v);
231+ });
232+ row.append(name, input, out);
233+ host.append(row);
234+ }
235+}
236+
237+/** Scene edits go through the MATLAB interpreter, which is fast but not free;
238+ * a drag should not queue up one evaluation per pixel. */
239+let sceneTimer = 0;
240+const scheduleSceneUpdate = (): void => {
241+ clearTimeout(sceneTimer);
242+ sceneTimer = window.setTimeout(applyScene, 150);
243+};
244+
245+function applyScene(): void {
246+ if (!session) return;
247+ try {
248+ session.setScene(sceneParams, sources.scene ?? undefined);
249+ clearError();
250+ // A scene edit that raises the fastest speed invalidates the timestep
251+ // (and with it every object grid), which only a rebuild can fix.
252+ if (Math.abs(session.dtWanted - session.dt) > 1e-12 * session.dt) {
253+ void rebuild();
254+ return;
255+ }
256+ dirty = true;
257+ } catch (e) {
258+ showError(e, sources.scene ?? session.sceneSource);
259+ }
260+}
261+
262+const onModelParam = (key: string, value: number): void => {
263+ params = { ...params, [key]: value };
264+ session?.setParams(params);
265+};
266+const onSceneParam = (key: string, value: number): void => {
267+ sceneParams = { ...sceneParams, [key]: value };
268+ scheduleSceneUpdate();
269+};
270+
271+function buildPresetCards(): void {
272+ const host = el('presets');
273+ host.textContent = '';
274+ for (const p of PRESETS) {
275+ const card = document.createElement('button');
276+ card.className = `preset${p.key === presetKey ? ' active' : ''}`;
277+ card.innerHTML = `<b>${p.label}</b><span>${p.blurb}</span>`;
278+ card.addEventListener('click', () => {
279+ presetKey = p.key;
280+ location.hash = `preset/${p.key}`;
281+ spec = cloneSpec(p.spec);
282+ Object.assign(mic, p.mic);
283+ ({ params, sceneParams } = specParams(spec));
284+ sources.model = null;
285+ sources.scene = null;
286+ refreshControls();
287+ void rebuild();
288+ });
289+ host.append(card);
290+ }
291+}
292+
293+function buildMusicalSliders(): void {
294+ const host = el('musicalparams');
295+ host.textContent = '';
296+ for (const o of spec.objects) {
297+ const t = templateOf(o);
298+ const specs = suffixedSpecs(t.paramSpecs, o.id)
299+ .filter((s) => s.musical)
300+ .map((s) => ({ ...s, label: `${o.id} · ${s.label}` }));
301+ addSliders(host, specs, params, onModelParam);
302+ }
303+}
304+
305+function buildObjectCards(): void {
306+ const host = el('objects');
307+ host.textContent = '';
308+ spec.objects.forEach((o, index) => {
309+ const t = templateOf(o);
310+ const card = document.createElement('div');
311+ card.className = 'objcard';
312+ const head = document.createElement('div');
313+ head.className = 'objcard-head';
314+ const swatch = document.createElement('i');
315+ swatch.className = 'swatch';
316+ swatch.style.background = objectColor(index);
317+ const title = document.createElement('b');
318+ title.textContent = `${t.label} ${o.id}`;
319+ const blurb = document.createElement('span');
320+ blurb.className = 'sub';
321+ blurb.style.margin = '0';
322+ blurb.textContent = t.blurb;
323+ const remove = document.createElement('button');
324+ remove.textContent = 'remove';
325+ remove.disabled = spec.objects.length === 1;
326+ remove.title = remove.disabled
327+ ? 'A scene needs at least one object'
328+ : 'Remove this object (recompiles)';
329+ remove.addEventListener('click', () => {
330+ writeBack();
331+ spec.objects = spec.objects.filter((x) => x.id !== o.id);
332+ presetKey = null;
333+ syncParams();
334+ refreshControls();
335+ void rebuild();
336+ });
337+ head.append(swatch, title, blurb, remove);
338+ card.append(head);
339+
340+ const sliders = document.createElement('div');
341+ sliders.className = 'sliders';
342+ // Sizes first: they rebuild. Then placement (scene) and parameters.
343+ addSliders(
344+ sliders,
345+ t.sizeSpecs,
346+ { ...Object.fromEntries(t.sizeSpecs.map((s) => [s.key, (o.size as Params)[s.key] ?? s.value])) },
347+ (key, value) => {
348+ writeBack();
349+ (o.size as Params)[key] = value;
350+ presetKey = null;
351+ scheduleRebuild();
352+ },
353+ );
354+ addSliders(sliders, suffixedSpecs(t.placeSpecs, o.id), sceneParams, onSceneParam);
355+ addSliders(sliders, suffixedSpecs(t.paramSpecs, o.id), params, onModelParam);
356+ card.append(sliders);
357+ host.append(card);
358+ });
359+}
360+
361+function buildBodyControls(): void {
362+ const select = el<HTMLSelectElement>('bodytype');
363+ select.value = spec.body.type;
364+ el('bodyblurb').textContent = BODY_TEMPLATES[spec.body.type].blurb;
365+ buildSliders(
366+ el('bodyparams'),
367+ BODY_TEMPLATES[spec.body.type].paramSpecs,
368+ sceneParams,
369+ onSceneParam,
370+ );
371+}
372+
373+function buildMicControls(): void {
374+ const specs: ParamSpec[] = [
375+ { key: 'x', label: 'mic x (m)', value: mic.x, min: -0.45, max: 0.45, step: 0.01 },
376+ { key: 'y', label: 'mic y (m)', value: mic.y, min: -0.22, max: 0.22, step: 0.01 },
377+ { key: 'z', label: 'mic z (m)', value: mic.z, min: -0.22, max: 0.22, step: 0.01 },
378+ ];
379+ buildSliders(el('micparams'), specs, { x: mic.x, y: mic.y, z: mic.z }, (key, value) => {
380+ mic[key as 'x' | 'y' | 'z'] = value;
381+ session?.setMic(mic.x, mic.y, mic.z);
382+ dirty = true;
383+ });
384+}
385+
386+function buildObjPick(): void {
387+ const pick = el<HTMLSelectElement>('objpick');
388+ pick.textContent = '';
389+ for (const o of spec.objects) {
390+ pick.append(new Option(`${templateOf(o).label} ${o.id}`, o.id));
391+ }
392+ if (!plotId || !objectIds().includes(plotId)) plotId = spec.objects[0]?.id ?? null;
393+ if (plotId) pick.value = plotId;
394+}
395+
396+function refreshControls(): void {
397+ el<HTMLButtonElement>('excite').textContent = verb();
398+ buildPresetCards();
399+ buildMusicalSliders();
400+ buildObjectCards();
401+ buildBodyControls();
402+ buildMicControls();
403+ buildObjPick();
404+}
405+
406+/* ------------------------------------------------------------- the loop -- */
407+
408+let frames = 0;
409+let lastFpsAt = performance.now();
410+let msPerFrame = 0;
411+/** A readback in flight; only one at a time, since they share a buffer. */
412+let reading = false;
413+
414+function excite(): void {
415+ if (!session) return;
416+ session.excite();
417+ peakSeen = 0;
418+ if (autoScale) scale = 1e-6;
419+ plotPeak.clear();
420+ dirty = true;
421+ showRecording();
422+}
423+
424+/**
425+ * Compile the current scene into a new session and swap it in.
426+ *
427+ * The old session keeps running until the new one exists, and is only torn
428+ * down once the swap has happened: a compile that fails leaves something on
429+ * screen and something to edit rather than a dead page.
430+ */
431+let building = false;
432+let rebuildAgain = false;
433+async function rebuild(): Promise<void> {
434+ if (building) {
435+ rebuildAgain = true;
436+ return;
437+ }
438+ building = true;
439+ renderingClip = false;
440+ const old = session;
441+ try {
442+ syncParams();
443+ const next = await StudioSession.create({
444+ device: device!,
445+ spec,
446+ params,
447+ sceneParams,
448+ modelSource: sources.model ?? undefined,
449+ sceneSource: sources.scene ?? undefined,
450+ nx: gridNx,
451+ });
452+ next.excite();
453+ session = next;
454+ view.setSource(
455+ next.gpu.stateBuffer('p')!,
456+ next.gpu.stateBuffer('wall')!,
457+ {
458+ nx: next.air.nx, ny: next.air.ny, nz: next.air.nz,
459+ Lx: next.air.Lx, Ly: next.air.Ly, Lz: next.air.Lz, h: next.air.h,
460+ },
461+ );
462+ next.setMic(mic.x, mic.y, mic.z);
463+ old?.destroy();
464+ lastFields.clear();
465+ plotPeak.clear();
466+ peakSeen = 0;
467+ scale = 1e-6;
468+ lastTrace = null;
469+ dirty = true;
470+ clearError();
471+ showRecording();
472+ showStatics();
473+ el<HTMLButtonElement>('recompile').classList.remove('primary');
474+ el('compiled').textContent = describe(next);
475+ showFile(editorFile.value as 'model' | 'scene');
476+ drawSpec();
477+ } catch (e) {
478+ const which = failingFile(e);
479+ showError(e, sources[which] ?? '');
480+ if (e instanceof ModelCompileError && e.start !== undefined && sources[which]) {
481+ editorFile.value = which;
482+ showFile(which);
483+ editor.select(e.start, e.end ?? e.start + 1);
484+ }
485+ } finally {
486+ building = false;
487+ if (rebuildAgain) {
488+ rebuildAgain = false;
489+ void rebuild();
490+ }
491+ }
492+}
493+
494+/** Size-slider drags would rebuild per pixel; give them a moment to settle. */
495+let rebuildTimer = 0;
496+const scheduleRebuild = (): void => {
497+ clearTimeout(rebuildTimer);
498+ rebuildTimer = window.setTimeout(() => void rebuild(), 250);
499+};
500+
501+/** A scene failure is reported against `medium`, everything else against the
502+ * model's own functions. */
503+const failingFile = (e: unknown): 'model' | 'scene' =>
504+ e instanceof ModelCompileError && e.fn === 'medium' ? 'scene' : 'model';
505+
506+const describe = (s: StudioSession): string => {
507+ const { init, step } = s.describe();
508+ return [
509+ `% init — one ${verb().toLowerCase()}`,
510+ ...init.map((l) => ` ${l}`),
511+ ``,
512+ `% step — every timestep`,
513+ ...step.map((l) => ` ${l}`),
514+ ].join('\n');
515+};
516+
517+/** What the microphone has, and what it would sound like. */
518+function showRecording(): void {
519+ const info = el('recinfo');
520+ if (!session) {
521+ info.textContent = '';
522+ return;
523+ }
524+ const n = session.recorder.count;
525+ if (n === 0) {
526+ info.textContent = 'nothing recorded yet — Run, or Render clip';
527+ return;
528+ }
529+ const plan = planPlayback(n, session.dt);
530+ info.textContent =
531+ `${fmtTime(n * session.dt)} recorded · plays at ` +
532+ `${Math.round(plan.rate).toLocaleString()} Hz` +
533+ (plan.realTime ? '' : ' (rate clamped)') +
534+ (session.recorder.full ? ' · buffer full' : '');
535+}
536+
537+/** The facts that only change on a rebuild. */
538+function showStatics(): void {
539+ if (!session) return;
540+ const { air } = session;
541+ const fmax = C_AIR / (POOR_RESOLUTION * air.h);
542+ const objects = spec.objects
543+ .map((o) => {
544+ const g = session!.grids[o.id];
545+ return `${o.id}: ${g.kind === 'line' ? `${g.n1} nodes` : `${g.n1}×${g.n2} nodes`}`;
546+ })
547+ .join(', ');
548+ el('domaininfo').textContent =
549+ `The air is a ${fmtLength(air.Lx)} × ${fmtLength(air.Ly)} × ${fmtLength(air.Lz)} box ` +
550+ `at ${air.nx}×${air.ny}×${air.nz} cells of ${fmtLength(air.h)} — honest to about ` +
551+ `${(fmax / 1000).toFixed(1)} kHz. Objects: ${objects}. ` +
552+ `dt = ${fmtTime(session.dt)} (${Math.round(1 / session.dt / 1000)} kHz).`;
553+}
554+
555+function stats(): void {
556+ if (!session) return;
557+ const rate =
558+ running && msPerFrame > 0 ? `${msPerFrame.toFixed(1)} ms/frame` : 'paused';
559+ el('stats').innerHTML =
560+ `t = <b>${fmtTime(session.t)}</b> · step <b>${session.steps.toLocaleString()}</b> · ` +
561+ `${spec.objects.length} object${spec.objects.length === 1 ? '' : 's'} · ${rate}`;
562+}
563+
564+/**
565+ * Follow the field with the colour scale, and keep the overlay and plot fed.
566+ * The only readbacks in the app, a few times a second rather than every
567+ * frame, and strictly one at a time — they share a staging buffer.
568+ */
569+async function pollFields(): Promise<void> {
570+ if (!session || reading) return;
571+ reading = true;
572+ try {
573+ for (const o of spec.objects) {
574+ const u = await session.read(`u_${o.id}`);
575+ lastFields.set(o.id, u);
576+ let peak = plotPeak.get(o.id) ?? 0;
577+ for (const v of u) peak = Math.max(peak, Math.abs(v));
578+ plotPeak.set(o.id, peak);
579+ }
580+ if (autoScale) {
581+ const p = await session.read('p');
582+ let peak = 0;
583+ for (const v of p) peak = Math.max(peak, Math.abs(v));
584+ peakSeen = Math.max(peakSeen, peak);
585+ scale = peak > scale ? peak : 0.97 * scale + 0.03 * peak;
586+ scale = Math.max(scale, 0.05 * peakSeen, 1e-12);
587+ }
588+ dirty = true;
589+ } catch {
590+ // A rebuild can destroy the buffers mid-read; the next poll recovers.
591+ } finally {
592+ reading = false;
593+ }
594+}
595+
596+/** The overlay's view of the scene, from the builder state. */
597+function overlayObjects(): OverlayObject[] {
598+ return spec.objects.map((o, index) => {
599+ const g = session?.grids[o.id];
600+ const color = objectColor(index);
601+ if (o.type === 'membrane') {
602+ return {
603+ kind: 'rect' as const,
604+ color,
605+ L1: o.size.L1,
606+ L2: o.size.L2 ?? o.size.L1,
607+ n1: g?.n1 ?? 2,
608+ n2: g?.n2 ?? 2,
609+ oz: sceneParams[`oz_${o.id}`] ?? 0,
610+ u: lastFields.get(o.id) ?? null,
611+ };
612+ }
613+ return {
614+ kind: 'line' as const,
615+ color,
616+ L1: o.size.L1,
617+ oy: sceneParams[`oy_${o.id}`] ?? 0,
618+ oz: sceneParams[`oz_${o.id}`] ?? 0,
619+ u: lastFields.get(o.id) ?? null,
620+ posts: o.type === 'string',
621+ };
622+ });
623+}
624+
625+/** The selected object's displacement, as a 1D slice for the plot. */
626+function plotSlice(): { u: Float32Array | null; label: string } {
627+ if (!plotId || !session) return { u: null, label: '' };
628+ const o = spec.objects.find((x) => x.id === plotId);
629+ const u = lastFields.get(plotId) ?? null;
630+ const g = session.grids[plotId];
631+ if (!o || !g) return { u: null, label: '' };
632+ let slice = u;
633+ let where = '';
634+ if (o.type === 'membrane' && u) {
635+ const midJ = Math.floor(g.n2 / 2);
636+ slice = u.subarray(g.n1 * midJ, g.n1 * (midJ + 1));
637+ where = ' (centre row)';
638+ }
639+ return {
640+ u: slice,
641+ label: `${plotId} displacement${where}, full scale ±${fmtValue(1100 * plotScale())} mm`,
642+ };
643+}
644+const plotScale = (): number =>
645+ Math.max(plotPeak.get(plotId ?? '') ?? 0, 1e-6) * 1.05;
646+
647+let lastPollAt = 0;
648+function frame(): void {
649+ if (session && (running || dirty)) {
650+ if (running && !renderingClip) {
651+ // Fractional speeds accumulate a debt and step when it reaches a whole
652+ // timestep, so ¼× is one step every fourth frame rather than nothing.
653+ stepDebt += stepsPerFrame;
654+ const n = Math.floor(stepDebt);
655+ if (n > 0) {
656+ session.step(n);
657+ stepDebt -= n;
658+ }
659+ }
660+ const cf = cameraFrame(camera, canvas.width / Math.max(canvas.height, 1));
661+ view.draw({
662+ frame: cf,
663+ scale: Math.max(scale, 1e-20),
664+ opacity: show.field ? viewState.opacity : 0,
665+ contrast: viewState.contrast,
666+ steps: viewState.quality,
667+ clipY: viewState.clipFrac * session.air.Ly,
668+ body: show.body ? 0.6 : 0,
669+ mic: show.mic ? mic : null,
670+ });
671+ overlay.draw({
672+ frame: cf,
673+ objects: overlayObjects(),
674+ body: { type: spec.body.type, params: sceneParams },
675+ exaggerate: viewState.exaggerate,
676+ wireframe: show.wire,
677+ showObjects: show.objects,
678+ });
679+ const slice = plotSlice();
680+ plot.draw(slice.u, plotScale(), darkMedia.matches);
681+ el('objlabel').textContent = slice.label;
682+ dirty = false;
683+ colorbar.setRange(-scale, scale);
684+ frames++;
685+
686+ const now = performance.now();
687+ if ((running || renderingClip) && now - lastPollAt > 250) {
688+ lastPollAt = now;
689+ void pollFields();
690+ }
691+ }
692+ const now = performance.now();
693+ if (now - lastFpsAt > 400) {
694+ msPerFrame = frames > 0 ? (now - lastFpsAt) / frames : 0;
695+ frames = 0;
696+ lastFpsAt = now;
697+ stats();
698+ showRecording();
699+ }
700+ requestAnimationFrame(frame);
701+}
702+
703+/* ---------------------------------------------------------- the spectrum -- */
704+
705+function drawSpec(): void {
706+ const canvasEl = el<HTMLCanvasElement>('spectrum');
707+ const legend = el('speclegend');
708+ legend.textContent = '';
709+ if (!session || !lastTrace) {
710+ const ctx = canvasEl.getContext('2d');
711+ ctx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
712+ return;
713+ }
714+ const fair = C_AIR / (POOR_RESOLUTION * session.air.h);
715+ const fmax = Math.min(6000, Math.max(1000, 1.3 * fair));
716+ const partials: PartialSet[] = spec.objects.map((o) =>
717+ predictedPartials(o, session!.grids[o.id], params, fmax),
718+ );
719+ drawSpectrum(canvasEl, {
720+ trace: lastTrace.trace,
721+ dt: lastTrace.dt,
722+ fmax,
723+ partials,
724+ dark: darkMedia.matches,
725+ });
726+ partials.forEach((set, i) => {
727+ const item = document.createElement('i');
728+ item.style.color = objectColor(i);
729+ item.textContent = `■ ${set.label}`;
730+ legend.append(item);
731+ });
732+}
733+
734+async function analyzeTrace(): Promise<Float32Array | null> {
735+ if (!session || session.recorder.count === 0) return null;
736+ const s = session;
737+ const trace = await s.recorder.read();
738+ lastTrace = { trace, dt: s.dt };
739+ drawSpec();
740+ return trace;
741+}
742+
743+/* ------------------------------------------------------- rendering a clip -- */
744+
745+/**
746+ * Excite, then run the solver as fast as the GPU will go — no display frames
747+ * in the way, just batches of steps with a sync between them so the queue
748+ * never runs unboundedly ahead — until the requested duration of audio is in
749+ * the trace. Then play it.
750+ */
751+async function renderClip(): Promise<void> {
752+ if (!session || building) return;
753+ const btn = el<HTMLButtonElement>('renderclip');
754+ if (renderingClip) {
755+ renderingClip = false; // cancel: the loop below notices and stops
756+ return;
757+ }
758+ renderingClip = true;
759+ setRunning(false);
760+ btn.textContent = 'Cancel';
761+ const prog = el<HTMLProgressElement>('renderprog');
762+ prog.hidden = false;
763+ prog.value = 0;
764+ const seconds = Number(el<HTMLSelectElement>('renderdur').value);
765+ try {
766+ excite();
767+ const s = session;
768+ const total = Math.min(Math.ceil(seconds / s.dt), s.recorder.capacity);
769+ const batch = 512;
770+ let done = 0;
771+ while (done < total && renderingClip && session === s) {
772+ const n = Math.min(batch, total - done);
773+ s.step(n);
774+ done += n;
775+ await s.sync();
776+ prog.value = done / total;
777+ }
778+ if (renderingClip && session === s) {
779+ const trace = await analyzeTrace();
780+ if (trace) {
781+ stopAudio();
782+ await playTrace(trace, planPlayback(trace.length, s.dt));
783+ }
784+ clearError();
785+ }
786+ } catch (e) {
787+ showError(e, '');
788+ } finally {
789+ renderingClip = false;
790+ prog.hidden = true;
791+ btn.textContent = 'Render clip';
792+ dirty = true;
793+ showRecording();
794+ }
795+}
796+
797+/* -------------------------------------------------------------- wiring --- */
798+
799+const TABS = ['play', 'build', 'lab'] as const;
800+for (const tab of TABS) {
801+ el(`tab-${tab}`).addEventListener('click', () => {
802+ for (const t of TABS) {
803+ el(`tab-${t}`).classList.toggle('active', t === tab);
804+ el(`panel-${t}`).classList.toggle('active', t === tab);
805+ }
806+ if (tab === 'lab') drawSpec();
807+ });
808+}
809+
810+function showFile(which: 'model' | 'scene'): void {
811+ if (!session) return;
812+ editor.value =
813+ sources[which] ?? (which === 'model' ? session.generated.modelSource : session.generated.sceneSource);
814+ const edited = sources[which] !== null ? ' — edited by hand' : ' — generated from the scene';
815+ el('editor-title').textContent =
816+ (which === 'model'
817+ ? 'init and step, all objects together, compiled to WebGPU'
818+ : 'medium(x, y, z, …) → walls and coupling, evaluated once on the CPU') + edited;
819+}
820+
821+el('gridsize').addEventListener('change', (e) => {
822+ gridNx = Number((e.target as HTMLSelectElement).value);
823+ void rebuild();
824+});
825+
826+el('bodytype').addEventListener('change', (e) => {
827+ writeBack();
828+ spec.body = { type: (e.target as HTMLSelectElement).value as SceneSpec['body']['type'], params: {} };
829+ presetKey = null;
830+ syncParams();
831+ Object.assign(sceneParams, defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs));
832+ refreshControls();
833+ void rebuild();
834+});
835+
836+const addObject = (type: ObjectType): void => {
837+ writeBack();
838+ const t = OBJECT_TEMPLATES[type];
839+ const size: { L1: number; L2?: number } = { L1: t.sizeSpecs[0].value };
840+ if (type === 'membrane') size.L2 = t.sizeSpecs[1].value;
841+ spec.objects.push({ id: nextId(type), type, size, params: {}, place: {} });
842+ presetKey = null;
843+ syncParams();
844+ refreshControls();
845+ void rebuild();
846+};
847+el('add-string').addEventListener('click', () => addObject('string'));
848+el('add-bar').addEventListener('click', () => addObject('bar'));
849+el('add-membrane').addEventListener('click', () => addObject('membrane'));
850+
851+const runPause = el<HTMLButtonElement>('runpause');
852+const setRunning = (r: boolean): void => {
853+ running = r;
854+ runPause.textContent = r ? 'Pause' : 'Run';
855+ frames = 0;
856+ lastFpsAt = performance.now();
857+ dirty = true;
858+};
859+runPause.addEventListener('click', () => setRunning(!running && !renderingClip));
860+
861+el('excite').addEventListener('click', excite);
862+
863+el('spf').addEventListener('change', (e) => {
864+ stepsPerFrame = Number((e.target as HTMLSelectElement).value);
865+});
866+
867+el('renderclip').addEventListener('click', () => void renderClip());
868+
869+const listen = el<HTMLButtonElement>('listen');
870+listen.addEventListener('click', () => {
871+ if (!session) return;
872+ const s = session;
873+ if (s.recorder.count === 0) {
874+ showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
875+ return;
876+ }
877+ listen.disabled = true;
878+ stopAudio();
879+ void analyzeTrace()
880+ .then((trace) => (trace ? playTrace(trace, planPlayback(trace.length, s.dt)) : null))
881+ .then(() => clearError())
882+ .catch((e: unknown) => showError(e, ''))
883+ .finally(() => {
884+ listen.disabled = false;
885+ });
886+});
887+
888+el('download').addEventListener('click', () => {
889+ if (!session) return;
890+ const s = session;
891+ if (s.recorder.count === 0) {
892+ showError(new Error('the microphone has not recorded anything yet — Run, or Render clip'), '');
893+ return;
894+ }
895+ void s.recorder
896+ .read()
897+ .then((trace) => {
898+ const url = URL.createObjectURL(traceToWav(trace, s.dt));
899+ const a = document.createElement('a');
900+ a.href = url;
901+ a.download = 'soundbox.wav';
902+ a.click();
903+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
904+ })
905+ .catch((e: unknown) => showError(e, ''));
906+});
907+
908+colormapSelect.addEventListener('change', () => {
909+ colormapName = colormapSelect.value;
910+ view.setColormap(colormaps[colormapName]);
911+ colorbar.setColormap(colormaps[colormapName]);
912+ dirty = true;
913+});
914+
915+el('scalemode').addEventListener('change', (e) => {
916+ autoScale = (e.target as HTMLSelectElement).value === 'auto';
917+});
918+
919+const bindRange = (id: string, apply: (v: number) => void): void => {
920+ el<HTMLInputElement>(id).addEventListener('input', (e) => {
921+ apply(Number((e.target as HTMLInputElement).value));
922+ dirty = true;
923+ });
924+};
925+bindRange('opacity', (v) => (viewState.opacity = v));
926+bindRange('contrast', (v) => (viewState.contrast = v));
927+bindRange('clipy', (v) => (viewState.clipFrac = v));
928+bindRange('exaggerate', (v) => (viewState.exaggerate = v));
929+el('quality').addEventListener('change', (e) => {
930+ viewState.quality = Number((e.target as HTMLSelectElement).value);
931+ dirty = true;
932+});
933+
934+const bindCheck = (id: string, apply: (v: boolean) => void): void => {
935+ el<HTMLInputElement>(id).addEventListener('change', (e) => {
936+ apply((e.target as HTMLInputElement).checked);
937+ dirty = true;
938+ });
939+};
940+bindCheck('showfield', (v) => (show.field = v));
941+bindCheck('showbody', (v) => (show.body = v));
942+bindCheck('showobjects', (v) => (show.objects = v));
943+bindCheck('showwire', (v) => (show.wire = v));
944+bindCheck('showmic', (v) => (show.mic = v));
945+
946+el<HTMLSelectElement>('objpick').addEventListener('change', (e) => {
947+ plotId = (e.target as HTMLSelectElement).value;
948+ dirty = true;
949+});
950+
951+// --- orbit ---------------------------------------------------------------
952+let dragging = false;
953+let last = [0, 0];
954+canvas.addEventListener('pointerdown', (e) => {
955+ dragging = true;
956+ last = [e.clientX, e.clientY];
957+ canvas.setPointerCapture(e.pointerId);
958+});
959+canvas.addEventListener('pointermove', (e) => {
960+ if (!dragging) return;
961+ camera.az -= (e.clientX - last[0]) * 0.008;
962+ camera.el = Math.min(1.5, Math.max(-1.5, camera.el + (e.clientY - last[1]) * 0.008));
963+ last = [e.clientX, e.clientY];
964+ dirty = true;
965+});
966+canvas.addEventListener('pointerup', () => (dragging = false));
967+canvas.addEventListener('pointercancel', () => (dragging = false));
968+canvas.addEventListener(
969+ 'wheel',
970+ (e) => {
971+ e.preventDefault();
972+ camera.dist = Math.min(5, Math.max(0.7, camera.dist * Math.exp(e.deltaY * 0.001)));
973+ dirty = true;
974+ },
975+ { passive: false },
976+);
977+
978+editorFile.addEventListener('change', () => {
979+ showFile(editorFile.value as 'model' | 'scene');
980+});
981+
982+el('recompile').addEventListener('click', () => {
983+ void rebuild();
984+});
985+
986+el('revert').addEventListener('click', () => {
987+ sources[editorFile.value as 'model' | 'scene'] = null;
988+ void rebuild();
989+});
990+
991+darkMedia.addEventListener('change', () => {
992+ dirty = true;
993+ drawSpec();
994+});
995+
996+/* ---------------------------------------------------------------- start -- */
997+
998+refreshControls();
999+await rebuild();
1000+// The initial excitation shapes, so the page opens showing something.
1001+void pollFields();
1002+requestAnimationFrame(frame);
src/mgpu/compile.tsadded+207−0View file
@@ -0,0 +1,207 @@
1+/**
2+ * MATLAB source -> numbl's JIT IR, ready for the WGSL backend.
3+ *
4+ * A model file defines ordinary MATLAB functions; the host specializes the ones
5+ * it needs (`init`, `step`) for the concrete argument types of the current
6+ * grid. This is exactly how numbl drives its own JIT — the caller supplies
7+ * argument types, and lowering fixes every type and shape from there.
8+ *
9+ * Driving it through function signatures rather than injected scope means the
10+ * .m declares what it needs: each parameter name is matched against what the
11+ * host offers, and a name the host does not provide is a compile error rather
12+ * than a silently undefined variable.
13+ *
14+ * Two numbl passes matter here:
15+ * - `specializeUserFunction` lowers one function to IR, one statement per
16+ * operation (ANF), with every node's type fixed.
17+ * - `inlinePass` then folds single-use temps back into their consumer, so a
18+ * source line like `pn = 2*p - pm + cdt2 .* lap` becomes ONE statement whose
19+ * RHS is an expression tree — i.e. one GPU kernel instead of four.
20+ */
21+import { parseMFile } from 'numbl-src/numbl-core/parser/index.ts';
22+import { Workspace, Lowerer, tensorDouble, scalarDouble } from 'numbl-src/numbl-core/jit/index.ts';
23+import { specializeUserFunction } from 'numbl-src/numbl-core/jit/lowering/specialize.ts';
24+import { inlinePass } from 'numbl-src/numbl-core/jit/codegen/inlinePass.ts';
25+import type { IRFunc, IRStmt } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
26+import type { Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
27+import { fuseTemps } from './fuse.ts';
28+import { externalOpFiles } from './externals.ts';
29+import type { OpInstance } from './ops.ts';
30+import { ModelCompileError } from './errors.ts';
31+
32+/** What the host can supply for an argument the .m declares. */
33+export type Binding =
34+ /** An array, passed in a GPU buffer. */
35+ | { kind: 'tensor'; shape: number[] }
36+ /** A tunable scalar. Deliberately carries no exact value: an exact scalar
37+ * would be constant-folded into the kernels, so moving a slider would force
38+ * a recompile instead of just rewriting a uniform. */
39+ | { kind: 'param' }
40+ /** A fixed scalar, exact so array constructors reading it keep static
41+ * shapes. */
42+ | { kind: 'const'; value: number };
43+
44+const typeOf = (b: Binding): Type => {
45+ switch (b.kind) {
46+ case 'tensor':
47+ return tensorDouble(b.shape);
48+ case 'param':
49+ return scalarDouble('unknown');
50+ case 'const':
51+ // Carry the sign too: numbl's sign lattice decides, for instance,
52+ // whether sqrt() of a value can go complex.
53+ return scalarDouble(
54+ b.value > 0 ? 'positive' : b.value < 0 ? 'negative' : 'zero',
55+ b.value,
56+ );
57+ }
58+};
59+
60+/** One specialized function, as the planner consumes it. */
61+export interface CompiledFunction {
62+ name: string;
63+ /** Declared arguments, in order, with the cName each lowered to. */
64+ params: { name: string; cName: string; binding: Binding }[];
65+ /** Requested outputs, in order, with the cName holding each result. */
66+ outputs: { name: string; cName: string; ty: Type }[];
67+ /** The lowered body. Read this only after `finish()`: the inline pass
68+ * REPLACES the statement array rather than mutating it, so this is a live
69+ * view of the function rather than a snapshot. */
70+ readonly body: IRStmt[];
71+}
72+
73+/** The shape of a `function` statement in numbl's AST. */
74+interface FunctionDecl {
75+ type: 'Function';
76+ name: string;
77+ params: string[];
78+ outputs: string[];
79+}
80+
81+/**
82+ * A parsed model. Specialize the functions you need, then call `finish()` once
83+ * — the inline pass rewrites every specialization together.
84+ */
85+export class CompiledModel {
86+ #lowerer: Lowerer;
87+ #decls: Map<string, FunctionDecl>;
88+ #bindings: Record<string, Binding>;
89+
90+ constructor(
91+ source: string,
92+ bindings: Record<string, Binding>,
93+ ops: Pick<OpInstance, 'name' | 'argCounts' | 'outCount'>[],
94+ fileName = 'model.m',
95+ ) {
96+ const ast = parseMFile(source, fileName);
97+ const ws = new Workspace(fileName, []);
98+ ws.addFile({ name: fileName, source, ast });
99+ // The scene's op instances (dxx_s1, lapw, ...) become resolvable, with
100+ // their type rules.
101+ for (const f of externalOpFiles(ops)) ws.addFile(f);
102+ ws.finalize();
103+
104+ this.#bindings = bindings;
105+ this.#lowerer = new Lowerer(ws);
106+ this.#decls = new Map();
107+ for (const stmt of ast.body as { type: string }[]) {
108+ if (stmt.type === 'Function') {
109+ const fn = stmt as unknown as FunctionDecl;
110+ this.#decls.set(fn.name, fn);
111+ }
112+ }
113+ }
114+
115+ /** Names of the functions the file defines. */
116+ functionNames(): string[] {
117+ return [...this.#decls.keys()];
118+ }
119+
120+ /**
121+ * Lower `name` for the current bindings, requesting `nargout` outputs.
122+ * Every declared parameter must name something the host provides.
123+ */
124+ specialize(name: string, nargout: number): CompiledFunction {
125+ const decl = this.#decls.get(name);
126+ if (!decl) {
127+ const defined = this.functionNames();
128+ throw new ModelCompileError(
129+ `the model must define a function named '${name}'` +
130+ (defined.length
131+ ? ` (it defines ${defined.map((n) => `'${n}'`).join(', ')})`
132+ : ' (it defines no functions)'),
133+ );
134+ }
135+ if (decl.outputs.length < nargout) {
136+ throw new ModelCompileError(
137+ `'${name}' must return ${nargout} value${nargout === 1 ? '' : 's'}, ` +
138+ `but declares ${decl.outputs.length}`,
139+ );
140+ }
141+
142+ const bindings = decl.params.map((p) => {
143+ const b = this.#bindings[p];
144+ if (!b) {
145+ const offered = Object.keys(this.#bindings).join(', ');
146+ throw new ModelCompileError(
147+ `'${name}' takes an argument named '${p}', which this app does not ` +
148+ `provide. Available: ${offered}.`,
149+ );
150+ }
151+ return b;
152+ });
153+
154+ const fn: IRFunc = specializeUserFunction.call(
155+ this.#lowerer,
156+ decl,
157+ bindings.map(typeOf),
158+ undefined,
159+ undefined,
160+ undefined,
161+ nargout,
162+ undefined,
163+ );
164+
165+ return {
166+ name,
167+ params: fn.params.map((p, i) => ({
168+ name: p,
169+ cName: fn.cParams[i],
170+ binding: bindings[i],
171+ })),
172+ outputs: fn.outputs.slice(0, nargout).map((o, i) => ({
173+ name: o,
174+ cName: fn.cOutputs[i],
175+ ty: fn.outputTypes[i],
176+ })),
177+ // A getter, not a snapshot: `finish()` runs after every specialization
178+ // and swaps in a rewritten statement array.
179+ get body() {
180+ return fn.body;
181+ },
182+ };
183+ }
184+
185+ /**
186+ * Run the fusion passes over everything specialized so far. They rewrite the
187+ * function bodies in place, so `CompiledFunction`s handed out earlier are
188+ * updated too.
189+ *
190+ * Two of them: numbl's, which folds the temps its own C backend would fuse,
191+ * and this project's (src/mgpu/fuse.ts), which folds the ones it declines —
192+ * `sin`, `exp`, `tanh` and the rest, which WGSL evaluates per element just
193+ * as happily as it does a multiply.
194+ *
195+ * Neither touches a variable the .m names. In particular a bare `pold = p;`
196+ * — the line that turns this step's field into the next step's history —
197+ * survives as its own statement, and plans as the copy it is: numbl's pass
198+ * gives every declared output a protective use count, and ours only folds
199+ * compiler temps.
200+ */
201+ finish(): void {
202+ inlinePass({ topLevelStmts: [], functions: this.#lowerer.specializations });
203+ for (const fn of this.#lowerer.specializations.values()) {
204+ fn.body = fuseTemps(fn.body);
205+ }
206+ }
207+}
src/mgpu/errors.tsadded+105−0View file
@@ -0,0 +1,105 @@
1+/**
2+ * Compile failures, reported in coordinates of the model file the user edits.
3+ *
4+ * Failures arrive from three places, each with its own idea of position:
5+ * numbl's parser (a `position` offset), numbl's lowerer (`UnsupportedConstruct`
6+ * / `JitTypeError`, with a `span`), and this project's WGSL emitter
7+ * (`UnsupportedOnGpu`, carrying the numbl span it was given). All of them are
8+ * offsets into the whole model file — the file is parsed once, and each function
9+ * is specialized from that one AST — so they need only be turned into a line and
10+ * column for the editor.
11+ */
12+
13+/** A compile failure located in the full model source. */
14+export class ModelCompileError extends Error {
15+ /** Offset into the whole .m file, when the failure has a position. */
16+ readonly start?: number;
17+ readonly end?: number;
18+ /** Name of the model function being compiled. */
19+ readonly fn?: string;
20+
21+ constructor(
22+ message: string,
23+ opts: { start?: number; end?: number; fn?: string; cause?: unknown } = {},
24+ ) {
25+ super(message, { cause: opts.cause });
26+ this.name = 'ModelCompileError';
27+ this.start = opts.start;
28+ this.end = opts.end;
29+ this.fn = opts.fn;
30+ }
31+}
32+
33+/** Extract whatever position information an error carries. */
34+function positionOf(e: unknown): { start?: number; end?: number } {
35+ const span = (e as { span?: { start?: unknown; end?: unknown } }).span;
36+ if (span && typeof span.start === 'number') {
37+ return {
38+ start: span.start,
39+ end: typeof span.end === 'number' ? span.end : undefined,
40+ };
41+ }
42+ // numbl's parser SyntaxError reports a bare offset.
43+ const position = (e as { position?: unknown }).position;
44+ if (typeof position === 'number') return { start: position };
45+ return {};
46+}
47+
48+/** Normalize any thrown value into a located `ModelCompileError`. */
49+function asCompileError(e: unknown, fn?: string): ModelCompileError {
50+ if (e instanceof ModelCompileError) return e;
51+ const { start, end } = positionOf(e);
52+ const raw = e instanceof Error ? e.message : String(e);
53+ // numbl's parse errors read as bare token complaints out of context.
54+ const message =
55+ (e as Error)?.name === 'SyntaxError' ? `MATLAB syntax error: ${raw}` : raw;
56+ return new ModelCompileError(message, { fn, start, end, cause: e });
57+}
58+
59+/**
60+ * Run `fn`, locating any compile failure in the model file. Use for whole-file
61+ * phases (parsing) that belong to no single function.
62+ */
63+export function inModel<T>(fn: () => T): T {
64+ try {
65+ return fn();
66+ } catch (e) {
67+ throw asCompileError(e);
68+ }
69+}
70+
71+/** Run `fn`, attributing any compile failure to the model function `name`. */
72+export function inFunction<T>(name: string, fn: () => T): T {
73+ try {
74+ return fn();
75+ } catch (e) {
76+ throw asCompileError(e, name);
77+ }
78+}
79+
80+/** Async form of `inFunction`. */
81+export async function inFunctionAsync<T>(
82+ name: string,
83+ fn: () => Promise<T>,
84+): Promise<T> {
85+ try {
86+ return await fn();
87+ } catch (e) {
88+ throw asCompileError(e, name);
89+ }
90+}
91+
92+/** Render a failure for display: message, section, and 1-based line/column. */
93+export function formatFailure(e: unknown, source: string): string {
94+ const message = e instanceof Error ? e.message : String(e);
95+ if (!(e instanceof ModelCompileError)) return message;
96+ const where: string[] = [];
97+ if (e.start !== undefined && e.start <= source.length) {
98+ const before = source.slice(0, e.start);
99+ const line = before.split('\n').length;
100+ const column = e.start - before.lastIndexOf('\n');
101+ where.push(`line ${line}, column ${column}`);
102+ }
103+ if (e.fn) where.push(`in ${e.fn}()`);
104+ return where.length ? `${message} (${where.join(', ')})` : message;
105+}
src/mgpu/externals.tsadded+77−0View file
@@ -0,0 +1,77 @@
1+/**
2+ * Type rules for the host-provided operations a model .m can call.
3+ *
4+ * numbl needs only each op's *shape contract* in order to lower a call site.
5+ * It gets that from a `.mtoc2.js` workspace file — numbl's sanctioned
6+ * extension point for a JS-defined builtin (see `mtoc2UserFunctionsByName` in
7+ * numbl's LoweringContext). The file is evaluated in a bare CommonJS sandbox
8+ * with no imports available, so `transfer` builds numbl `Type` objects as
9+ * plain literals, with the element counts baked in by the generator below (a
10+ * grid change recompiles anyway).
11+ *
12+ * Unlike dulcimer, the op set here is not fixed: the scene builder
13+ * instantiates one op per (operation, object) pair, and this module turns
14+ * whatever set the scene produced into workspace files. The actual
15+ * implementations are the WGSL dispatches in src/mgpu/ops.ts.
16+ *
17+ * The `emit`/`cBody` exports exist only because the loader's contract
18+ * requires them; we never emit C.
19+ */
20+import type { OpInstance } from './ops.ts';
21+
22+const numericType = (rows: number): string =>
23+ `{ kind: "Numeric", elem: "double", isComplex: false, ` +
24+ `dims: [{ kind: "exact", value: ${rows} }, { kind: "exact", value: 1 }], ` +
25+ `shape: [${rows}, 1], sign: "unknown" }`;
26+
27+/** Source for one op's `.mtoc2.js`: fields in, field out, shapes checked. */
28+function opSource(inst: Pick<OpInstance, 'name' | 'argCounts' | 'outCount'>): string {
29+ const checks = inst.argCounts
30+ .map((n, i) => {
31+ return `
32+ var a${i} = argTypes[${i}];
33+ if (!a${i} || a${i}.kind !== "Numeric" || a${i}.isComplex) {
34+ throw new Error("${inst.name}: argument ${i + 1} must be a real numeric array");
35+ }
36+ var s${i} = a${i}.shape;
37+ if (!s${i} || s${i}.length !== 2 || s${i}[0] !== ${n} || s${i}[1] !== 1) {
38+ throw new Error(
39+ "${inst.name}: argument ${i + 1} must be a ${n}x1 field, not " +
40+ (s${i} ? s${i}.join("x") : "unknown shape")
41+ );
42+ }`;
43+ })
44+ .join('\n');
45+ return `
46+exports.name = ${JSON.stringify(inst.name)};
47+
48+exports.transfer = function (argTypes, nargout) {
49+ if (argTypes.length !== ${inst.argCounts.length}) {
50+ throw new Error("${inst.name} takes ${inst.argCounts.length} argument(s), got " + argTypes.length);
51+ }
52+ if (nargout > 1) {
53+ throw new Error("${inst.name} returns one value, but " + nargout + " were requested");
54+ }
55+${checks}
56+ return [${numericType(inst.outCount)}];
57+};
58+
59+// Never called: this project executes the IR on WebGPU and emits no C.
60+exports.emit = function () {
61+ throw new Error("${inst.name}: no C backend (this op runs on WebGPU)");
62+};
63+exports.cBody = function () {
64+ return "";
65+};
66+`;
67+}
68+
69+/** Workspace files that make the ops resolvable during lowering. */
70+export function externalOpFiles(
71+ instances: Pick<OpInstance, 'name' | 'argCounts' | 'outCount'>[],
72+): { name: string; source: string }[] {
73+ return instances.map((inst) => ({
74+ name: `${inst.name}.mtoc2.js`,
75+ source: opSource(inst),
76+ }));
77+}
src/mgpu/fuse.tsadded+136−0View file
@@ -0,0 +1,136 @@
1+/**
2+ * Fold the single-use ANF temps numbl's inline pass left behind.
3+ *
4+ * numbl's own pass (`inlinePass`) only folds producers its C backend can fuse,
5+ * which leaves out tensor-producing calls — `sin(x)`, `exp(x)`, `tanh(x)`. The
6+ * WGSL emitter fuses all of those happily, so without this pass a source line
7+ * like
8+ *
9+ * s = (amp * env) .* sin(2*pi*f*(t - t0)) .* exp(-(gx + gy));
10+ *
11+ * plans as half a dozen kernels rather than one, each writing a whole grid to
12+ * memory for the next one to read straight back.
13+ *
14+ * Same shape as numbl's pass, deliberately narrower where it matters: only
15+ * compiler temps (`_mtoc2_*`) are folded, so every variable the .m names keeps
16+ * its own buffer and one source line stays one kernel. The producer's RHS must
17+ * be something the emitter can evaluate per element, its result must be used
18+ * exactly once, and nothing between the two statements may write to anything
19+ * it reads.
20+ *
21+ * Adapted from math-webgpu-sandbox's src/mgpu/fuse.ts, trimmed to the
22+ * straight-line bodies this project compiles.
23+ */
24+import type { IRExpr, IRStmt } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
25+import { isGpuFusableExpr } from './wgsl.ts';
26+
27+const isTemp = (cName: string): boolean => cName.startsWith('_mtoc2_');
28+
29+export function fuseTemps(stmts: IRStmt[]): IRStmt[] {
30+ let cur = stmts;
31+ for (let iter = 0; iter < 32; iter++) {
32+ const next = fuseOnePass(cur);
33+ if (next === cur) break;
34+ cur = next;
35+ }
36+ return cur;
37+}
38+
39+/** One sweep, at most one fold. Returns the same array when nothing fired. */
40+function fuseOnePass(stmts: IRStmt[]): IRStmt[] {
41+ const uses = useCounts(stmts);
42+ for (let i = 0; i < stmts.length; i++) {
43+ const p = stmts[i];
44+ if (p.kind !== 'Assign' || !isTemp(p.cName)) continue;
45+ if (uses.get(p.cName) !== 1) continue;
46+ if (!isGpuFusableExpr(p.expr)) continue;
47+
48+ const reads = new Set<string>();
49+ walkVars(p.expr, (c) => reads.add(c));
50+
51+ for (let j = i + 1; j < stmts.length; j++) {
52+ const c = stmts[j];
53+ if (c.kind !== 'Assign') break; // anything else ends the safe window
54+ if (countIn(c.expr, p.cName) > 0) {
55+ // The one use. Fold into it if it is a kernel; if it is a stencil
56+ // call, or anything else that wants its argument in a buffer of its
57+ // own, leave the producer alone.
58+ if (countIn(c.expr, p.cName) === 1 && isGpuFusableExpr(c.expr)) {
59+ c.expr = substitute(c.expr, p.cName, p.expr);
60+ const out = stmts.slice();
61+ out.splice(i, 1);
62+ return out;
63+ }
64+ break;
65+ }
66+ // An intervening write to the temp itself, or to one of its operands,
67+ // invalidates the fold window. Statements that do neither are simply
68+ // skipped over — the stencil dispatch between the source term and the
69+ // update it feeds is exactly that case.
70+ if (c.cName === p.cName || reads.has(c.cName)) break;
71+ }
72+ }
73+ return stmts;
74+}
75+
76+function useCounts(stmts: IRStmt[]): Map<string, number> {
77+ const counts = new Map<string, number>();
78+ const bump = (c: string): void => {
79+ counts.set(c, (counts.get(c) ?? 0) + 1);
80+ };
81+ for (const s of stmts) if (s.kind === 'Assign') walkVars(s.expr, bump);
82+ return counts;
83+}
84+
85+function walkVars(e: IRExpr, visit: (cName: string) => void): void {
86+ const walk = (x: IRExpr): void => {
87+ switch (x.kind) {
88+ case 'Var':
89+ visit(x.cName);
90+ return;
91+ case 'Binary':
92+ walk(x.left);
93+ walk(x.right);
94+ return;
95+ case 'Unary':
96+ walk(x.operand);
97+ return;
98+ case 'Call':
99+ x.args.forEach(walk);
100+ return;
101+ default:
102+ return;
103+ }
104+ };
105+ walk(e);
106+}
107+
108+function countIn(e: IRExpr, cName: string): number {
109+ let n = 0;
110+ walkVars(e, (c) => {
111+ if (c === cName) n++;
112+ });
113+ return n;
114+}
115+
116+/** Replace the (single) `Var` read of `cName` with `replacement`. */
117+function substitute(e: IRExpr, cName: string, replacement: IRExpr): IRExpr {
118+ const sub = (x: IRExpr): IRExpr => {
119+ if (x.kind === 'Var' && x.cName === cName) return replacement;
120+ switch (x.kind) {
121+ case 'Binary':
122+ x.left = sub(x.left);
123+ x.right = sub(x.right);
124+ return x;
125+ case 'Unary':
126+ x.operand = sub(x.operand);
127+ return x;
128+ case 'Call':
129+ for (let i = 0; i < x.args.length; i++) x.args[i] = sub(x.args[i]);
130+ return x;
131+ default:
132+ return x;
133+ }
134+ };
135+ return sub(e);
136+}
src/mgpu/model.tsadded+278−0View file
@@ -0,0 +1,278 @@
1+/**
2+ * The .m model, compiled and running on the GPU.
3+ *
4+ * The model file is ordinary MATLAB: it defines an `init` function that builds
5+ * the initial state — each object shaped into its excitation, silent air — and
6+ * a `step` function that advances everything one timestep. Each is specialized
7+ * for the current grids and compiled into a ModelPlan, and both operate on the
8+ * same state buffers (see HostBuffers).
9+ *
10+ * Both functions return the state, in the same order, so their signatures say
11+ * exactly what they produce. The state spans several grids — a pair of history
12+ * fields per vibrating object, plus the air's pressure pair — and the compiled
13+ * statements mix freely: each line is one kernel over its own field's size,
14+ * and the external ops (src/mgpu/ops.ts) are where a value crosses from one
15+ * grid to another.
16+ *
17+ * Where dulcimer hardcoded its two grids by name, everything here is data the
18+ * caller supplies: `consts` are the exact scalars a .m may read (grid counts,
19+ * spacings, lengths — exact so array constructors keep static shapes),
20+ * `fields` are the host-uploaded arrays (coordinates, pin masks, the medium
21+ * and the coupling profiles), `state` names the fields the .m advances and how
22+ * big each is, and `paramNames` the tunable scalars. The .m names the
23+ * arguments it wants and the host matches each against this offer; a name
24+ * nobody provides is a compile error with a position, not a silently undefined
25+ * variable.
26+ *
27+ * There is no clock here, and that is not an oversight: every excitation is
28+ * an initial condition, not a driven source, so nothing in the model needs to
29+ * know what time it is.
30+ */
31+import { HostBuffers, ModelPlan } from './plan.ts';
32+import type { OpPlan } from './ops.ts';
33+import { inFunction, inFunctionAsync, inModel } from './errors.ts';
34+import { CompiledModel, type Binding } from './compile.ts';
35+
36+export interface ModelParams {
37+ [key: string]: number;
38+}
39+
40+/** One state field the .m advances, and its element count. */
41+export interface StateField {
42+ name: string;
43+ count: number;
44+}
45+
46+export interface GpuModelOptions {
47+ device: GPUDevice;
48+ ops: OpPlan;
49+ /** Exact scalars the .m may take as arguments (grid counts, spacings). */
50+ consts: Record<string, number>;
51+ /** Host-uploaded arrays the .m may take as arguments (coordinates, masks,
52+ * the medium, the coupling profiles), with their initial contents. */
53+ fields: Record<string, Float32Array>;
54+ /** Model source (.m text). */
55+ source: string;
56+ /** Tunable scalar names the .m may take as arguments. */
57+ paramNames: string[];
58+ /** State fields the .m advances, in the order its functions return them. */
59+ state: StateField[];
60+ /** Grid fields one kernel may read, overriding what the device allows.
61+ * Only for tests. */
62+ operandBudget?: number;
63+}
64+
65+export class GpuModel {
66+ readonly paramNames: string[];
67+ readonly state: StateField[];
68+
69+ #device: GPUDevice;
70+ #host: HostBuffers;
71+ #initPlan: ModelPlan;
72+ #stepPlan: ModelPlan;
73+ /** Names of the uploadable fields, for uploadFields' checks. */
74+ #fieldNames: Set<string>;
75+ /** Timestep, host-owned: it follows from the grid and the medium (a CFL
76+ * condition), not from anything the user types, and it is folded into every
77+ * setParams so the .m's `dt` is never left with the zero a missing
78+ * parameter would default to. */
79+ #dt = 0;
80+ #readback: GPUBuffer;
81+ /** Which function wrote the state most recently; see `read`. */
82+ #lastRan: 'init' | 'step' = 'init';
83+
84+ private constructor(init: {
85+ device: GPUDevice;
86+ host: HostBuffers;
87+ initPlan: ModelPlan;
88+ stepPlan: ModelPlan;
89+ readback: GPUBuffer;
90+ paramNames: string[];
91+ state: StateField[];
92+ fieldNames: Set<string>;
93+ }) {
94+ this.#device = init.device;
95+ this.#host = init.host;
96+ this.#initPlan = init.initPlan;
97+ this.#stepPlan = init.stepPlan;
98+ this.#readback = init.readback;
99+ this.paramNames = init.paramNames;
100+ this.state = init.state;
101+ this.#fieldNames = init.fieldNames;
102+ }
103+
104+ static async create(opts: GpuModelOptions): Promise<GpuModel> {
105+ const { device, ops, consts, fields, source, paramNames, state } = opts;
106+
107+ // What the .m may ask for by name. The grid geometry is exact, so a
108+ // constructor reading it (`zeros(npts, 1)`) keeps a static shape.
109+ const bindings: Record<string, Binding> = { dt: { kind: 'param' } };
110+ for (const [name, value] of Object.entries(consts)) {
111+ bindings[name] = { kind: 'const', value };
112+ }
113+ for (const [name, data] of Object.entries(fields)) {
114+ bindings[name] = { kind: 'tensor', shape: [data.length, 1] };
115+ }
116+ for (const s of state) bindings[s.name] = { kind: 'tensor', shape: [s.count, 1] };
117+ for (const p of paramNames) bindings[p] = { kind: 'param' };
118+
119+ // Parsing belongs to the file, not to either function.
120+ const compiled = inModel(() => new CompiledModel(source, bindings, ops.instances));
121+ const nargout = state.length;
122+ const initFn = inFunction('init', () => compiled.specialize('init', nargout));
123+ const stepFn = inFunction('step', () => compiled.specialize('step', nargout));
124+ compiled.finish();
125+
126+ // Both functions return the state, in order, and both feed it back into
127+ // the shared buffers.
128+ const feedback = state.map((s) => s.name);
129+
130+ const host = new HostBuffers(device);
131+ // The host owns the state and the inputs it uploads, whether or not a
132+ // given function happens to take them as arguments.
133+ for (const s of state) host.ensure(s.name, s.count);
134+ for (const [name, data] of Object.entries(fields)) host.ensure(name, data.length);
135+
136+ const initPlan = await inFunctionAsync('init', () =>
137+ ModelPlan.create(device, ops, { fn: initFn, feedback }, host, opts.operandBudget),
138+ );
139+ const stepPlan = await inFunctionAsync('step', () =>
140+ ModelPlan.create(device, ops, { fn: stepFn, feedback }, host, opts.operandBudget),
141+ );
142+
143+ for (const [name, data] of Object.entries(fields)) host.upload(name, data);
144+
145+ const maxCount = Math.max(...state.map((s) => s.count), ...Object.values(fields).map((f) => f.length));
146+ const readback = device.createBuffer({
147+ label: 'mgpu-readback',
148+ size: 4 * maxCount,
149+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
150+ });
151+
152+ return new GpuModel({
153+ device, host, initPlan, stepPlan, readback, paramNames, state,
154+ fieldNames: new Set(Object.keys(fields)),
155+ });
156+ }
157+
158+ /** The timestep in force. Host-owned; see `#dt`. */
159+ get dt(): number {
160+ return this.#dt;
161+ }
162+
163+ setDt(dt: number): void {
164+ this.#dt = dt;
165+ }
166+
167+ setParams(params: ModelParams): void {
168+ const merged = { dt: this.#dt, ...params };
169+ this.#initPlan.setParams(merged);
170+ this.#stepPlan.setParams(merged);
171+ }
172+
173+ /**
174+ * Swap host-uploaded fields under a running model — the medium and the
175+ * coupling profiles after a scene edit. They are data, not code — their
176+ * shapes in the bindings depend only on the grids — so changing the body's
177+ * geometry or moving an object is a few buffer writes and no recompile.
178+ */
179+ uploadFields(fields: Record<string, Float32Array>): void {
180+ for (const [name, data] of Object.entries(fields)) {
181+ if (!this.#fieldNames.has(name)) {
182+ throw new Error(`uploadFields: '${name}' is not a host field of this model`);
183+ }
184+ this.#host.upload(name, data);
185+ }
186+ }
187+
188+ /** Write a host-owned value directly. Lets a test set up an exact initial
189+ * condition instead of going through `init`. */
190+ upload(name: string, data: Float32Array): void {
191+ this.#host.upload(name, data);
192+ }
193+
194+ /** Run `init`, replacing the state. */
195+ init(): void {
196+ const enc = this.#device.createCommandEncoder({ label: 'mgpu-init' });
197+ this.#initPlan.encodeSteps(enc, 1);
198+ this.#device.queue.submit([enc.finish()]);
199+ this.#lastRan = 'init';
200+ }
201+
202+ /**
203+ * Advance `steps` timesteps. Synchronous — this only records commands and
204+ * submits them; nothing is read back and nothing is awaited.
205+ *
206+ * `after` is recorded once per step, so anything that must see every
207+ * timestep (the microphone) rides along in the same submission.
208+ */
209+ step(steps = 1, after?: (encoder: GPUCommandEncoder) => void): void {
210+ const enc = this.#device.createCommandEncoder({ label: 'mgpu-step' });
211+ this.#stepPlan.encodeSteps(enc, steps, after);
212+ this.#device.queue.submit([enc.finish()]);
213+ this.#lastRan = 'step';
214+ }
215+
216+ /**
217+ * The buffer currently holding a named value. A field the .m computes is
218+ * produced by both functions, into separate buffers (only the state is
219+ * shared), so this resolves to whichever function ran most recently — which
220+ * is what makes the first frame show the initial state rather than an
221+ * unwritten buffer.
222+ */
223+ #locate(name: string): { buffer: GPUBuffer; count: number } | null {
224+ const [first, second] =
225+ this.#lastRan === 'init'
226+ ? [this.#initPlan, this.#stepPlan]
227+ : [this.#stepPlan, this.#initPlan];
228+ const buffer = first.buffer(name) ?? second.buffer(name);
229+ const count = first.elementCount(name) ?? second.elementCount(name);
230+ if (!buffer || count === undefined) return null;
231+ return { buffer, count };
232+ }
233+
234+ /** The GPU buffer a named value would be read from right now. */
235+ valueBuffer(name: string): GPUBuffer | null {
236+ return this.#locate(name)?.buffer ?? null;
237+ }
238+
239+ /**
240+ * The buffer a host-owned field lives in — the state between calls, or an
241+ * input like the wall mask.
242+ *
243+ * This is what the renderer binds, and it must be this rather than
244+ * `valueBuffer`: a bind group is built once and holds a particular buffer,
245+ * while `init` and `step` write their outputs into buffers of their own and
246+ * only agree here, where their feedback copies land. Binding either
247+ * function's private buffer would draw a stale field for half the run.
248+ */
249+ stateBuffer(name: string): GPUBuffer | null {
250+ return this.#host.get(name)?.buffer ?? null;
251+ }
252+
253+ /** Read a named value back to the CPU. The only await in the whole loop. */
254+ async read(name: string): Promise<Float32Array> {
255+ const located = this.#locate(name);
256+ if (!located) throw new Error(`read: the model has no value named '${name}'`);
257+ const { buffer, count } = located;
258+ const enc = this.#device.createCommandEncoder({ label: `mgpu-read-${name}` });
259+ enc.copyBufferToBuffer(buffer, 0, this.#readback, 0, 4 * count);
260+ this.#device.queue.submit([enc.finish()]);
261+ await this.#readback.mapAsync(GPUMapMode.READ, 0, 4 * count);
262+ const out = new Float32Array(this.#readback.getMappedRange(0, 4 * count).slice(0));
263+ this.#readback.unmap();
264+ return out;
265+ }
266+
267+ /** What the .m compiled to, for display. */
268+ describe(): { init: string[]; step: string[] } {
269+ return { init: this.#initPlan.describe(), step: this.#stepPlan.describe() };
270+ }
271+
272+ destroy(): void {
273+ this.#initPlan.destroy();
274+ this.#stepPlan.destroy();
275+ this.#host.destroy();
276+ this.#readback.destroy();
277+ }
278+}
src/mgpu/numbl.d.tsadded+366−0View file
@@ -0,0 +1,366 @@
1+/**
2+ * The numbl compiler surface this project depends on.
3+ *
4+ * We reach past numbl's published entry points into its internals — the JIT
5+ * side (parser, lowerer, IR, inline pass) that compiles the models, and the
6+ * interpreter side (executeCode, runtime values) that evaluates the
7+ * scenes — which its package `exports` map does not expose. Those imports
8+ * resolve through the `numbl-src` alias in vite.config.ts; these declarations
9+ * are what TypeScript checks against.
10+ *
11+ * Declaring the surface here rather than type-checking numbl's sources
12+ * directly keeps this project's compiler settings independent of numbl's, and
13+ * pins the exact contract we rely on. If numbl changes one of these shapes,
14+ * the build breaks here with a clear diff rather than deep inside its tree.
15+ *
16+ * Only the nodes the WGSL backend actually walks are spelled out; every other
17+ * IR kind is collapsed into a catch-all so that unhandled constructs are
18+ * rejected with a message instead of being silently mis-compiled.
19+ */
20+
21+declare module 'numbl-src/numbl-core/jit/lowering/types.ts' {
22+ export type Sign =
23+ | 'positive' | 'nonneg' | 'negative' | 'nonpositive'
24+ | 'zero' | 'nonzero' | 'unknown';
25+
26+ export type DimInfo = { kind: 'exact'; value: number } | { kind: 'unknown' };
27+
28+ export type NumericExact =
29+ | number
30+ | Float64Array
31+ | { re: number; im: number }
32+ | { re: Float64Array; im: Float64Array };
33+
34+ export interface NumericType {
35+ kind: 'Numeric';
36+ elem: 'double' | 'logical' | 'char' | string;
37+ isComplex: boolean;
38+ dims: DimInfo[];
39+ /** Present iff every dim is exact. */
40+ shape?: number[];
41+ sign: Sign;
42+ exact?: NumericExact;
43+ }
44+
45+ /** Everything the WGSL backend rejects. */
46+ export interface NonNumericType {
47+ kind: 'Void' | 'Unknown' | 'String' | 'Handle' | 'Struct' | 'Class' | 'Cell';
48+ }
49+
50+ export type Type = NumericType | NonNumericType;
51+
52+ export function isMultiElement(t: NumericType): boolean;
53+ export function tensorDouble(shape: number[], exact?: Float64Array): NumericType;
54+ export function scalarDouble(sign?: Sign, exact?: number): NumericType;
55+}
56+
57+declare module 'numbl-src/numbl-core/jit/lowering/ir.ts' {
58+ import type { Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
59+
60+ export interface Span {
61+ file: string;
62+ start: number;
63+ end: number;
64+ }
65+
66+ export interface NumLit {
67+ kind: 'NumLit';
68+ value: number;
69+ ty: Type;
70+ span: Span;
71+ }
72+ export interface Var {
73+ kind: 'Var';
74+ name: string;
75+ cName: string;
76+ ty: Type;
77+ span: Span;
78+ }
79+ export interface Binary {
80+ kind: 'Binary';
81+ builtin: string;
82+ left: IRExpr;
83+ right: IRExpr;
84+ ty: Type;
85+ span: Span;
86+ }
87+ export interface Unary {
88+ kind: 'Unary';
89+ builtin: string;
90+ operand: IRExpr;
91+ ty: Type;
92+ span: Span;
93+ }
94+ export interface Call {
95+ kind: 'Call';
96+ cName: string;
97+ name: string;
98+ args: IRExpr[];
99+ ty: Type;
100+ span: Span;
101+ }
102+ /** Any other IR expression kind — rejected by the WGSL emitter. */
103+ export interface OtherExpr {
104+ kind:
105+ | 'ImagLit' | 'StringLit' | 'TensorBuild' | 'TensorConcat' | 'CellLit'
106+ | 'CellEmpty' | 'CellIndexLoad' | 'HandleLit' | 'HandleCaptureLoad'
107+ | 'StructLit' | 'MemberLoad' | 'IndexLoad' | 'IndexSlice' | 'EndRef'
108+ | 'MakeRange';
109+ ty: Type;
110+ span: Span;
111+ }
112+
113+ export type IRExpr = NumLit | Var | Binary | Unary | Call | OtherExpr;
114+
115+ export interface Assign {
116+ kind: 'Assign';
117+ name: string;
118+ cName: string;
119+ ty: Type;
120+ expr: IRExpr;
121+ span: Span;
122+ }
123+ /**
124+ * A counted loop. The planner unrolls it, so only the fields that decide
125+ * the trip count and the loop variable's value are spelled out. `step` is
126+ * already a literal number in the IR — numbl rejects a non-literal step
127+ * during lowering — while `start` and `end` are expressions that must carry
128+ * an exact value for the planner to accept the loop.
129+ */
130+ export interface For {
131+ kind: 'For';
132+ /** Loop variable, as written in the .m. */
133+ varName: string;
134+ /** Loop variable's cName, the key the planner binds its value under. */
135+ cVar: string;
136+ start: IRExpr;
137+ step: number;
138+ end: IRExpr;
139+ body: IRStmt[];
140+ span: Span;
141+ }
142+ /**
143+ * Multi-output call statement: `[a, b] = f(x, y)`. For `isBuiltin: true`
144+ * the builtin's `transfer(argTypes, nargout)` typed the slots during
145+ * lowering; args arrive ANF'd. The planner accepts this only for the
146+ * batched transforms (`synth`/`analys`), where output k is the transform
147+ * of argument k.
148+ */
149+ export interface MultiAssignCall {
150+ kind: 'MultiAssignCall';
151+ cName: string;
152+ name: string;
153+ isBuiltin?: boolean;
154+ args: IRExpr[];
155+ outputs: ReadonlyArray<{
156+ ty: Type;
157+ binding: { name: string; cName: string } | null;
158+ }>;
159+ span: Span;
160+ }
161+
162+ /** Any other IR statement kind — rejected by the planner. */
163+ export interface OtherStmt {
164+ kind:
165+ | 'ExprStmt' | 'If' | 'While' | 'ReturnFromFunction' | 'Break'
166+ | 'Continue' | 'TypeComment' | 'MemberStore'
167+ | 'IndexStore' | 'IndexSliceStore' | 'CellIndexStore';
168+ span: Span;
169+ }
170+
171+ export type IRStmt = Assign | For | MultiAssignCall | OtherStmt;
172+
173+ export interface IRFunc {
174+ name: string;
175+ cName: string;
176+ /** Parameter source names. */
177+ params: string[];
178+ /** Parameter cNames, parallel to `params`. */
179+ cParams: string[];
180+ paramTypes: Type[];
181+ /** Output source names. */
182+ outputs: string[];
183+ /** Output cNames, parallel to `outputs`. */
184+ cOutputs: string[];
185+ outputTypes: Type[];
186+ body: IRStmt[];
187+ span: Span;
188+ }
189+
190+ export interface IRProgram {
191+ topLevelStmts: IRStmt[];
192+ functions: Map<string, IRFunc>;
193+ }
194+}
195+
196+declare module 'numbl-src/numbl-core/parser/index.ts' {
197+ export interface ParseSpan {
198+ start: number;
199+ end: number;
200+ }
201+
202+ /** The one parse-tree node this project inspects (src/geom/geometry.ts,
203+ * finding `shape` and its argument names). */
204+ export interface FunctionStmt {
205+ type: 'Function';
206+ name: string;
207+ params: string[];
208+ outputs: string[];
209+ span: ParseSpan;
210+ }
211+
212+ /** Any other statement in a file's body — opaque to this project. Its
213+ * `type` is some other literal; narrowing to FunctionStmt goes through an
214+ * explicit type guard rather than the discriminant. */
215+ export interface OtherParseStmt {
216+ type: string;
217+ span: ParseSpan;
218+ }
219+
220+ export type Stmt = FunctionStmt | OtherParseStmt;
221+
222+ export interface AbstractSyntaxTree {
223+ body: Stmt[];
224+ }
225+ export function parseMFile(input: string, fileName?: string): AbstractSyntaxTree;
226+ export class SyntaxError extends Error {}
227+}
228+
229+declare module 'numbl-src/numbl-core/runtime/types.ts' {
230+ /** A numeric array: f64 data in column-major order, with its shape. */
231+ export class RuntimeTensor {
232+ readonly kind: 'tensor';
233+ data: Float64Array;
234+ /** Present iff the value is complex. */
235+ imag: Float64Array | undefined;
236+ shape: number[];
237+ constructor(data: Float64Array, shape: number[], imag?: Float64Array);
238+ }
239+
240+ /** Every other value kind the interpreter can hold, collapsed. */
241+ export interface OtherRuntimeValue {
242+ readonly kind: string;
243+ }
244+
245+ export type RuntimeValue =
246+ | number
247+ | boolean
248+ | string
249+ | RuntimeTensor
250+ | OtherRuntimeValue;
251+
252+ export function isRuntimeTensor(value: RuntimeValue): value is RuntimeTensor;
253+}
254+
255+declare module 'numbl-src/numbl-core/executeCode.ts' {
256+ import type { RuntimeValue } from 'numbl-src/numbl-core/runtime/types.ts';
257+
258+ export interface ExecOptions {
259+ /** Variables pre-bound in the script's workspace before it runs. */
260+ initialVariableValues?: Record<string, RuntimeValue>;
261+ displayResults?: boolean;
262+ onOutput?: (text: string) => void;
263+ /** null opts out of scanning a working directory for .m files. */
264+ implicitCwdPath?: string | null;
265+ }
266+
267+ export interface ExecWorkspaceFile {
268+ name: string;
269+ source: string;
270+ }
271+
272+ export interface ExecResult {
273+ output: string[];
274+ /** The script's workspace after it ran. */
275+ variableValues: Record<string, RuntimeValue>;
276+ }
277+
278+ /** Run a script through numbl's interpreter (with its JS-JIT), CPU-side. */
279+ export function executeCode(
280+ source: string,
281+ options?: ExecOptions,
282+ workspaceFiles?: ExecWorkspaceFile[],
283+ mainFileName?: string,
284+ ): ExecResult;
285+}
286+
287+declare module 'numbl-src/numbl-core/jit/index.ts' {
288+ import type { AbstractSyntaxTree } from 'numbl-src/numbl-core/parser/index.ts';
289+ import type { IRProgram, IRFunc, Span } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
290+ import type { Type, NumericType, Sign } from 'numbl-src/numbl-core/jit/lowering/types.ts';
291+
292+ export interface WorkspaceFile {
293+ name: string;
294+ source: string;
295+ ast?: AbstractSyntaxTree;
296+ }
297+
298+ export class Workspace {
299+ constructor(mainFile: string, searchPaths?: ReadonlyArray<string>);
300+ addFile(file: WorkspaceFile): void;
301+ finalize(): void;
302+ }
303+
304+ export interface EnvEntry {
305+ cName: string;
306+ ty: Type;
307+ maybeUnassigned?: boolean;
308+ }
309+
310+ export class Lowerer {
311+ constructor(workspace: Workspace);
312+ /** Pre-bindable variable scope: seed host-provided values here. */
313+ env: Map<string, EnvEntry>;
314+ specializations: Map<string, IRFunc>;
315+ lowerProgram(ast: AbstractSyntaxTree): IRProgram;
316+ }
317+
318+ /** Thrown for MATLAB the JIT pipeline cannot lower; carries a source span. */
319+ export class UnsupportedConstruct extends Error {
320+ span?: Span;
321+ }
322+ export class JitTypeError extends Error {
323+ span?: Span;
324+ }
325+
326+ export function tensorDouble(shape: number[], exact?: Float64Array): NumericType;
327+ export function scalarDouble(sign?: Sign, exact?: number): NumericType;
328+ export function isMultiElement(t: NumericType): boolean;
329+}
330+
331+declare module 'numbl-src/numbl-core/jit/lowering/specialize.ts' {
332+ import type { Lowerer } from 'numbl-src/numbl-core/jit/index.ts';
333+ import type { IRFunc, IRExpr, Span } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
334+ import type { Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
335+
336+ /**
337+ * Lower one user function for a concrete argument-type signature. Called with
338+ * a `Lowerer` as `this` (numbl's own JIT does the same), so specializations
339+ * accumulate in `lowerer.specializations`.
340+ */
341+ export function specializeUserFunction(
342+ this: Lowerer,
343+ decl: unknown,
344+ argTypes: Type[],
345+ specSource?: string,
346+ definingFile?: string,
347+ preSeedOutput?: { name: string; ty: Type; initExpr: IRExpr },
348+ nargout?: number,
349+ callSiteSpan?: Span,
350+ ): IRFunc;
351+}
352+
353+declare module 'numbl-src/numbl-core/jit/codegen/inlinePass.ts' {
354+ import type { IRProgram } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
355+ /** Folds single-use ANF temps into their consumer, in place. */
356+ export function inlinePass(prog: IRProgram): void;
357+}
358+
359+declare module 'numbl-src/numbl-core/jit/builtins/index.ts' {
360+ export interface Builtin {
361+ name: string;
362+ /** Safe to evaluate one output element from one input element per slot. */
363+ elementwise?: boolean;
364+ }
365+ export function getBuiltin(name: string): Builtin | undefined;
366+}
src/mgpu/ops.tsadded+339−0View file
@@ -0,0 +1,339 @@
1+/**
2+ * The external operations, as GPU dispatches — instantiated per object.
3+ *
4+ * Everything a compiled model does is element-wise, so these ops are the only
5+ * places where a point reads anything but itself: the difference stencils on
6+ * each object's own grid, the wall-masked Laplacian in the air, and the
7+ * couplings that carry an object's motion into the air. Where dulcimer had a
8+ * fixed set of five ops over exactly two grids, here the scene builder
9+ * instantiates one op per (operation, object) pair — `dxx_s1` is the second
10+ * difference on string s1's grid, `rad_m1` samples membrane m1 at each air
11+ * point — each with its own geometry baked into its WGSL. The generated
12+ * model .m calls them by these names.
13+ *
14+ * Layouts live only here and in the renderer; a .m never sees them. An air
15+ * field is an npts x 1 column vector laid out x-fastest — the point
16+ * (ix, iy, iz) is element `ix + nx*(iy + ny*iz)` — and an object field is n1
17+ * (times n2) nodes over its local 0..L1 (x 0..L2) coordinates, first axis
18+ * fastest. In world coordinates every object is centred (see src/grids.ts),
19+ * which is what the coupling ops assume; where an object sits in y and z is
20+ * carried by the scene-built profile fields instead, so it costs no
21+ * recompile to move one.
22+ *
23+ * `lapw` is the wall-masked Laplacian, and it is what makes a body's shell
24+ * rigid. Each of the six face terms is scaled by the mask at the neighbour it
25+ * reads, so a face into the wall contributes nothing — the discrete Neumann
26+ * (zero normal velocity, sound-hard) condition, in the divergence form
27+ * div(w grad p). The alternative, a wall as a fast material, would cut the
28+ * global timestep several-fold; a mask costs nothing. Outside the domain the
29+ * field is taken to be zero (a soft outer boundary); the scene's absorbing
30+ * layer is meant to have swallowed the wave before it matters.
31+ */
32+import { UnsupportedOnGpu, WORKGROUP_SIZE } from './wgsl.ts';
33+import type { AirGrid, ObjectGrid } from '../grids.ts';
34+
35+export type OpKind = 'dxx' | 'dxxxx' | 'lap2' | 'lapw' | 'radline' | 'radrect' | 'bridge';
36+
37+/** One instantiated operation: its name in the .m, its shape contract, and
38+ * its WGSL. */
39+export interface OpInstance {
40+ name: string;
41+ kind: OpKind;
42+ /** Element counts of the arguments, in order. */
43+ argCounts: number[];
44+ outCount: number;
45+ code: string;
46+}
47+
48+const lineAt = (n: number): string => `
49+fn at(j: i32) -> f32 {
50+ if (j < 0 || j >= ${n}) { return 0.0; }
51+ return src[u32(j)];
52+}
53+`;
54+
55+const header = (args: number): string =>
56+ [
57+ `@group(0) @binding(0) var<storage, read_write> dst: array<f32>;`,
58+ `@group(0) @binding(1) var<storage, read> src: array<f32>;`,
59+ ...(args > 1
60+ ? [`@group(0) @binding(2) var<storage, read> src2: array<f32>;`]
61+ : []),
62+ ].join('\n');
63+
64+/** dxx_<id>: second difference along a line grid, zero beyond the ends. */
65+export function makeDxx(name: string, g: ObjectGrid): OpInstance {
66+ const n = g.n1;
67+ const code = `${header(1)}
68+${lineAt(n)}
69+@compute @workgroup_size(${WORKGROUP_SIZE})
70+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
71+ let i = gid.x;
72+ if (i >= ${n}u) { return; }
73+ let j = i32(i);
74+ dst[i] = ${1 / (g.h1 * g.h1)}f * (at(j - 1) - 2.0 * at(j) + at(j + 1));
75+}
76+`;
77+ return { name, kind: 'dxx', argCounts: [n], outCount: n, code };
78+}
79+
80+/**
81+ * dxxxx_<id>: fourth difference along a line grid, with *antisymmetric*
82+ * ghost values (u(-k) = -u(k), and mirrored at the far end). Together with
83+ * the pinned endpoints this is the simply-supported end condition (u = 0 and
84+ * u'' = 0), and it makes the discrete operator exactly the square of the
85+ * pinned second difference — so the modes are sines and a bar's partials
86+ * land where the dispersion relation f_n = kap·lam_n/2π says, which is what
87+ * the spectrum overlay draws. Zero ghosts here would quietly clamp the ends
88+ * instead, and the pitch knob would lie by a factor approaching two.
89+ */
90+export function makeDxxxx(name: string, g: ObjectGrid): OpInstance {
91+ const n = g.n1;
92+ const inv4 = 1 / (g.h1 * g.h1 * g.h1 * g.h1);
93+ const code = `${header(1)}
94+fn at(j: i32) -> f32 {
95+ var k = j;
96+ var s = 1.0;
97+ if (k < 0) { k = -k; s = -1.0; }
98+ if (k > ${n - 1}) { k = ${2 * (n - 1)} - k; s = -s; }
99+ if (k < 0 || k > ${n - 1}) { return 0.0; }
100+ return s * src[u32(k)];
101+}
102+@compute @workgroup_size(${WORKGROUP_SIZE})
103+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
104+ let i = gid.x;
105+ if (i >= ${n}u) { return; }
106+ let j = i32(i);
107+ dst[i] = ${inv4}f * (at(j - 2) - 4.0 * at(j - 1) + 6.0 * at(j) - 4.0 * at(j + 1) + at(j + 2));
108+}
109+`;
110+ return { name, kind: 'dxxxx', argCounts: [n], outCount: n, code };
111+}
112+
113+/**
114+ * lap_<id>: 5-point Laplacian on a rectangular grid, zero beyond the rim.
115+ * The rim itself is pinned by the object's `pin` mask, so reading zero
116+ * outside is consistent with the boundary the scheme imposes.
117+ */
118+export function makeLap2(name: string, g: ObjectGrid): OpInstance {
119+ const { n1, n2, npts, h1, h2 } = g;
120+ const code = `${header(1)}
121+fn at(i: i32, j: i32) -> f32 {
122+ if (i < 0 || i >= ${n1} || j < 0 || j >= ${n2}) { return 0.0; }
123+ return src[u32(i + ${n1} * j)];
124+}
125+@compute @workgroup_size(${WORKGROUP_SIZE})
126+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
127+ let k = gid.x;
128+ if (k >= ${npts}u) { return; }
129+ let i = i32(k % ${n1}u);
130+ let j = i32(k / ${n1}u);
131+ let c = at(i, j);
132+ dst[k] = ${1 / (h1 * h1)}f * (at(i - 1, j) - 2.0 * c + at(i + 1, j))
133+ + ${1 / (h2 * h2)}f * (at(i, j - 1) - 2.0 * c + at(i, j + 1));
134+}
135+`;
136+ return { name, kind: 'lap2', argCounts: [npts], outCount: npts, code };
137+}
138+
139+/** lapw: the wall-masked 7-point Laplacian in the air. One instance. */
140+export function makeLapw(name: string, air: AirGrid): OpInstance {
141+ const { nx, ny, nz, npts, h } = air;
142+ const code = `@group(0) @binding(0) var<storage, read_write> dst: array<f32>;
143+@group(0) @binding(1) var<storage, read> src: array<f32>;
144+@group(0) @binding(2) var<storage, read> wall: array<f32>;
145+
146+fn idx(ix: i32, iy: i32, iz: i32) -> i32 {
147+ return ix + ${nx} * (iy + ${ny} * iz);
148+}
149+fn inside(ix: i32, iy: i32, iz: i32) -> bool {
150+ return ix >= 0 && ix < ${nx} && iy >= 0 && iy < ${ny} && iz >= 0 && iz < ${nz};
151+}
152+// One face's contribution: masked by the wall at the neighbour, so a face
153+// into the wall carries no flux — the Neumann (rigid) condition.
154+fn face(ix: i32, iy: i32, iz: i32, pc: f32) -> f32 {
155+ if (!inside(ix, iy, iz)) { return -pc; } // outside the domain: p = 0, open
156+ let k = u32(idx(ix, iy, iz));
157+ return wall[k] * (src[k] - pc);
158+}
159+
160+@compute @workgroup_size(${WORKGROUP_SIZE})
161+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
162+ let i = gid.x;
163+ if (i >= ${npts}u) { return; }
164+ let ix = i32(i % ${nx}u);
165+ let iy = i32((i / ${nx}u) % ${ny}u);
166+ let iz = i32(i / ${nx * ny}u);
167+ let pc = src[i];
168+ let s = face(ix - 1, iy, iz, pc) + face(ix + 1, iy, iz, pc)
169+ + face(ix, iy - 1, iz, pc) + face(ix, iy + 1, iz, pc)
170+ + face(ix, iy, iz - 1, pc) + face(ix, iy, iz + 1, pc);
171+ dst[i] = ${1 / (h * h)}f * wall[i] * s;
172+}
173+`;
174+ return { name, kind: 'lapw', argCounts: [npts, npts], outCount: npts, code };
175+}
176+
177+/**
178+ * rad_<id> for a line object: give each air point the object's value at that
179+ * point's own x (linearly interpolated, zero beyond the ends). Multiplied by
180+ * a scene-built profile, that is the object radiating from its line.
181+ */
182+export function makeRadLine(name: string, g: ObjectGrid, air: AirGrid): OpInstance {
183+ const n = g.n1;
184+ const code = `${header(1)}
185+@compute @workgroup_size(${WORKGROUP_SIZE})
186+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
187+ let i = gid.x;
188+ if (i >= ${air.npts}u) { return; }
189+ let ix = i % ${air.nx}u;
190+ let xw = ${-air.Lx / 2}f + (f32(ix) + 0.5) * ${air.h}f;
191+ let u = (xw - ${-g.L1 / 2}f) / ${g.h1}f;
192+ let j = i32(floor(u));
193+ if (j < 0 || j >= ${n - 1}) { dst[i] = 0.0; return; }
194+ let f = u - f32(j);
195+ dst[i] = mix(src[u32(j)], src[u32(j) + 1u], f);
196+}
197+`;
198+ return { name, kind: 'radline', argCounts: [n], outCount: air.npts, code };
199+}
200+
201+/**
202+ * rad_<id> for a rectangular object: give each air point the object's value
203+ * at that point's own (x, y), bilinearly interpolated, zero beyond the rim.
204+ * Multiplied by a scene-built sheet profile, that is the membrane radiating
205+ * from its plane.
206+ */
207+export function makeRadRect(name: string, g: ObjectGrid, air: AirGrid): OpInstance {
208+ const { n1, n2 } = g;
209+ const code = `${header(1)}
210+@compute @workgroup_size(${WORKGROUP_SIZE})
211+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
212+ let i = gid.x;
213+ if (i >= ${air.npts}u) { return; }
214+ let ix = i % ${air.nx}u;
215+ let iy = (i / ${air.nx}u) % ${air.ny}u;
216+ let xw = ${-air.Lx / 2}f + (f32(ix) + 0.5) * ${air.h}f;
217+ let yw = ${-air.Ly / 2}f + (f32(iy) + 0.5) * ${air.h}f;
218+ let u = (xw - ${-g.L1 / 2}f) / ${g.h1}f;
219+ let v = (yw - ${-g.L2 / 2}f) / ${g.h2}f;
220+ let ju = i32(floor(u));
221+ let jv = i32(floor(v));
222+ if (ju < 0 || ju >= ${n1 - 1} || jv < 0 || jv >= ${n2 - 1}) { dst[i] = 0.0; return; }
223+ let fu = u - f32(ju);
224+ let fv = v - f32(jv);
225+ let a = mix(src[u32(ju + ${n1} * jv)], src[u32(ju + 1 + ${n1} * jv)], fu);
226+ let b = mix(src[u32(ju + ${n1} * (jv + 1))], src[u32(ju + 1 + ${n1} * (jv + 1))], fu);
227+ dst[i] = mix(a, b, fv);
228+}
229+`;
230+ return { name, kind: 'radrect', argCounts: [g.npts], outCount: air.npts, code };
231+}
232+
233+/**
234+ * brg_<id>: du/dx at the last node of a line object, broadcast to every air
235+ * point. The end is pinned, so this is the arriving slope — what the tension
236+ * pulls the bridge with. Multiplied by a scene-built patch profile, that is
237+ * the bridge force driving the top plate.
238+ */
239+export function makeBridge(name: string, g: ObjectGrid, air: AirGrid): OpInstance {
240+ const n = g.n1;
241+ const code = `${header(1)}
242+@compute @workgroup_size(${WORKGROUP_SIZE})
243+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
244+ let i = gid.x;
245+ if (i >= ${air.npts}u) { return; }
246+ dst[i] = (src[${n - 1}u] - src[${n - 2}u]) * ${1 / g.h1}f;
247+}
248+`;
249+ return { name, kind: 'bridge', argCounts: [n], outCount: air.npts, code };
250+}
251+
252+/** Compiled op pipelines for one scene's set of instances. Shared by every
253+ * plan compiled against that scene. */
254+export class OpPlan {
255+ #device: GPUDevice;
256+ #instances = new Map<string, OpInstance>();
257+ #layouts = new Map<number, GPUBindGroupLayout>();
258+ #pipelines = new Map<string, GPUComputePipeline>();
259+
260+ constructor(device: GPUDevice, instances: OpInstance[]) {
261+ this.#device = device;
262+ for (const inst of instances) {
263+ if (this.#instances.has(inst.name)) {
264+ throw new UnsupportedOnGpu(`duplicate external op '${inst.name}'`);
265+ }
266+ this.#instances.set(inst.name, inst);
267+ }
268+ }
269+
270+ /** Every instance, for the type-rule files and the editor's highlighter. */
271+ get instances(): OpInstance[] {
272+ return [...this.#instances.values()];
273+ }
274+
275+ has(name: string): boolean {
276+ return this.#instances.has(name);
277+ }
278+
279+ /** The op's shape contract, for the planner's checks. */
280+ spec(name: string): { argCounts: number[]; outCount: number } {
281+ const inst = this.#instances.get(name);
282+ if (!inst) throw new UnsupportedOnGpu(`unknown external op '${name}'`);
283+ return { argCounts: inst.argCounts, outCount: inst.outCount };
284+ }
285+
286+ #layout(args: number): GPUBindGroupLayout {
287+ const existing = this.#layouts.get(args);
288+ if (existing) return existing;
289+ const entries: GPUBindGroupLayoutEntry[] = [
290+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
291+ ];
292+ for (let i = 0; i < args; i++) {
293+ entries.push({
294+ binding: i + 1,
295+ visibility: GPUShaderStage.COMPUTE,
296+ buffer: { type: 'read-only-storage' },
297+ });
298+ }
299+ const layout = this.#device.createBindGroupLayout({ label: `op-${args}`, entries });
300+ this.#layouts.set(args, layout);
301+ return layout;
302+ }
303+
304+ /** The pipeline for one op, compiled on first use. */
305+ async pipeline(name: string): Promise<{ pipeline: GPUComputePipeline; outCount: number }> {
306+ const inst = this.#instances.get(name);
307+ if (!inst) throw new UnsupportedOnGpu(`unknown external op '${name}'`);
308+ const existing = this.#pipelines.get(name);
309+ if (existing) return { pipeline: existing, outCount: inst.outCount };
310+ const module = this.#device.createShaderModule({ code: inst.code, label: name });
311+ let pipeline: GPUComputePipeline;
312+ try {
313+ pipeline = await this.#device.createComputePipelineAsync({
314+ layout: this.#device.createPipelineLayout({
315+ bindGroupLayouts: [this.#layout(inst.argCounts.length)],
316+ }),
317+ compute: { module, entryPoint: 'main' },
318+ label: name,
319+ });
320+ } catch (e) {
321+ // No error scope here either; see makePipeline in plan.ts.
322+ throw new UnsupportedOnGpu(
323+ `op '${name}': ${e instanceof Error ? e.message : String(e)}`,
324+ );
325+ }
326+ this.#pipelines.set(name, pipeline);
327+ return { pipeline, outCount: inst.outCount };
328+ }
329+
330+ createBinding(name: string, srcs: GPUBuffer[], dst: GPUBuffer): GPUBindGroup {
331+ const entries: GPUBindGroupEntry[] = [{ binding: 0, resource: { buffer: dst } }];
332+ srcs.forEach((s, i) => entries.push({ binding: i + 1, resource: { buffer: s } }));
333+ return this.#device.createBindGroup({ layout: this.#layout(srcs.length), entries });
334+ }
335+
336+ workgroups(outCount: number): number {
337+ return Math.ceil(outCount / WORKGROUP_SIZE);
338+ }
339+}
src/mgpu/plan.tsadded+761−0View file
@@ -0,0 +1,761 @@
1+/**
2+ * Statement list -> a replayable sequence of GPU operations.
3+ *
4+ * Everything expensive happens once, here: pipeline compilation, buffer
5+ * allocation, bind-group construction. Because numbl fixes every type and
6+ * shape at lowering time, the resulting op sequence is fully static — so
7+ * `encodeSteps` is pure synchronous command recording, with no allocation, no
8+ * pipeline lookup and no readback. That is what lets a whole batch of
9+ * timesteps be encoded into one submit and keeps the CPU out of the loop.
10+ */
11+import { isMultiElement } from 'numbl-src/numbl-core/jit/lowering/types.ts';
12+import type { Assign, IRExpr, IRStmt } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
13+import type { NumericType, Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
14+import type { CompiledFunction } from './compile.ts';
15+import { OpPlan } from './ops.ts';
16+import { kernelOperandBudget } from '../device.ts';
17+import {
18+ buildKernel,
19+ checkShapes,
20+ UnsupportedOnGpu,
21+ WORKGROUP_SIZE,
22+ type KernelInputs,
23+} from './wgsl.ts';
24+
25+const isNumeric = (t: Type): t is NumericType => t.kind === 'Numeric';
26+const isTensor = (t: Type): boolean => isNumeric(t) && isMultiElement(t);
27+const numel = (t: NumericType): number => (t.shape ?? []).reduce((a, b) => a * b, 1);
28+
29+interface Slot {
30+ buffer: GPUBuffer;
31+ count: number;
32+}
33+
34+const makeBuffer = (device: GPUDevice, label: string, count: number): GPUBuffer =>
35+ device.createBuffer({
36+ label,
37+ size: 4 * count,
38+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
39+ });
40+
41+/**
42+ * Buffers for host-bound variables, shared across plans.
43+ *
44+ * A model is two programs — `init` and `step` — compiled separately but
45+ * operating on the same state. `p` in the step must be the very buffer `init`
46+ * wrote, so the buffers for host bindings live here rather than inside either
47+ * plan.
48+ */
49+export class HostBuffers {
50+ #device: GPUDevice;
51+ #slots = new Map<string, Slot>();
52+
53+ constructor(device: GPUDevice) {
54+ this.#device = device;
55+ }
56+
57+ ensure(name: string, count: number): Slot {
58+ const existing = this.#slots.get(name);
59+ if (existing) {
60+ if (existing.count !== count) {
61+ throw new UnsupportedOnGpu(
62+ `'${name}' is ${existing.count} elements in one program and ` +
63+ `${count} in another`,
64+ );
65+ }
66+ return existing;
67+ }
68+ const slot = { buffer: makeBuffer(this.#device, `mgpu-${name}`, count), count };
69+ this.#slots.set(name, slot);
70+ return slot;
71+ }
72+
73+ get(name: string): Slot | undefined {
74+ return this.#slots.get(name);
75+ }
76+
77+ /** Upload initial data for a host binding. */
78+ upload(name: string, data: Float32Array): void {
79+ const slot = this.#slots.get(name);
80+ if (!slot) throw new Error(`upload: no buffer named '${name}'`);
81+ if (data.length !== slot.count) {
82+ throw new Error(
83+ `upload '${name}': expected ${slot.count} elements, got ${data.length}`,
84+ );
85+ }
86+ this.#device.queue.writeBuffer(slot.buffer, 0, data as Float32Array<ArrayBuffer>);
87+ }
88+
89+ destroy(): void {
90+ for (const s of this.#slots.values()) s.buffer.destroy();
91+ this.#slots.clear();
92+ }
93+}
94+
95+type Op =
96+ | {
97+ kind: 'kernel';
98+ pipeline: GPUComputePipeline;
99+ bindGroup: GPUBindGroup;
100+ count: number;
101+ label: string;
102+ /** Set when the kernel had to write to scratch because its output
103+ * aliases one of its inputs; copied back after the dispatch. */
104+ copyBack?: { from: GPUBuffer; to: GPUBuffer; bytes: number };
105+ }
106+ | {
107+ kind: 'external';
108+ pipeline: GPUComputePipeline;
109+ bindGroup: GPUBindGroup;
110+ workgroups: number;
111+ label: string;
112+ }
113+ | { kind: 'copy'; from: GPUBuffer; to: GPUBuffer; bytes: number; label: string };
114+
115+export interface PlanSpec {
116+ /** The specialized function this plan executes. */
117+ fn: CompiledFunction;
118+ /** Output index -> host binding name to copy the result into after the run,
119+ * so the next call reads it (the new field feeds the old). */
120+ feedback: (string | null)[];
121+}
122+
123+/**
124+ * Bind group layout for a kernel: the output at 0, `inputs` read-only storage
125+ * buffers after it, then the params buffer.
126+ *
127+ * Declared explicitly rather than with `layout: 'auto'`, because an auto layout
128+ * only contains the bindings the shader actually references — so a kernel that
129+ * happens to use no parameters would drop the params binding and no longer
130+ * match the bind group. An explicit layout may carry bindings the shader
131+ * ignores.
132+ */
133+function kernelLayout(device: GPUDevice, inputs: number): GPUBindGroupLayout {
134+ const readOnly = (binding: number): GPUBindGroupLayoutEntry => ({
135+ binding,
136+ visibility: GPUShaderStage.COMPUTE,
137+ buffer: { type: 'read-only-storage' },
138+ });
139+ return device.createBindGroupLayout({
140+ entries: [
141+ {
142+ binding: 0,
143+ visibility: GPUShaderStage.COMPUTE,
144+ buffer: { type: 'storage' },
145+ },
146+ ...Array.from({ length: inputs }, (_, i) => readOnly(i + 1)),
147+ readOnly(inputs + 1),
148+ ],
149+ });
150+}
151+
152+/**
153+ * Compile one shader into a pipeline.
154+ *
155+ * No validation error scope around it: `createComputePipelineAsync` already
156+ * rejects on a shader that will not compile or a layout that does not match,
157+ * which is the whole of what a scope here would have caught, and the scope
158+ * costs an extra device round trip per pipeline. `getCompilationInfo`, which
159+ * has the line and column within the generated WGSL, is asked for only once
160+ * something has gone wrong, and defensively even then.
161+ *
162+ * In practice the WGSL here is generated, so a shader that fails to compile is
163+ * this project's bug rather than the user's; a mistake in a .m is caught
164+ * earlier, by the emitter, with a position in the MATLAB source.
165+ */
166+async function makePipeline(
167+ device: GPUDevice,
168+ code: string,
169+ label: string,
170+ bindGroupLayout: GPUBindGroupLayout,
171+): Promise<GPUComputePipeline> {
172+ const module = device.createShaderModule({ code, label });
173+ try {
174+ return await device.createComputePipelineAsync({
175+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
176+ compute: { module, entryPoint: 'main' },
177+ label,
178+ });
179+ } catch (e) {
180+ throw new UnsupportedOnGpu(
181+ `generated WGSL failed to compile for '${label}':\n` +
182+ `${await shaderErrors(module, e)}\n--- shader ---\n${code}`,
183+ );
184+ }
185+}
186+
187+/** Per-line compile errors, if the browser will hand them over. */
188+async function shaderErrors(module: GPUShaderModule, cause: unknown): Promise<string> {
189+ const fallback = cause instanceof Error ? cause.message : String(cause);
190+ try {
191+ const info = await module.getCompilationInfo();
192+ const errors = info.messages.filter((m) => m.type === 'error');
193+ if (!errors.length) return fallback;
194+ return errors.map((e) => ` ${e.lineNum}:${e.linePos} ${e.message}`).join('\n');
195+ } catch {
196+ return fallback;
197+ }
198+}
199+
200+/** A compiled .m function, ready to run on the GPU. */
201+export class ModelPlan {
202+ /** Scalar parameter names, in the order the params buffer expects them. */
203+ readonly paramNames: string[];
204+
205+ #device: GPUDevice;
206+ #ops: Op[];
207+ #owned: GPUBuffer[];
208+ #paramBuf: GPUBuffer;
209+ #paramData: Float32Array;
210+ /** Public name -> buffer, for uploading initial state and reading results. */
211+ #byName: Map<string, Slot>;
212+
213+ private constructor(init: {
214+ device: GPUDevice;
215+ ops: Op[];
216+ byName: Map<string, Slot>;
217+ owned: GPUBuffer[];
218+ paramBuf: GPUBuffer;
219+ paramData: Float32Array;
220+ paramNames: string[];
221+ }) {
222+ this.#device = init.device;
223+ this.#ops = init.ops;
224+ this.#byName = init.byName;
225+ this.#owned = init.owned;
226+ this.#paramBuf = init.paramBuf;
227+ this.#paramData = init.paramData;
228+ this.paramNames = init.paramNames;
229+ }
230+
231+ static async create(
232+ device: GPUDevice,
233+ external: OpPlan,
234+ spec: PlanSpec,
235+ host: HostBuffers,
236+ /** Overrides what the device allows; only tests pass it. */
237+ operandBudget?: number,
238+ ): Promise<ModelPlan> {
239+ const { fn } = spec;
240+
241+ const slots = new Map<string, Slot>();
242+ const byName = new Map<string, Slot>();
243+ const owned: GPUBuffer[] = [];
244+ /** Scalars the .m computes from its parameters, by cName. */
245+ const derivedScalars = new Map<string, { name: string; expr: IRExpr }>();
246+ /** Grid fields one kernel may read on this device (see fitToBudget). */
247+ const budget = operandBudget ?? kernelOperandBudget(device);
248+ /** How many kernels a line has been split into, for naming the pieces. */
249+ let splits = 0;
250+
251+ const alloc = (label: string, count: number): Slot => {
252+ const buffer = makeBuffer(device, label, count);
253+ owned.push(buffer);
254+ return { buffer, count };
255+ };
256+
257+ // Arguments, bound by what the function's signature declares. Array
258+ // arguments come from the shared pool, so a value one function returns is
259+ // the same buffer the next one reads. Scalar parameters share one small
260+ // storage buffer, in signature order.
261+ const paramNames: string[] = [];
262+ const paramSlots = new Map<string, number>();
263+ for (const p of fn.params) {
264+ if (p.binding.kind === 'tensor') {
265+ const count = p.binding.shape.reduce((x, y) => x * y, 1);
266+ const slot = host.ensure(p.name, count);
267+ slots.set(p.cName, slot);
268+ byName.set(p.name, slot);
269+ } else if (p.binding.kind === 'param') {
270+ paramSlots.set(p.cName, paramNames.length);
271+ paramNames.push(p.name);
272+ }
273+ // `const` arguments are exact in the IR and fold into the kernels.
274+ }
275+ const paramData = new Float32Array(Math.max(1, paramNames.length));
276+ const paramBuf = device.createBuffer({
277+ label: 'mgpu-params',
278+ size: 4 * paramData.length,
279+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
280+ });
281+
282+ const ops: Op[] = [];
283+ for (const stmt of fn.body) {
284+ await planStatement(stmt);
285+ }
286+
287+ planFeedback();
288+
289+ return new ModelPlan({ device, ops, byName, owned, paramBuf, paramData, paramNames });
290+
291+ async function planStatement(stmt: IRStmt): Promise<void> {
292+ if (stmt.kind === 'ReturnFromFunction') return; // nothing follows it
293+ if (stmt.kind !== 'Assign') {
294+ throw new UnsupportedOnGpu(
295+ `a model function body may only contain assignments ` +
296+ `(found '${stmt.kind}')`,
297+ stmt.span,
298+ );
299+ }
300+ if (!isNumeric(stmt.ty)) {
301+ throw new UnsupportedOnGpu(`'${stmt.name}' is not a numeric value`, stmt.span);
302+ }
303+ if (!isTensor(stmt.ty)) {
304+ // A scalar the model derives from its parameters (`om = 2*pi*f`). It
305+ // gets no buffer and no dispatch: the kernels that read it bind it as
306+ // a `let` in their prologue.
307+ derivedScalars.set(stmt.cName, { name: stmt.name, expr: stmt.expr });
308+ return;
309+ }
310+ const count = numel(stmt.ty);
311+
312+ // Reuse the destination buffer across steps: the same cName always maps
313+ // to the same buffer, so a step allocates nothing.
314+ let dest = slots.get(stmt.cName);
315+ if (!dest) {
316+ dest = alloc(`mgpu-${stmt.name}`, count);
317+ slots.set(stmt.cName, dest);
318+ } else if (dest.count !== count) {
319+ throw new UnsupportedOnGpu(
320+ `'${stmt.name}' changes size between assignments`,
321+ stmt.span,
322+ );
323+ }
324+ byName.set(stmt.name, dest);
325+
326+ const ext = externalCall(stmt, external);
327+ if (ext) return planExternal(stmt, ext, dest);
328+
329+ // Element-wise. Checked against the whole line first, so a broadcasting
330+ // mistake is reported against what was written rather than against a
331+ // fragment of it.
332+ checkShapes(stmt.expr, stmt.ty, stmt.name);
333+ const expr = await fitToBudget(stmt.expr, stmt.name, count, stmt.span);
334+ await emitElementwise(stmt.name, expr, stmt.ty, stmt.span, dest, count, stmt.cName);
335+ }
336+
337+ /**
338+ * Emit one element-wise kernel: `expr` evaluated at every index into
339+ * `dest`. `selfCName` is the variable being assigned, if any, so an
340+ * in-place update can be spotted.
341+ */
342+ async function emitElementwise(
343+ name: string,
344+ expr: IRExpr,
345+ ty: NumericType,
346+ span: unknown,
347+ dest: Slot,
348+ count: number,
349+ selfCName?: string,
350+ ): Promise<void> {
351+ // Collect the distinct tensor operands and give them dense binding slots.
352+ const tensors = new Map<string, number>();
353+ collectTensorVars(expr, (cName) => {
354+ if (!tensors.has(cName)) tensors.set(cName, tensors.size);
355+ });
356+
357+ const label = `${name} = <${count} elements, element-wise>`;
358+ const kernel = buildKernel(
359+ { kind: 'Assign', name, ty, expr, span } as unknown as Assign,
360+ {
361+ tensors,
362+ params: paramSlots,
363+ scalars: derivedScalars,
364+ } satisfies KernelInputs,
365+ count,
366+ label,
367+ );
368+
369+ const bindGroupLayout = kernelLayout(device, tensors.size);
370+ const pipeline = await makePipeline(device, kernel.code, label, bindGroupLayout);
371+
372+ // WebGPU forbids aliasing a writable storage binding with another
373+ // binding in the same group, so an in-place update (`p = p + 1`) writes
374+ // to scratch and copies back. Element-wise kernels only ever touch
375+ // their own index, so the copy is the only cost.
376+ const aliased = selfCName !== undefined && tensors.has(selfCName);
377+ const target = aliased ? alloc(`mgpu-${name}-scratch`, count) : dest;
378+
379+ const entries: GPUBindGroupEntry[] = [
380+ { binding: 0, resource: { buffer: target.buffer } },
381+ ];
382+ for (const [cName, i] of tensors) {
383+ const s = slots.get(cName);
384+ if (!s) {
385+ throw new UnsupportedOnGpu(`'${name}' reads a value with no buffer`, span);
386+ }
387+ entries.push({ binding: i + 1, resource: { buffer: s.buffer } });
388+ }
389+ entries.push({ binding: tensors.size + 1, resource: { buffer: paramBuf } });
390+
391+ ops.push({
392+ kind: 'kernel',
393+ pipeline,
394+ bindGroup: device.createBindGroup({ layout: bindGroupLayout, entries }),
395+ count,
396+ label,
397+ copyBack: aliased
398+ ? { from: target.buffer, to: dest.buffer, bytes: 4 * count }
399+ : undefined,
400+ });
401+ }
402+
403+ /**
404+ * Split an expression that reads more grid fields than one kernel may bind.
405+ *
406+ * A kernel binds one storage buffer per distinct field it reads, plus its
407+ * output and the parameter block, and WebGPU guarantees only eight per
408+ * compute stage — fewer in compatibility mode. numbl's inline pass, which
409+ * is what makes one source line become one kernel, does not know about
410+ * that limit, and a model has no way to ask it for less: a temporary used
411+ * once is exactly what it folds away.
412+ *
413+ * So the budget is enforced here instead. Any child subtree that reads
414+ * more than one field is evaluated into its own buffer and replaced by a
415+ * reference to it, which leaves the parent reading at most one field per
416+ * child. The result is the same arithmetic in a few more passes over
417+ * memory, and it only happens on a line that would not otherwise compile.
418+ */
419+ async function fitToBudget(
420+ expr: IRExpr,
421+ hint: string,
422+ count: number,
423+ span: unknown,
424+ ): Promise<IRExpr> {
425+ if (tensorCount(expr) <= budget) return expr;
426+
427+ const fit = async (e: IRExpr): Promise<IRExpr> => {
428+ if (tensorCount(e) <= budget) return e;
429+ const kids = children(e);
430+ if (!kids.length) return e;
431+ const out: IRExpr[] = [];
432+ for (const kid of kids) {
433+ const fitted = await fit(kid);
434+ out.push(tensorCount(fitted) > 1 ? await hoist(fitted) : fitted);
435+ }
436+ return withChildren(e, out);
437+ };
438+
439+ /** Evaluate a subtree into its own buffer and hand back a reference. */
440+ const hoist = async (e: IRExpr): Promise<IRExpr> => {
441+ if (!isNumeric(e.ty) || !isTensor(e.ty)) return e;
442+ const name = `${hint}_part${++splits}`;
443+ const cName = `mgpu_split_${splits}`;
444+ const slot = alloc(`mgpu-${name}`, count);
445+ slots.set(cName, slot);
446+ await emitElementwise(name, e, e.ty, e.span, slot, count);
447+ return { kind: 'Var', name, cName, ty: e.ty, span: e.span } as IRExpr;
448+ };
449+
450+ const fitted = await fit(expr);
451+ if (tensorCount(fitted) > budget) {
452+ throw new UnsupportedOnGpu(
453+ `'${hint}' reads ${tensorCount(fitted)} grid fields at once, and this ` +
454+ `device allows ${budget} per kernel. Compute part of it into a ` +
455+ `named field on a line of its own.`,
456+ span,
457+ );
458+ }
459+ return fitted;
460+ }
461+
462+ /** `lp = lapw(p, wall)`: one dispatch, sources and dst distinct. */
463+ async function planExternal(
464+ stmt: Assign,
465+ ext: { name: string; args: (IRExpr & { kind: 'Var' })[] },
466+ dest: Slot,
467+ ): Promise<void> {
468+ const contract = external.spec(ext.name);
469+ const argSlots = ext.args.map((arg, i) => {
470+ const s = slots.get(arg.cName);
471+ if (!s) {
472+ throw new UnsupportedOnGpu(
473+ `'${ext.name}' reads '${arg.name}', which has no buffer`,
474+ stmt.span,
475+ );
476+ }
477+ if (s.count !== contract.argCounts[i]) {
478+ throw new UnsupportedOnGpu(
479+ `'${ext.name}' wants ${contract.argCounts[i]} elements for its ` +
480+ `argument ${i + 1}, but '${arg.name}' holds ${s.count}`,
481+ stmt.span,
482+ );
483+ }
484+ // An op reads its neighbours, so unlike an element-wise kernel it
485+ // cannot be routed through scratch and copied back — the neighbours
486+ // would already have been overwritten. WebGPU forbids the aliasing
487+ // outright anyway; refuse rather than silently reroute.
488+ if (s.buffer === dest.buffer) {
489+ throw new UnsupportedOnGpu(
490+ `'${stmt.name} = ${ext.name}(...)' reads and writes the same ` +
491+ `buffer through '${arg.name}'; assign to a new name instead`,
492+ stmt.span,
493+ );
494+ }
495+ return s;
496+ });
497+ if (dest.count !== contract.outCount) {
498+ throw new UnsupportedOnGpu(
499+ `'${ext.name}' produces a ${contract.outCount}-point field, but ` +
500+ `'${stmt.name}' holds ${dest.count}`,
501+ stmt.span,
502+ );
503+ }
504+ const built = await external.pipeline(ext.name);
505+ ops.push({
506+ kind: 'external',
507+ pipeline: built.pipeline,
508+ bindGroup: external.createBinding(
509+ ext.name,
510+ argSlots.map((s) => s.buffer),
511+ dest.buffer,
512+ ),
513+ workgroups: external.workgroups(contract.outCount),
514+ label: `${stmt.name} = ${ext.name}(${ext.args.map((a) => a.name).join(', ')})`,
515+ });
516+ }
517+
518+ /**
519+ * Feed declared outputs back into the argument buffers they replace, so
520+ * the next call reads what this one produced.
521+ *
522+ * The copies are not independent: a model whose new history field is the
523+ * old current one (`function [pn, pold] = step(p, pm, ...)`) has an output
524+ * whose *source* is another output's *destination*. Doing them in order
525+ * would then copy the new value where the old one was wanted, silently.
526+ * So any source that a previous copy overwrites is staged through scratch
527+ * first — normally none, since a model that writes `pold = p;` gets its
528+ * own buffer from the copy kernel that line plans to.
529+ */
530+ function planFeedback(): void {
531+ const copies: { from: Slot; to: Slot; label: string }[] = [];
532+ fn.outputs.forEach((out, i) => {
533+ const to = spec.feedback[i];
534+ if (!to) return;
535+ const src = slots.get(out.cName);
536+ const dst = host.get(to);
537+ if (!src) {
538+ throw new UnsupportedOnGpu(
539+ `'${fn.name}' declares the output '${out.name}' but never assigns it`,
540+ );
541+ }
542+ if (!dst) throw new UnsupportedOnGpu(`'${to}' is not a host binding`);
543+ if (src.count !== dst.count) {
544+ throw new UnsupportedOnGpu(
545+ `'${out.name}' (${src.count} elements) cannot feed '${to}' (${dst.count})`,
546+ );
547+ }
548+ copies.push({ from: src, to: dst, label: `${out.name} -> ${to}` });
549+ });
550+
551+ const written = new Set<GPUBuffer>();
552+ for (const c of copies) written.add(c.to.buffer);
553+ for (const c of copies) {
554+ // Only a source another copy overwrites needs staging, and only if it
555+ // is not that same copy's own destination (which is a no-op anyway).
556+ if (c.from.buffer !== c.to.buffer && written.has(c.from.buffer)) {
557+ const scratch = alloc(`mgpu-feedback-scratch`, c.from.count);
558+ ops.push({
559+ kind: 'copy',
560+ from: c.from.buffer,
561+ to: scratch.buffer,
562+ bytes: 4 * c.from.count,
563+ label: `${c.label} (staged)`,
564+ });
565+ c.from = scratch;
566+ }
567+ }
568+ for (const c of copies) {
569+ if (c.from.buffer === c.to.buffer) continue; // already in place
570+ ops.push({
571+ kind: 'copy',
572+ from: c.from.buffer,
573+ to: c.to.buffer,
574+ bytes: 4 * c.from.count,
575+ label: c.label,
576+ });
577+ }
578+ }
579+ }
580+
581+ /** Upload parameter values, in `paramNames` order. Cheap — call freely. */
582+ setParams(values: Record<string, number>): void {
583+ this.paramNames.forEach((name, i) => {
584+ const v = values[name];
585+ this.#paramData[i] = Number.isFinite(v) ? v : 0;
586+ });
587+ this.#device.queue.writeBuffer(
588+ this.#paramBuf,
589+ 0,
590+ this.#paramData as Float32Array<ArrayBuffer>,
591+ );
592+ }
593+
594+ /** Buffer holding the named value, or undefined if the .m never binds it. */
595+ buffer(name: string): GPUBuffer | undefined {
596+ return this.#byName.get(name)?.buffer;
597+ }
598+
599+ elementCount(name: string): number | undefined {
600+ return this.#byName.get(name)?.count;
601+ }
602+
603+ /**
604+ * Record `steps` passes of this plan. Synchronous: no awaits, no readback.
605+ * The dispatches share one compute pass, which WebGPU executes in submission
606+ * order with a barrier between them.
607+ *
608+ * `after` runs once per step, inside the same submission — which is what
609+ * lets the microphone sample every timestep rather than every frame.
610+ */
611+ encodeSteps(
612+ encoder: GPUCommandEncoder,
613+ steps: number,
614+ after?: (encoder: GPUCommandEncoder) => void,
615+ ): void {
616+ for (let s = 0; s < steps; s++) {
617+ this.#encodeOps(encoder);
618+ after?.(encoder);
619+ }
620+ }
621+
622+ /** Record one pass over the op sequence into `encoder`. */
623+ #encodeOps(encoder: GPUCommandEncoder): void {
624+ let pass: GPUComputePassEncoder | null = null;
625+ const inPass = (): GPUComputePassEncoder => {
626+ if (!pass) pass = encoder.beginComputePass({ label: 'mgpu-step' });
627+ return pass;
628+ };
629+ const endPass = (): void => {
630+ if (pass) {
631+ pass.end();
632+ pass = null;
633+ }
634+ };
635+ for (const op of this.#ops) {
636+ switch (op.kind) {
637+ case 'kernel': {
638+ const p = inPass();
639+ p.setPipeline(op.pipeline);
640+ p.setBindGroup(0, op.bindGroup);
641+ p.dispatchWorkgroups(Math.ceil(op.count / WORKGROUP_SIZE));
642+ if (op.copyBack) {
643+ endPass();
644+ encoder.copyBufferToBuffer(
645+ op.copyBack.from, 0, op.copyBack.to, 0, op.copyBack.bytes,
646+ );
647+ }
648+ break;
649+ }
650+ case 'external': {
651+ const p = inPass();
652+ p.setPipeline(op.pipeline);
653+ p.setBindGroup(0, op.bindGroup);
654+ p.dispatchWorkgroups(op.workgroups);
655+ break;
656+ }
657+ case 'copy':
658+ endPass();
659+ encoder.copyBufferToBuffer(op.from, 0, op.to, 0, op.bytes);
660+ break;
661+ }
662+ }
663+ endPass();
664+ }
665+
666+ /** Human-readable op sequence — what the .m actually compiled to. */
667+ describe(): string[] {
668+ return this.#ops.map((op) => `${op.kind.padEnd(7)} ${op.label}`);
669+ }
670+
671+ destroy(): void {
672+ for (const b of this.#owned) b.destroy();
673+ this.#paramBuf.destroy();
674+ this.#owned.length = 0;
675+ }
676+}
677+
678+/** `lp = lapw(p, wall)` -> the op's name and its arguments. */
679+function externalCall(
680+ stmt: Assign,
681+ external: OpPlan,
682+): { name: string; args: (IRExpr & { kind: 'Var' })[] } | null {
683+ const e = stmt.expr;
684+ if (e.kind !== 'Call' || !external.has(e.name)) return null;
685+ const spec = external.spec(e.name);
686+ if (e.args.length !== spec.argCounts.length) {
687+ throw new UnsupportedOnGpu(
688+ `'${e.name}' takes ${spec.argCounts.length} argument(s), got ${e.args.length}`,
689+ stmt.span,
690+ );
691+ }
692+ const args = e.args.map((arg) => {
693+ if (arg.kind !== 'Var') {
694+ throw new UnsupportedOnGpu(
695+ `'${e.name}' must be applied to variables, not expressions — ` +
696+ `name the field first`,
697+ stmt.span,
698+ );
699+ }
700+ return arg;
701+ });
702+ return { name: e.name, args };
703+}
704+
705+/** Distinct grid fields an expression reads — its storage-buffer cost. */
706+function tensorCount(e: IRExpr): number {
707+ const seen = new Set<string>();
708+ collectTensorVars(e, (c) => seen.add(c));
709+ return seen.size;
710+}
711+
712+/** The subexpressions of a node, in evaluation order. Leaves have none. */
713+function children(e: IRExpr): IRExpr[] {
714+ switch (e.kind) {
715+ case 'Binary':
716+ return [e.left, e.right];
717+ case 'Unary':
718+ return [e.operand];
719+ case 'Call':
720+ return e.args;
721+ default:
722+ return [];
723+ }
724+}
725+
726+/** The same node with its subexpressions replaced. */
727+function withChildren(e: IRExpr, kids: IRExpr[]): IRExpr {
728+ switch (e.kind) {
729+ case 'Binary':
730+ return { ...e, left: kids[0], right: kids[1] };
731+ case 'Unary':
732+ return { ...e, operand: kids[0] };
733+ case 'Call':
734+ return { ...e, args: kids };
735+ default:
736+ return e;
737+ }
738+}
739+
740+function collectTensorVars(e: IRExpr, visit: (cName: string) => void): void {
741+ const walk = (x: IRExpr): void => {
742+ switch (x.kind) {
743+ case 'Var':
744+ if (isTensor(x.ty)) visit(x.cName);
745+ return;
746+ case 'Binary':
747+ walk(x.left);
748+ walk(x.right);
749+ return;
750+ case 'Unary':
751+ walk(x.operand);
752+ return;
753+ case 'Call':
754+ x.args.forEach(walk);
755+ return;
756+ default:
757+ return;
758+ }
759+ };
760+ walk(e);
761+}
src/mgpu/wgsl.tsadded+420−0View file
@@ -0,0 +1,420 @@
1+/**
2+ * IR expression tree -> one WGSL compute kernel.
3+ *
4+ * This is the WebGPU counterpart of numbl's C-side fused emitter
5+ * (`codegen/emitTensorFused.ts`): for an `Assign` whose right-hand side is
6+ * purely element-wise over operands of the target's shape, emit a single
7+ * kernel that computes one output element per invocation. Because numbl's
8+ * inline pass has already folded the ANF temps back together, one source line
9+ * of MATLAB becomes one kernel.
10+ *
11+ * Everything is f32, matching the existing fp32 WebGPU transform backend.
12+ */
13+import { getBuiltin } from 'numbl-src/numbl-core/jit/builtins/index.ts';
14+import { isMultiElement } from 'numbl-src/numbl-core/jit/lowering/types.ts';
15+import type { IRExpr, Assign } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
16+import type { NumericType, Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
17+
18+/** Raised for a construct the WGSL backend cannot express. Mirrors numbl's
19+ * own decline discipline: fail at compile time with a source span, never
20+ * silently produce something that computes the wrong thing. */
21+export class UnsupportedOnGpu extends Error {
22+ readonly span?: unknown;
23+ constructor(message: string, span?: unknown) {
24+ super(message);
25+ this.name = 'UnsupportedOnGpu';
26+ this.span = span;
27+ }
28+}
29+
30+const isNumeric = (t: Type): t is NumericType => t.kind === 'Numeric';
31+const isTensor = (t: Type): boolean => isNumeric(t) && isMultiElement(t);
32+
33+/** Element-wise binary builtins -> WGSL infix operator. */
34+const BINARY_OPS: Record<string, string> = {
35+ plus: '+',
36+ minus: '-',
37+ times: '*',
38+ rdivide: '/',
39+ // Degenerate to element-wise when at least one side is a scalar; the
40+ // both-tensor (true matrix) case is rejected below.
41+ mtimes: '*',
42+ mrdivide: '/',
43+};
44+
45+/** Element-wise unary builtins -> WGSL prefix operator. */
46+const UNARY_OPS: Record<string, string> = { uminus: '-', uplus: '+' };
47+
48+/** Element-wise builtin calls -> WGSL builtin of the same arity. */
49+const CALL_FNS: Record<string, string> = {
50+ abs: 'abs',
51+ acos: 'acos',
52+ asin: 'asin',
53+ atan: 'atan',
54+ atan2: 'atan2',
55+ ceil: 'ceil',
56+ cos: 'cos',
57+ cosh: 'cosh',
58+ exp: 'exp',
59+ floor: 'floor',
60+ log: 'log',
61+ log2: 'log2',
62+ max: 'max',
63+ min: 'min',
64+ round: 'round',
65+ sign: 'sign',
66+ sin: 'sin',
67+ sinh: 'sinh',
68+ sqrt: 'sqrt',
69+ tan: 'tan',
70+ tanh: 'tanh',
71+};
72+
73+/** Zero-argument builtins that are compile-time constants. numbl lowers `pi`
74+ * as a call rather than folding it, so the backend is where it becomes a
75+ * number. */
76+const CONST_FNS: Record<string, number> = { pi: Math.PI };
77+
78+/** WGSL f32 literal. Must always carry a decimal point or exponent, or WGSL
79+ * infers AbstractInt and rejects the mixed-type arithmetic. */
80+function f32Lit(v: number): string {
81+ if (!Number.isFinite(v)) {
82+ throw new UnsupportedOnGpu(`cannot emit non-finite literal ${v}`);
83+ }
84+ return Number.isInteger(v) && Math.abs(v) < 1e21
85+ ? `${v}.0`
86+ : String(v).includes('e')
87+ ? `${v}f`
88+ : String(v);
89+}
90+
91+/** How a scalar or tensor operand is read inside the kernel. */
92+export interface KernelInputs {
93+ /** cName -> storage binding index, for multi-element tensor operands. */
94+ tensors: Map<string, number>;
95+ /** cName -> slot in the params storage buffer, for runtime scalars. */
96+ params: Map<string, number>;
97+ /** cName -> defining expression, for scalars the .m computes from
98+ * parameters (`us = a + b`). These have no buffer and no param slot; they
99+ * become `let` bindings in the prologue of every kernel that reads them. */
100+ scalars: Map<string, { name: string; expr: IRExpr }>;
101+}
102+
103+/** Mutable state while emitting one kernel. */
104+interface Ctx {
105+ io: KernelInputs;
106+ /** `let` lines to emit before the body, in dependency order. */
107+ prologue: string[];
108+ /** cName -> WGSL identifier, for scalars already bound in the prologue. */
109+ bound: Map<string, string>;
110+}
111+
112+/** WGSL identifier for a derived scalar. Avoids a leading underscore, which
113+ * WGSL reserves. */
114+const scalarIdent = (cName: string): string =>
115+ `s_${cName.replace(/[^A-Za-z0-9_]/g, '_')}`;
116+
117+/**
118+ * Bind a .m-derived scalar in the prologue (once), after whatever it depends
119+ * on, and return its identifier.
120+ */
121+function bindScalar(cName: string, ctx: Ctx): string {
122+ const already = ctx.bound.get(cName);
123+ if (already) return already;
124+ const def = ctx.io.scalars.get(cName)!;
125+ const ident = scalarIdent(cName);
126+ // Claim the name before emitting the RHS so a (malformed) self-reference
127+ // cannot recurse forever.
128+ ctx.bound.set(cName, ident);
129+ const rhs = emitExpr(def.expr, ctx);
130+ ctx.prologue.push(` let ${ident} = ${rhs};`);
131+ return ident;
132+}
133+
134+/**
135+ * Emit the per-element WGSL expression for `e`. `i` is the element index
136+ * variable in scope.
137+ */
138+function emitExpr(e: IRExpr, ctx: Ctx): string {
139+ const io = ctx.io;
140+ switch (e.kind) {
141+ case 'NumLit':
142+ return f32Lit(e.value);
143+
144+ case 'Var': {
145+ if (isTensor(e.ty)) {
146+ const slot = io.tensors.get(e.cName);
147+ if (slot === undefined) {
148+ throw new UnsupportedOnGpu(`no buffer bound for '${e.name}'`, e.span);
149+ }
150+ return `in${slot}[i]`;
151+ }
152+ // Scalar: either an exact compile-time value or a runtime parameter.
153+ if (isNumeric(e.ty) && typeof e.ty.exact === 'number') {
154+ return f32Lit(e.ty.exact);
155+ }
156+ const slot = io.params.get(e.cName);
157+ if (slot !== undefined) return `prm[${slot}]`;
158+ if (io.scalars.has(e.cName)) return bindScalar(e.cName, ctx);
159+ throw new UnsupportedOnGpu(
160+ `scalar '${e.name}' is not a constant, a parameter, or computed in ` +
161+ `this model`,
162+ e.span,
163+ );
164+ }
165+
166+ case 'Binary': {
167+ if ((e.builtin === 'mtimes' || e.builtin === 'mrdivide') &&
168+ isTensor(e.left.ty) && isTensor(e.right.ty)) {
169+ throw new UnsupportedOnGpu(
170+ `matrix '${e.builtin === 'mtimes' ? '*' : '/'}' is not supported; ` +
171+ `use the element-wise form ('.${e.builtin === 'mtimes' ? '*' : '/'}')`,
172+ e.span,
173+ );
174+ }
175+ if (e.builtin === 'power' || e.builtin === 'mpower') {
176+ return emitPower(e.left, e.right, ctx, e.span);
177+ }
178+ const op = BINARY_OPS[e.builtin];
179+ if (!op) {
180+ throw new UnsupportedOnGpu(`operator '${e.builtin}' is not supported`, e.span);
181+ }
182+ return `(${emitExpr(e.left, ctx)} ${op} ${emitExpr(e.right, ctx)})`;
183+ }
184+
185+ case 'Unary': {
186+ const op = UNARY_OPS[e.builtin];
187+ if (!op) {
188+ throw new UnsupportedOnGpu(`unary '${e.builtin}' is not supported`, e.span);
189+ }
190+ return `(${op}${emitExpr(e.operand, ctx)})`;
191+ }
192+
193+ case 'Call': {
194+ // A shape constructor used inside an element-wise expression
195+ // contributes the same constant at every slot, so it needs no buffer.
196+ // (The shape itself is validated against the target by checkShapes.)
197+ if (e.name === 'ones') return '1.0';
198+ if (e.name === 'zeros') return '0.0';
199+
200+ const konst = CONST_FNS[e.name];
201+ if (konst !== undefined && e.args.length === 0) return f32Lit(konst);
202+
203+ const fn = CALL_FNS[e.name];
204+ const b = getBuiltin(e.name);
205+ if (!fn || !b?.elementwise) {
206+ // A call numbl resolved to another function in the file gets a mangled
207+ // specialization name; a builtin keeps its source-level name. Only the
208+ // model's entry points are compiled, so a helper is a distinct failure
209+ // from an unsupported builtin and deserves to say so.
210+ const isUserFunction = e.cName !== e.name;
211+ throw new UnsupportedOnGpu(
212+ isUserFunction
213+ ? `'${e.name}' is a function defined in this model. Only init and ` +
214+ `step are compiled — inline its body into the caller.`
215+ : `'${e.name}' cannot be evaluated element-wise on the GPU`,
216+ e.span,
217+ );
218+ }
219+ return `${fn}(${e.args.map((a) => emitExpr(a, ctx)).join(', ')})`;
220+ }
221+
222+ default:
223+ throw new UnsupportedOnGpu(`'${e.kind}' is not supported on the GPU`, e.span);
224+ }
225+}
226+
227+/**
228+ * `x.^k`. WGSL's `pow` is undefined for a negative base, and these fields go
229+ * negative routinely, so expand small non-negative integer exponents into
230+ * repeated multiplication — which is also what makes `u.^2` free.
231+ */
232+function emitPower(base: IRExpr, exponent: IRExpr, ctx: Ctx, span: unknown): string {
233+ const k =
234+ exponent.kind === 'NumLit'
235+ ? exponent.value
236+ : isNumeric(exponent.ty) && typeof exponent.ty.exact === 'number'
237+ ? exponent.ty.exact
238+ : undefined;
239+ const b = emitExpr(base, ctx);
240+ if (k !== undefined && Number.isInteger(k) && k >= 0 && k <= 8) {
241+ if (k === 0) return '1.0';
242+ // bind once so a compound base expression is not re-evaluated k times
243+ return `pow_i${k}(${b})`;
244+ }
245+ if (k !== undefined && Number.isInteger(k) && k < 0 && k >= -8) {
246+ return `(1.0 / pow_i${-k}(${b}))`;
247+ }
248+ throw new UnsupportedOnGpu(
249+ `'.^' needs a literal integer exponent in [-8, 8] (got ` +
250+ `${k === undefined ? 'a runtime value' : k}); a negative base makes ` +
251+ `WGSL's pow() undefined`,
252+ span,
253+ );
254+}
255+
256+/** Fixed-exponent power helpers, emitted only when used. */
257+function powHelpers(used: Set<number>): string {
258+ const out: string[] = [];
259+ for (const k of [...used].sort((a, b) => a - b)) {
260+ const body =
261+ k === 1 ? 'x' : `x${' * x'.repeat(k - 1)}`;
262+ out.push(`fn pow_i${k}(x: f32) -> f32 { return ${body}; }`);
263+ }
264+ return out.join('\n');
265+}
266+
267+/**
268+ * Can this expression be evaluated inside a fused kernel?
269+ *
270+ * The predicate behind src/mgpu/fuse.ts, and the single statement of what the
271+ * emitter above accepts: everything here is something `emitExpr` can write out
272+ * per element, and everything it declines is something that needs its own
273+ * dispatch. Keep the two in step.
274+ */
275+export function isGpuFusableExpr(e: IRExpr): boolean {
276+ switch (e.kind) {
277+ case 'NumLit':
278+ return true;
279+ case 'Var':
280+ return isNumeric(e.ty);
281+ case 'Binary': {
282+ if (
283+ (e.builtin === 'mtimes' || e.builtin === 'mrdivide') &&
284+ isTensor(e.left.ty) && isTensor(e.right.ty)
285+ ) {
286+ return false;
287+ }
288+ if (e.builtin === 'power' || e.builtin === 'mpower') {
289+ return isGpuFusableExpr(e.left) && isGpuFusableExpr(e.right);
290+ }
291+ return (
292+ e.builtin in BINARY_OPS && isGpuFusableExpr(e.left) && isGpuFusableExpr(e.right)
293+ );
294+ }
295+ case 'Unary':
296+ return e.builtin in UNARY_OPS && isGpuFusableExpr(e.operand);
297+ case 'Call': {
298+ if (e.name === 'zeros' || e.name === 'ones') return true;
299+ if (e.name in CONST_FNS) return e.args.length === 0;
300+ return e.name in CALL_FNS && e.args.every(isGpuFusableExpr);
301+ }
302+ default:
303+ return false;
304+ }
305+}
306+
307+/**
308+ * Reject implicit expansion (broadcasting).
309+ *
310+ * numbl's lowering permits it — `2x4096 .* 1x4096` lowers happily with MATLAB
311+ * expansion semantics — but a kernel that walks one linear index across every
312+ * operand would quietly compute the wrong thing. So every multi-element
313+ * operand must have exactly the target's shape. Scalars are fine: they are
314+ * read from the params buffer or folded in as literals.
315+ */
316+export function checkShapes(e: IRExpr, target: NumericType, name: string): void {
317+ const want = target.shape;
318+ const same = (t: NumericType): boolean => {
319+ const got = t.shape;
320+ return (
321+ !!want && !!got && want.length === got.length &&
322+ want.every((d, i) => d === got[i])
323+ );
324+ };
325+ const walk = (x: IRExpr): void => {
326+ if (isNumeric(x.ty) && isMultiElement(x.ty) && !same(x.ty)) {
327+ const got = x.ty.shape?.join('x') ?? 'dynamic';
328+ throw new UnsupportedOnGpu(
329+ `'${name}' would need implicit expansion: an operand is ${got} but the ` +
330+ `result is ${want?.join('x') ?? 'dynamic'}. Expand it explicitly ` +
331+ `(the GPU kernel walks one index across every operand).`,
332+ x.span,
333+ );
334+ }
335+ switch (x.kind) {
336+ case 'Binary':
337+ walk(x.left);
338+ walk(x.right);
339+ return;
340+ case 'Unary':
341+ walk(x.operand);
342+ return;
343+ case 'Call':
344+ // A shape constructor's own arguments are sizes, not data.
345+ if (x.name !== 'ones' && x.name !== 'zeros') x.args.forEach(walk);
346+ return;
347+ default:
348+ return;
349+ }
350+ };
351+ walk(e);
352+}
353+
354+export const WORKGROUP_SIZE = 64;
355+
356+export interface Kernel {
357+ code: string;
358+ /** Number of output elements. */
359+ count: number;
360+ label: string;
361+}
362+
363+/**
364+ * Build the kernel for one element-wise `Assign`. `io` must already map every
365+ * tensor operand cName to a binding index and every runtime scalar to a
366+ * params slot; the output is binding 0 and the params buffer is the binding
367+ * after the last input.
368+ */
369+export function buildKernel(
370+ stmt: Assign,
371+ io: KernelInputs,
372+ count: number,
373+ label: string,
374+): Kernel {
375+ if (!isNumeric(stmt.ty)) {
376+ throw new UnsupportedOnGpu(`'${stmt.name}' is not a numeric array`, stmt.span);
377+ }
378+ if (stmt.ty.isComplex) {
379+ throw new UnsupportedOnGpu(
380+ `'${stmt.name}' is complex; the GPU backend is real-only (a spectral ` +
381+ `field is carried as a real 2 x nlm array)`,
382+ stmt.span,
383+ );
384+ }
385+
386+ checkShapes(stmt.expr, stmt.ty, stmt.name);
387+ const ctx: Ctx = { io, prologue: [], bound: new Map() };
388+ const body = emitExpr(stmt.expr, ctx);
389+
390+ // pow_iK helpers are discovered during emission; scan the result for them.
391+ const used = new Set<number>();
392+ const emitted = [...ctx.prologue, body].join('\n');
393+ for (const m of emitted.matchAll(/\bpow_i(\d+)\(/g)) used.add(Number(m[1]));
394+
395+ const decls = [`@group(0) @binding(0) var<storage, read_write> out: array<f32>;`];
396+ for (const [, slot] of io.tensors) {
397+ decls.push(
398+ `@group(0) @binding(${slot + 1}) var<storage, read> in${slot}: array<f32>;`,
399+ );
400+ }
401+ // Params live in a read-only storage buffer rather than a uniform block:
402+ // uniform arrays would need 16-byte element stride.
403+ const prmBinding = io.tensors.size + 1;
404+ decls.push(
405+ `@group(0) @binding(${prmBinding}) var<storage, read> prm: array<f32>;`,
406+ );
407+
408+ const code = `${decls.join('\n')}
409+
410+${powHelpers(used)}
411+
412+@compute @workgroup_size(${WORKGROUP_SIZE})
413+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
414+ let i = gid.x;
415+ if (i >= ${count}u) { return; }
416+${ctx.prologue.length ? `${ctx.prologue.join('\n')}\n` : ''} out[i] = ${body};
417+}
418+`;
419+ return { code, count, label };
420+}
src/raw.d.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+/** Vite's `?raw` suffix imports a file's text. Used to load .m model sources. */
2+declare module '*?raw' {
3+ const source: string;
4+ export default source;
5+}
6+
7+/** Vite's `import.meta.glob`, used to load every .m in tools/ at once
8+ * (src/tools.ts). Only the eager + `?raw` form this project uses is
9+ * declared — it returns each match's text, keyed by path. */
10+interface ImportMeta {
11+ glob(
12+ pattern: string,
13+ options: { query: '?raw'; eager: true; import: 'default' },
14+ ): Record<string, string>;
15+}
src/render/colorbar.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+import type { ColormapFunc } from './colormaps.ts';
2+
3+/** Compact numeric label: 3 significant digits, trailing zeros trimmed. */
4+export const fmtValue = (v: number): string =>
5+ Number.isFinite(v) ? v.toPrecision(3).replace(/\.?0+$/, '') : '—';
6+
7+/** Vertical colorbar drawn on a small canvas, with min/max labels.
8+ * Adapted from turing-surface's src/render/colorbar.ts. */
9+export class Colorbar {
10+ #canvas: HTMLCanvasElement;
11+ #minLabel: HTMLElement;
12+ #maxLabel: HTMLElement;
13+
14+ constructor(container: HTMLElement) {
15+ container.classList.add('colorbar');
16+ this.#maxLabel = document.createElement('div');
17+ this.#maxLabel.className = 'colorbar-label';
18+ this.#canvas = document.createElement('canvas');
19+ this.#canvas.width = 12;
20+ this.#canvas.height = 160;
21+ this.#minLabel = document.createElement('div');
22+ this.#minLabel.className = 'colorbar-label';
23+ container.append(this.#maxLabel, this.#canvas, this.#minLabel);
24+ }
25+
26+ /** Repaint the gradient. Only when the colormap changes: it is 160 filled
27+ * rows, and the frame loop has better things to do. */
28+ setColormap(cmap: ColormapFunc): void {
29+ const ctx = this.#canvas.getContext('2d');
30+ if (!ctx) return;
31+ const h = this.#canvas.height;
32+ for (let y = 0; y < h; y++) {
33+ const t = 1 - y / (h - 1);
34+ const [r, g, b] = cmap(t);
35+ ctx.fillStyle = `rgb(${r},${g},${b})`;
36+ ctx.fillRect(0, y, this.#canvas.width, 1);
37+ }
38+ }
39+
40+ /** The end labels, which do change as the scale follows the field. */
41+ setRange(vmin: number, vmax: number): void {
42+ const lo = fmtValue(vmin);
43+ const hi = fmtValue(vmax);
44+ if (this.#minLabel.textContent !== lo) this.#minLabel.textContent = lo;
45+ if (this.#maxLabel.textContent !== hi) this.#maxLabel.textContent = hi;
46+ }
47+}
src/render/colormaps.tsadded+113−0View file
@@ -0,0 +1,113 @@
1+/**
2+ * Colormaps: each maps a normalized value in [0, 1] to [r, g, b] in [0, 255].
3+ * Adapted from figpack's SphereEmbedding view (figpack_experimental).
4+ */
5+
6+export type ColormapFunc = (t: number) => [number, number, number];
7+
8+const clamp01 = (t: number) => Math.max(0, Math.min(1, t));
9+
10+// Piecewise-linear interpolation through control points (r, g, b in 0-255)
11+const makeInterpolated = (stops: [number, number, number][]): ColormapFunc => {
12+ const n = stops.length;
13+ return (t: number) => {
14+ t = clamp01(t);
15+ const x = t * (n - 1);
16+ const i = Math.min(n - 2, Math.floor(x));
17+ const f = x - i;
18+ const a = stops[i];
19+ const b = stops[i + 1];
20+ return [
21+ Math.round(a[0] + (b[0] - a[0]) * f),
22+ Math.round(a[1] + (b[1] - a[1]) * f),
23+ Math.round(a[2] + (b[2] - a[2]) * f),
24+ ];
25+ };
26+};
27+
28+// Control points sampled from matplotlib colormaps
29+const viridis = makeInterpolated([
30+ [68, 1, 84],
31+ [72, 40, 120],
32+ [62, 74, 137],
33+ [49, 104, 142],
34+ [38, 130, 142],
35+ [31, 158, 137],
36+ [53, 183, 121],
37+ [109, 205, 89],
38+ [180, 222, 44],
39+ [253, 231, 37],
40+]);
41+
42+const plasma = makeInterpolated([
43+ [13, 8, 135],
44+ [84, 2, 163],
45+ [139, 10, 165],
46+ [185, 50, 137],
47+ [219, 92, 104],
48+ [244, 136, 73],
49+ [254, 188, 43],
50+ [240, 249, 33],
51+]);
52+
53+const inferno = makeInterpolated([
54+ [0, 0, 4],
55+ [40, 11, 84],
56+ [101, 21, 110],
57+ [159, 42, 99],
58+ [212, 72, 66],
59+ [245, 125, 21],
60+ [250, 193, 39],
61+ [252, 255, 164],
62+]);
63+
64+const coolwarm = makeInterpolated([
65+ [59, 76, 192],
66+ [124, 159, 249],
67+ [192, 212, 245],
68+ [242, 242, 242],
69+ [245, 195, 157],
70+ [222, 96, 77],
71+ [180, 4, 38],
72+]);
73+
74+// matplotlib's `seismic`: harder contrast about the middle than coolwarm, and
75+// dark at both ends, which suits a wavefield whose interesting parts are the
76+// extremes.
77+const seismic = makeInterpolated([
78+ [0, 0, 76],
79+ [0, 0, 255],
80+ [255, 255, 255],
81+ [255, 0, 0],
82+ [128, 0, 0],
83+]);
84+
85+const jet = makeInterpolated([
86+ [0, 0, 128],
87+ [0, 0, 255],
88+ [0, 255, 255],
89+ [0, 255, 0],
90+ [255, 255, 0],
91+ [255, 0, 0],
92+ [128, 0, 0],
93+]);
94+
95+const grayscale: ColormapFunc = (t: number) => {
96+ const v = Math.round(clamp01(t) * 255);
97+ return [v, v, v];
98+};
99+
100+/** Diverging maps first: the pressure field is signed and is drawn
101+ * symmetrically about zero, so a map with a distinct middle is what makes
102+ * the wavefronts read. */
103+export const colormaps: Record<string, ColormapFunc> = {
104+ coolwarm,
105+ seismic,
106+ grayscale,
107+ viridis,
108+ plasma,
109+ inferno,
110+ jet,
111+};
112+
113+export const colormapNames = Object.keys(colormaps);
src/render/overlay.tsadded+221−0View file
@@ -0,0 +1,221 @@
1+/**
2+ * The line work over the volume: every object, the body's wireframe, the
3+ * bridge and nut posts.
4+ *
5+ * A 2D canvas stacked on the WebGPU one, redrawn each frame from the object
6+ * readbacks and the same camera the ray march uses (cameraFrame), so its
7+ * lines land on the volume's pixels. Lines composited over a volume have no
8+ * correct depth — the overlay is always on top — which is the price of
9+ * keeping the march simple; these are markers and guides, not things in the
10+ * scene.
11+ *
12+ * Displacements are millimetres on a half-metre picture, so they are drawn
13+ * exaggerated (the factor the object plot states), and that is the one
14+ * honest exaggeration in the app: everything else is to scale. A line
15+ * object's displacement is drawn in y, a membrane's in z (a drum head moves
16+ * up and down); each object is drawn in its own colour, the same one its
17+ * partials wear in the spectrum.
18+ */
19+import type { CameraFrame } from './volume.ts';
20+
21+/** One line object (string or bar), as drawn. */
22+export interface OverlayLine {
23+ kind: 'line';
24+ color: string;
25+ L1: number;
26+ oy: number;
27+ oz: number;
28+ /** Displacement at the nodes, or null before the first readback. */
29+ u: Float32Array | null;
30+ /** Draw the nut/bridge posts down to z = 0 (strings). */
31+ posts: boolean;
32+}
33+
34+/** One membrane, as drawn: the rim, plus its two deformed centre lines. */
35+export interface OverlayRect {
36+ kind: 'rect';
37+ color: string;
38+ L1: number;
39+ L2: number;
40+ n1: number;
41+ n2: number;
42+ oz: number;
43+ u: Float32Array | null;
44+}
45+
46+export type OverlayObject = OverlayLine | OverlayRect;
47+
48+export interface OverlayBody {
49+ type: 'none' | 'box' | 'shell';
50+ /** box: boxl, boxw, boxd, holer, holex; shell: shellr, shelld. */
51+ params: Record<string, number>;
52+}
53+
54+export interface OverlayOptions {
55+ frame: CameraFrame;
56+ objects: OverlayObject[];
57+ body: OverlayBody;
58+ /** Displacement multiplier for visibility. */
59+ exaggerate: number;
60+ wireframe: boolean;
61+ showObjects: boolean;
62+}
63+
64+type P3 = [number, number, number];
65+
66+export class Overlay {
67+ readonly canvas: HTMLCanvasElement;
68+ #ctx: CanvasRenderingContext2D;
69+
70+ constructor(canvas: HTMLCanvasElement) {
71+ this.canvas = canvas;
72+ const ctx = canvas.getContext('2d');
73+ if (!ctx) throw new Error('overlay canvas has no 2d context');
74+ this.#ctx = ctx;
75+ }
76+
77+ resize(): void {
78+ const rect = this.canvas.getBoundingClientRect();
79+ const w = Math.max(1, Math.round(rect.width));
80+ const h = Math.max(1, Math.round(rect.height));
81+ if (this.canvas.width !== w || this.canvas.height !== h) {
82+ this.canvas.width = w;
83+ this.canvas.height = h;
84+ }
85+ }
86+
87+ clear(): void {
88+ this.#ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
89+ }
90+
91+ draw(o: OverlayOptions): void {
92+ const ctx = this.#ctx;
93+ const { width, height } = this.canvas;
94+ ctx.clearRect(0, 0, width, height);
95+
96+ const f = o.frame;
97+ /** World point -> pixel, or null when behind the camera. */
98+ const project = (q: P3): [number, number] | null => {
99+ const dx = q[0] - f.eye[0];
100+ const dy = q[1] - f.eye[1];
101+ const dz = q[2] - f.eye[2];
102+ const zc = dx * f.fwd[0] + dy * f.fwd[1] + dz * f.fwd[2];
103+ if (zc < 1e-6) return null;
104+ const xc = dx * f.right[0] + dy * f.right[1] + dz * f.right[2];
105+ const yc = dx * f.up[0] + dy * f.up[1] + dz * f.up[2];
106+ const nx = xc / (zc * f.tanHalfFov * f.aspect);
107+ const ny = yc / (zc * f.tanHalfFov);
108+ return [((nx + 1) / 2) * width, ((1 - ny) / 2) * height];
109+ };
110+
111+ const polyline = (pts: P3[], close = false): void => {
112+ ctx.beginPath();
113+ let started = false;
114+ for (const q of pts) {
115+ const s = project(q);
116+ if (!s) {
117+ started = false;
118+ continue;
119+ }
120+ if (started) ctx.lineTo(s[0], s[1]);
121+ else {
122+ ctx.moveTo(s[0], s[1]);
123+ started = true;
124+ }
125+ }
126+ if (close) ctx.closePath();
127+ ctx.stroke();
128+ };
129+
130+ const circle = (r: number, cx: number, zv: number): P3[] => {
131+ const pts: P3[] = [];
132+ for (let i = 0; i <= 48; i++) {
133+ const a = (2 * Math.PI * i) / 48;
134+ pts.push([cx + r * Math.cos(a), r * Math.sin(a), zv]);
135+ }
136+ return pts;
137+ };
138+
139+ if (o.wireframe && o.body.type !== 'none') {
140+ ctx.lineWidth = 1;
141+ ctx.strokeStyle = 'rgba(160, 170, 185, 0.65)';
142+ const bp = o.body.params;
143+ if (o.body.type === 'box') {
144+ const hx = (bp.boxl ?? 0) / 2;
145+ const hy = (bp.boxw ?? 0) / 2;
146+ const corners = (zv: number): P3[] => [
147+ [-hx, -hy, zv], [hx, -hy, zv], [hx, hy, zv], [-hx, hy, zv],
148+ ];
149+ polyline(corners(0), true);
150+ polyline(corners(-(bp.boxd ?? 0)), true);
151+ for (let i = 0; i < 4; i++) {
152+ polyline([corners(0)[i], corners(-(bp.boxd ?? 0))[i]]);
153+ }
154+ if ((bp.holer ?? 0) > 0) polyline(circle(bp.holer, bp.holex ?? 0, 0));
155+ } else {
156+ const r = bp.shellr ?? 0;
157+ const d = bp.shelld ?? 0;
158+ polyline(circle(r, 0, 0));
159+ polyline(circle(r, 0, -d));
160+ for (let i = 0; i < 4; i++) {
161+ const a = (Math.PI * i) / 2;
162+ polyline([
163+ [r * Math.cos(a), r * Math.sin(a), 0],
164+ [r * Math.cos(a), r * Math.sin(a), -d],
165+ ]);
166+ }
167+ }
168+ }
169+
170+ if (!o.showObjects) return;
171+ for (const obj of o.objects) {
172+ ctx.strokeStyle = obj.color;
173+ if (obj.kind === 'line') {
174+ const pts: P3[] = [];
175+ const n = obj.u?.length ?? 2;
176+ for (let i = 0; i < n; i++) {
177+ const xw = -obj.L1 / 2 + (obj.L1 * i) / (n - 1);
178+ const d = obj.u ? o.exaggerate * obj.u[i] : 0;
179+ pts.push([xw, obj.oy + d, obj.oz]);
180+ }
181+ ctx.lineWidth = 1.6;
182+ polyline(pts);
183+ if (obj.posts) {
184+ ctx.lineWidth = 2;
185+ ctx.strokeStyle = 'rgba(220, 226, 235, 0.8)';
186+ polyline([[-obj.L1 / 2, obj.oy, obj.oz], [-obj.L1 / 2, obj.oy, 0]]);
187+ polyline([[obj.L1 / 2, obj.oy, obj.oz], [obj.L1 / 2, obj.oy, 0]]);
188+ }
189+ } else {
190+ // The rim, then the two centre lines carrying the motion.
191+ const hx = obj.L1 / 2;
192+ const hy = obj.L2 / 2;
193+ ctx.lineWidth = 1.2;
194+ polyline([[-hx, -hy, obj.oz], [hx, -hy, obj.oz], [hx, hy, obj.oz], [-hx, hy, obj.oz]], true);
195+ if (obj.u) {
196+ ctx.lineWidth = 1.6;
197+ const midJ = Math.floor(obj.n2 / 2);
198+ const row: P3[] = [];
199+ for (let i = 0; i < obj.n1; i++) {
200+ row.push([
201+ -hx + (obj.L1 * i) / (obj.n1 - 1),
202+ (midJ / (obj.n2 - 1)) * obj.L2 - hy,
203+ obj.oz + o.exaggerate * obj.u[i + obj.n1 * midJ],
204+ ]);
205+ }
206+ polyline(row);
207+ const midI = Math.floor(obj.n1 / 2);
208+ const col: P3[] = [];
209+ for (let j = 0; j < obj.n2; j++) {
210+ col.push([
211+ (midI / (obj.n1 - 1)) * obj.L1 - hx,
212+ -hy + (obj.L2 * j) / (obj.n2 - 1),
213+ obj.oz + o.exaggerate * obj.u[midI + obj.n1 * j],
214+ ]);
215+ }
216+ polyline(col);
217+ }
218+ }
219+ }
220+ }
221+}
src/render/stringplot.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+/**
2+ * The string on its own: u(x), drawn flat.
3+ *
4+ * The 3D view shows the string in context but a millimetre of displacement
5+ * needs exaggeration there; this plot gives the displacement an honest axis.
6+ * The vertical scale is fixed to the pluck height rather than following the
7+ * field, so the decay of the note is visible as the curve settling, not as an
8+ * axis chasing it.
9+ */
10+export class StringPlot {
11+ #canvas: HTMLCanvasElement;
12+ #ctx: CanvasRenderingContext2D;
13+
14+ constructor(canvas: HTMLCanvasElement) {
15+ this.#canvas = canvas;
16+ const ctx = canvas.getContext('2d');
17+ if (!ctx) throw new Error('string plot canvas has no 2d context');
18+ this.#ctx = ctx;
19+ }
20+
21+ resize(): void {
22+ const rect = this.#canvas.getBoundingClientRect();
23+ const w = Math.max(1, Math.round(rect.width * devicePixelRatio));
24+ const h = Math.max(1, Math.round(rect.height * devicePixelRatio));
25+ if (this.#canvas.width !== w || this.#canvas.height !== h) {
26+ this.#canvas.width = w;
27+ this.#canvas.height = h;
28+ }
29+ }
30+
31+ /** Draw the displacement `u` (metres) against a fixed range ±`umax`. */
32+ draw(u: Float32Array | null, umax: number, dark: boolean): void {
33+ const ctx = this.#ctx;
34+ const { width: w, height: h } = this.#canvas;
35+ ctx.clearRect(0, 0, w, h);
36+
37+ const ink = dark ? 'rgba(230, 233, 236, 0.9)' : 'rgba(31, 35, 40, 0.9)';
38+ const faint = dark ? 'rgba(154, 164, 175, 0.35)' : 'rgba(87, 96, 106, 0.35)';
39+ const pad = 6 * devicePixelRatio;
40+
41+ // Rest line and end posts.
42+ ctx.lineWidth = devicePixelRatio;
43+ ctx.strokeStyle = faint;
44+ ctx.beginPath();
45+ ctx.moveTo(pad, h / 2);
46+ ctx.lineTo(w - pad, h / 2);
47+ ctx.stroke();
48+ ctx.beginPath();
49+ ctx.moveTo(pad, pad);
50+ ctx.lineTo(pad, h - pad);
51+ ctx.moveTo(w - pad, pad);
52+ ctx.lineTo(w - pad, h - pad);
53+ ctx.stroke();
54+
55+ if (!u || u.length < 2 || !(umax > 0)) return;
56+ ctx.lineWidth = 1.6 * devicePixelRatio;
57+ ctx.strokeStyle = ink;
58+ ctx.beginPath();
59+ for (let i = 0; i < u.length; i++) {
60+ const x = pad + ((w - 2 * pad) * i) / (u.length - 1);
61+ const y = h / 2 - (h / 2 - pad) * Math.max(-1, Math.min(1, u[i] / umax));
62+ if (i === 0) ctx.moveTo(x, y);
63+ else ctx.lineTo(x, y);
64+ }
65+ ctx.stroke();
66+ }
67+}
src/render/volume.tsadded+379−0View file
@@ -0,0 +1,379 @@
1+/**
2+ * Drawing the pressure field, straight out of the buffer the solver wrote.
3+ *
4+ * There is no readback in the display path: the fragment shader reads the
5+ * solver's storage buffer directly, so a frame costs one draw call and no
6+ * GPU-to-CPU round trip. Adapted from acoustic-scattering-3d's renderer; the
7+ * differences are a rectangular domain instead of a cube, and the body drawn
8+ * from the wall mask instead of from a speed contrast.
9+ *
10+ * The picture is a ray march. Each pixel casts one ray, intersects it with
11+ * the domain box, and steps along it accumulating emission front to back:
12+ * the pressure goes through a diverging colormap about zero, and the opacity
13+ * goes as a power of |p|, so quiet regions are transparent and the
14+ * wavefronts are what you see. The body is added as a grey emission where
15+ * the wall mask says solid, which shows the box and its sound hole without
16+ * needing its own kind of drawing.
17+ *
18+ * Two honest limitations. Sampling is nearest-neighbour, not trilinear: the
19+ * field lives in a storage buffer rather than a filterable 3D texture, so
20+ * trilinear would be eight fetches per sample and the march takes tens of
21+ * millions of samples a frame. With the ray step set near the cell size the
22+ * difference is visible mainly as a faint stippling on strong wavefronts.
23+ * And the compositing is emission only, with no lighting and no shadowing,
24+ * so what is behind a strong feature is dimmed but never occluded correctly.
25+ *
26+ * A clip plane on y is provided because a volume render of a wavefield is
27+ * mostly the outside of a wavefield. Pulling the clip in cuts the picture
28+ * lengthwise through the string and the cavity, which is the view that
29+ * actually shows the instrument working.
30+ */
31+import type { ColormapFunc } from './colormaps.ts';
32+
33+const SHADER = `
34+struct View {
35+ eye: vec4f, // xyz: eye position, w: tan(fov/2)
36+ right: vec4f, // xyz: camera right, w: aspect ratio
37+ up: vec4f, // xyz: camera up, w: pressure the colormap saturates at
38+ fwd: vec4f, // xyz: camera forward, w: contrast exponent
39+ bg: vec4f, // rgb: background, w: body strength
40+ dims: vec4f, // Lx, Ly, Lz, h
41+ grid: vec4f, // nx, ny, nz, ray steps
42+ misc: vec4f, // opacity, clip y, (unused), (unused)
43+ m: vec4f, // xyz: microphone position, w: whether to draw it
44+};
45+
46+@group(0) @binding(0) var<uniform> V: View;
47+@group(0) @binding(1) var<storage, read> p: array<f32>;
48+@group(0) @binding(2) var<storage, read> wall: array<f32>;
49+@group(0) @binding(3) var cmap: texture_2d<f32>;
50+@group(0) @binding(4) var samp: sampler;
51+
52+struct VSOut {
53+ @builtin(position) pos: vec4f,
54+ @location(0) ndc: vec2f,
55+};
56+
57+@vertex
58+fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
59+ // One oversized triangle covering the viewport.
60+ var xy = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
61+ var out: VSOut;
62+ let q = xy[vi];
63+ out.pos = vec4f(q, 0.0, 1.0);
64+ out.ndc = q;
65+ return out;
66+}
67+
68+fn voxel(q: vec3f) -> u32 {
69+ let n = vec3i(i32(V.grid.x), i32(V.grid.y), i32(V.grid.z));
70+ let h = V.dims.w;
71+ let g = clamp(vec3i(floor((q + 0.5 * V.dims.xyz) / h)), vec3i(0), n - vec3i(1));
72+ return u32(g.x + n.x * (g.y + n.y * g.z));
73+}
74+
75+// A point on the box's surface is on an edge when two of its three distances
76+// to the bounding planes vanish, so the test is on the median of the three.
77+fn edge(q: vec3f) -> f32 {
78+ let d = abs(abs(q) - 0.5 * V.dims.xyz);
79+ let lo = min(d.x, min(d.y, d.z));
80+ let hi = max(d.x, max(d.y, d.z));
81+ let mid = d.x + d.y + d.z - lo - hi;
82+ let w = 0.004 * V.dims.x;
83+ return 1.0 - smoothstep(w, 2.0 * w, mid);
84+}
85+
86+@fragment
87+fn fs(in: VSOut) -> @location(0) vec4f {
88+ let half = 0.5 * V.dims.xyz;
89+ let eye = V.eye.xyz;
90+ let dir = normalize(V.fwd.xyz
91+ + in.ndc.x * V.right.w * V.eye.w * V.right.xyz
92+ + in.ndc.y * V.eye.w * V.up.xyz);
93+
94+ let inv = 1.0 / dir;
95+ let ta = (-half - eye) * inv;
96+ let tb = (half - eye) * inv;
97+ let lo = min(ta, tb);
98+ let hi = max(ta, tb);
99+ let t1 = min(min(hi.x, hi.y), hi.z);
100+ var t0 = max(max(lo.x, lo.y), lo.z);
101+
102+ var col = V.bg.rgb;
103+ if (t1 <= max(t0, 0.0)) { return vec4f(col, 1.0); }
104+ t0 = max(t0, 0.0);
105+
106+ let lineCol = vec3f(0.42, 0.47, 0.55);
107+ col = mix(col, lineCol, 0.5 * edge(eye + dir * t1));
108+
109+ let steps = i32(V.grid.w);
110+ let dl = (t1 - t0) / f32(steps);
111+ // Opacity is quoted per cell, so a longer ray step is proportionally more
112+ // opaque and the picture does not change brightness with the quality knob.
113+ let unit = dl / V.dims.w;
114+ let scale = max(V.up.w, 1e-20);
115+ let grey = vec3f(0.55, 0.58, 0.63);
116+
117+ var acc = vec3f(0.0);
118+ var alpha = 0.0;
119+ for (var k = 0; k < steps; k = k + 1) {
120+ if (alpha > 0.995) { break; }
121+ let q = eye + dir * (t0 + (f32(k) + 0.5) * dl);
122+ if (q.y > V.misc.y) { continue; }
123+ let i = voxel(q);
124+ let v = clamp(p[i] / scale, -1.0, 1.0);
125+ var a = pow(abs(v), V.fwd.w) * V.misc.x * unit;
126+ var rgb = textureSampleLevel(cmap, samp, vec2f(0.5 + 0.5 * v, 0.5), 0.0).rgb;
127+ if (V.bg.w > 0.0) {
128+ let m = clamp(1.0 - wall[i], 0.0, 1.0);
129+ let am = m * V.bg.w * unit;
130+ let tot = a + am;
131+ if (tot > 1e-12) { rgb = (a * rgb + am * grey) / tot; }
132+ a = tot;
133+ }
134+ a = clamp(a, 0.0, 1.0);
135+ acc = acc + (1.0 - alpha) * a * rgb;
136+ alpha = alpha + (1.0 - alpha) * a;
137+ }
138+ col = acc + (1.0 - alpha) * col;
139+ col = mix(col, lineCol, 0.75 * edge(eye + dir * t0));
140+
141+ // The microphone, a dot in a ring at the ray's closest approach to it.
142+ // Drawn on top rather than composited into the march: a marker, not a
143+ // thing in the scene.
144+ if (V.m.w > 0.0) {
145+ let toM = V.m.xyz - eye;
146+ let tm = dot(toM, dir);
147+ if (tm > 0.0) {
148+ let d = length(toM - tm * dir) / V.dims.x;
149+ let dotm = 1.0 - smoothstep(0.004, 0.007, d);
150+ let ring = 1.0 - smoothstep(0.0015, 0.004, abs(d - 0.016));
151+ col = mix(col, vec3f(1.0), 0.9 * max(dotm, ring));
152+ }
153+ }
154+ return vec4f(col, 1.0);
155+}
156+`;
157+
158+/** Where the camera is looking from. Angles in radians, distance in metres. */
159+export interface Camera {
160+ az: number;
161+ el: number;
162+ dist: number;
163+}
164+
165+/** The camera's orthonormal frame, shared with the overlay so its lines land
166+ * on the volume's pixels. */
167+export interface CameraFrame {
168+ eye: [number, number, number];
169+ right: [number, number, number];
170+ up: [number, number, number];
171+ fwd: [number, number, number];
172+ tanHalfFov: number;
173+ aspect: number;
174+}
175+
176+export const TAN_HALF_FOV = Math.tan((32 * Math.PI) / 360);
177+
178+const cross = (a: number[], b: number[]): [number, number, number] => [
179+ a[1] * b[2] - a[2] * b[1],
180+ a[2] * b[0] - a[0] * b[2],
181+ a[0] * b[1] - a[1] * b[0],
182+];
183+const norm = (a: [number, number, number]): [number, number, number] => {
184+ const m = Math.hypot(a[0], a[1], a[2]) || 1;
185+ return [a[0] / m, a[1] / m, a[2] / m];
186+};
187+
188+export function cameraFrame(camera: Camera, aspect: number): CameraFrame {
189+ const { az, el, dist } = camera;
190+ const ce = Math.cos(el);
191+ const toEye: [number, number, number] = [
192+ ce * Math.cos(az),
193+ ce * Math.sin(az),
194+ Math.sin(el),
195+ ];
196+ const eye: [number, number, number] = [toEye[0] * dist, toEye[1] * dist, toEye[2] * dist];
197+ const fwd: [number, number, number] = [-toEye[0], -toEye[1], -toEye[2]];
198+ const right = norm(cross([0, 0, 1], fwd));
199+ const up = cross(fwd, right);
200+ return { eye, right, up, fwd, tanHalfFov: TAN_HALF_FOV, aspect };
201+}
202+
203+export interface DrawOptions {
204+ frame: CameraFrame;
205+ /** Pressure the colormap saturates at, in both directions. */
206+ scale: number;
207+ opacity: number;
208+ contrast: number;
209+ /** Samples along each ray. */
210+ steps: number;
211+ /** Everything with y above this is not drawn, in metres. */
212+ clipY: number;
213+ /** Strength of the body's grey emission, 0 to turn it off. */
214+ body: number;
215+ /** Microphone position in metres, or null to not draw it. */
216+ mic?: { x: number; y: number; z: number } | null;
217+}
218+
219+export interface VolumeDims {
220+ nx: number;
221+ ny: number;
222+ nz: number;
223+ Lx: number;
224+ Ly: number;
225+ Lz: number;
226+ h: number;
227+}
228+
229+export class VolumeView {
230+ readonly canvas: HTMLCanvasElement;
231+
232+ #device: GPUDevice;
233+ #context: GPUCanvasContext;
234+ #pipeline: GPURenderPipeline;
235+ #layout: GPUBindGroupLayout;
236+ #uniform: GPUBuffer;
237+ #host = new ArrayBuffer(9 * 16);
238+ #sampler: GPUSampler;
239+ #cmapTexture: GPUTexture;
240+ #bindGroup: GPUBindGroup | null = null;
241+ #dims: VolumeDims | null = null;
242+ /** Background, matched to the CSS so the box sits on the page. */
243+ bg: [number, number, number] = [0.043, 0.055, 0.071];
244+
245+ constructor(device: GPUDevice, canvas: HTMLCanvasElement) {
246+ this.#device = device;
247+ this.canvas = canvas;
248+
249+ const context = canvas.getContext('webgpu');
250+ if (!context) throw new Error('this canvas has no WebGPU context');
251+ this.#context = context;
252+ const format = navigator.gpu.getPreferredCanvasFormat();
253+ context.configure({ device, format, alphaMode: 'opaque' });
254+
255+ this.#layout = device.createBindGroupLayout({
256+ label: 'volume-view',
257+ entries: [
258+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
259+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
260+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
261+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
262+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
263+ ],
264+ });
265+
266+ const module = device.createShaderModule({ code: SHADER, label: 'volume-view' });
267+ this.#pipeline = device.createRenderPipeline({
268+ label: 'volume-view',
269+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.#layout] }),
270+ vertex: { module, entryPoint: 'vs' },
271+ fragment: { module, entryPoint: 'fs', targets: [{ format }] },
272+ primitive: { topology: 'triangle-list' },
273+ });
274+
275+ this.#uniform = device.createBuffer({
276+ label: 'volume-view-uniform',
277+ size: this.#host.byteLength,
278+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
279+ });
280+ this.#sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
281+ this.#cmapTexture = device.createTexture({
282+ label: 'colormap',
283+ size: [256, 1],
284+ format: 'rgba8unorm',
285+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
286+ });
287+ }
288+
289+ /** Point the view at the buffers of a (new) simulation: the host-owned
290+ * pressure state and the wall mask, which is what the body is drawn from. */
291+ setSource(pressure: GPUBuffer, wall: GPUBuffer, dims: VolumeDims): void {
292+ this.#dims = dims;
293+ this.#bindGroup = this.#device.createBindGroup({
294+ layout: this.#layout,
295+ entries: [
296+ { binding: 0, resource: { buffer: this.#uniform } },
297+ { binding: 1, resource: { buffer: pressure } },
298+ { binding: 2, resource: { buffer: wall } },
299+ { binding: 3, resource: this.#cmapTexture.createView() },
300+ { binding: 4, resource: this.#sampler },
301+ ],
302+ });
303+ }
304+
305+ setColormap(cmap: ColormapFunc): void {
306+ const data = new Uint8Array(256 * 4);
307+ for (let i = 0; i < 256; i++) {
308+ const [r, g, b] = cmap(i / 255);
309+ data[4 * i] = r;
310+ data[4 * i + 1] = g;
311+ data[4 * i + 2] = b;
312+ data[4 * i + 3] = 255;
313+ }
314+ this.#device.queue.writeTexture(
315+ { texture: this.#cmapTexture },
316+ data,
317+ { bytesPerRow: 256 * 4 },
318+ { width: 256, height: 1 },
319+ );
320+ }
321+
322+ /** Draw one frame. */
323+ draw(o: DrawOptions): void {
324+ const bg = this.#bindGroup;
325+ const dims = this.#dims;
326+ if (!bg || !dims) return;
327+
328+ const { eye, right, up, fwd } = o.frame;
329+ const f = new Float32Array(this.#host);
330+ f.set([eye[0], eye[1], eye[2], o.frame.tanHalfFov], 0);
331+ f.set([right[0], right[1], right[2], o.frame.aspect], 4);
332+ f.set([up[0], up[1], up[2], o.scale], 8);
333+ f.set([fwd[0], fwd[1], fwd[2], o.contrast], 12);
334+ f.set([this.bg[0], this.bg[1], this.bg[2], o.body], 16);
335+ f.set([dims.Lx, dims.Ly, dims.Lz, dims.h], 20);
336+ f.set([dims.nx, dims.ny, dims.nz, o.steps], 24);
337+ f.set([o.opacity, o.clipY, 0, 0], 28);
338+ f.set([o.mic?.x ?? 0, o.mic?.y ?? 0, o.mic?.z ?? 0, o.mic ? 1 : 0], 32);
339+ this.#device.queue.writeBuffer(this.#uniform, 0, this.#host);
340+
341+ const enc = this.#device.createCommandEncoder({ label: 'volume-view' });
342+ const pass = enc.beginRenderPass({
343+ colorAttachments: [
344+ {
345+ view: this.#context.getCurrentTexture().createView(),
346+ clearValue: { r: this.bg[0], g: this.bg[1], b: this.bg[2], a: 1 },
347+ loadOp: 'clear',
348+ storeOp: 'store',
349+ },
350+ ],
351+ });
352+ pass.setPipeline(this.#pipeline);
353+ pass.setBindGroup(0, bg);
354+ pass.draw(3);
355+ pass.end();
356+ this.#device.queue.submit([enc.finish()]);
357+ }
358+
359+ /**
360+ * Match the canvas's backing store to its CSS size. Device pixel ratio is
361+ * ignored: the march is the whole cost of a frame and it scales with
362+ * pixels, so a retina display would pay four times over for a picture that
363+ * is already smooth.
364+ */
365+ resize(): void {
366+ const rect = this.canvas.getBoundingClientRect();
367+ const w = Math.max(1, Math.round(rect.width));
368+ const h = Math.max(1, Math.round(rect.height));
369+ if (this.canvas.width !== w || this.canvas.height !== h) {
370+ this.canvas.width = w;
371+ this.canvas.height = h;
372+ }
373+ }
374+
375+ destroy(): void {
376+ this.#uniform.destroy();
377+ this.#cmapTexture.destroy();
378+ }
379+}
src/scene/scene.tsadded+228−0View file
@@ -0,0 +1,228 @@
1+/**
2+ * The scene: bodies, absorption, and every object's coupling geometry, as a
3+ * .m file evaluated once on the air grid.
4+ *
5+ * A scene file is ordinary MATLAB defining one function,
6+ *
7+ * function [c, sig, wall, prof_s1, ...] = medium(x, y, z, <...>)
8+ *
9+ * over the solver's grid points. Unlike the model it is *not* compiled to
10+ * WGSL: the step runs every timestep and must lower to a fixed sequence of
11+ * GPU dispatches, but a scene is evaluated exactly once and survives only as
12+ * arrays of numbers. So it runs through numbl's CPU interpreter instead,
13+ * which buys the full MATLAB subset — loops, `if`, indexing, anything in
14+ * tools/ — and f64 evaluation, where the step dialect is element-wise f32.
15+ *
16+ * The first three outputs are fixed:
17+ * c sound speed, m/s (normally just c0 everywhere — bodies are a
18+ * mask, not a material, so they cost no timestep)
19+ * sig absorption rate, 1/s (the sponge at the domain edge, plus any
20+ * absorption a body's surfaces are given)
21+ * wall 1 in air, 0 in a body's solid shell; what lapw masks by
22+ * After them come whatever coupling profiles this scene's objects need
23+ * (prof_s1, brg_s1, ...), named by the caller — the studio's code generator
24+ * decides the list, and the same list is offered to the compiled model as
25+ * uploadable fields.
26+ */
27+import { parseMFile, type FunctionStmt } from 'numbl-src/numbl-core/parser/index.ts';
28+import { executeCode } from 'numbl-src/numbl-core/executeCode.ts';
29+import {
30+ RuntimeTensor,
31+ isRuntimeTensor,
32+ type RuntimeValue,
33+} from 'numbl-src/numbl-core/runtime/types.ts';
34+import type { AirGrid } from '../grids.ts';
35+import { C_AIR } from '../units.ts';
36+import { toolFiles } from '../tools.ts';
37+import { inFunction, inModel, ModelCompileError } from '../mgpu/errors.ts';
38+import type { ModelParams } from '../mgpu/model.ts';
39+
40+/** The function a scene file must define. */
41+export const MEDIUM_FN = 'medium';
42+
43+export interface SceneOptions {
44+ air: AirGrid;
45+ /** Scene source (.m text). */
46+ source: string;
47+ /** Output names, in order: 'c', 'sig', 'wall', then the profiles. */
48+ outputs: string[];
49+ /** Exact scalars offered by name beyond the grid's own (object lengths and
50+ * the like). */
51+ consts: Record<string, number>;
52+ /** Parameter names the .m may take beyond the grid and the consts. */
53+ paramNames: string[];
54+ params: ModelParams;
55+}
56+
57+export class Scene {
58+ /** Every output field, air-sized, by name. */
59+ readonly fields: Record<string, Float32Array>;
60+ readonly cmin: number;
61+ readonly cmax: number;
62+
63+ private constructor(fields: Record<string, Float32Array>) {
64+ this.fields = fields;
65+ let lo = Infinity;
66+ let hi = 0;
67+ for (const v of fields.c) {
68+ if (v < lo) lo = v;
69+ if (v > hi) hi = v;
70+ }
71+ this.cmin = lo;
72+ this.cmax = hi;
73+ }
74+
75+ get c(): Float32Array {
76+ return this.fields.c;
77+ }
78+ get sig(): Float32Array {
79+ return this.fields.sig;
80+ }
81+ get wall(): Float32Array {
82+ return this.fields.wall;
83+ }
84+
85+ static create(opts: SceneOptions): Scene {
86+ const fields = evaluateScene(opts);
87+ const { c, sig, wall } = fields;
88+ for (let i = 0; i < c.length; i++) {
89+ if (!(c[i] > 0)) {
90+ throw new ModelCompileError(
91+ `the scene's sound speed is ${c[i]} somewhere; it must be positive ` +
92+ `everywhere (the timestep is set by the fastest point, and a zero ` +
93+ `or negative speed has no wave equation)`,
94+ { fn: MEDIUM_FN },
95+ );
96+ }
97+ if (!(sig[i] >= 0)) {
98+ throw new ModelCompileError(
99+ `the scene's absorption is ${sig[i]} somewhere; it must be zero or ` +
100+ `positive (a negative one would amplify rather than absorb)`,
101+ { fn: MEDIUM_FN },
102+ );
103+ }
104+ // The mask multiplies Laplacian fluxes; outside [0, 1] it would add
105+ // energy or invert a face. Clamp rather than refuse: a smoothed
106+ // difference of indicators dips a hair below zero in f64 routinely.
107+ if (wall[i] < 0) wall[i] = 0;
108+ else if (wall[i] > 1) wall[i] = 1;
109+ }
110+ return new Scene(fields);
111+ }
112+}
113+
114+/**
115+ * Evaluate the scene file on the grid, through numbl's CPU interpreter.
116+ *
117+ * The .m keeps the same contract the model has: it names the arguments it
118+ * wants — the coordinates, the domain's numbers, and any of the offered
119+ * parameters — and the host supplies them by name, so their order in the
120+ * signature is the .m's own business.
121+ */
122+function evaluateScene(opts: SceneOptions): Record<string, Float32Array> {
123+ const { air, source, outputs, paramNames, params } = opts;
124+ const file = `${MEDIUM_FN}.m`;
125+ const ast = inModel(() => parseMFile(source, file));
126+ const fn = ast.body.find(
127+ (s): s is FunctionStmt =>
128+ s.type === 'Function' && (s as FunctionStmt).name === MEDIUM_FN,
129+ );
130+ if (!fn) {
131+ throw new ModelCompileError(`the scene defines no function named '${MEDIUM_FN}'`);
132+ }
133+ if (fn.outputs.length !== outputs.length) {
134+ throw new ModelCompileError(
135+ `'${MEDIUM_FN}' must return ${outputs.length} outputs ` +
136+ `[${outputs.join(', ')}], not ${fn.outputs.length}`,
137+ { fn: MEDIUM_FN, start: fn.span.start, end: fn.span.end },
138+ );
139+ }
140+ // What the grid offers a scene by name, beyond its own parameters: the
141+ // coordinates in metres, the numbers that describe the domain, the speed
142+ // of sound in air, and whatever consts the caller adds (object lengths —
143+ // the scene builds the coupling profiles around each object, so it needs
144+ // to know how big each one is).
145+ const vars: Record<string, RuntimeValue> = {
146+ x: new RuntimeTensor(air.x64, [air.npts, 1]),
147+ y: new RuntimeTensor(air.y64, [air.npts, 1]),
148+ z: new RuntimeTensor(air.z64, [air.npts, 1]),
149+ Lx: air.Lx,
150+ Ly: air.Ly,
151+ Lz: air.Lz,
152+ h: air.h,
153+ c0: C_AIR,
154+ npts: air.npts,
155+ nx: air.nx,
156+ ny: air.ny,
157+ nz: air.nz,
158+ ...opts.consts,
159+ };
160+ const known = new Set([...Object.keys(vars), ...paramNames]);
161+ for (const p of fn.params) {
162+ if (!known.has(p)) {
163+ throw new ModelCompileError(
164+ `'${MEDIUM_FN}' takes an argument '${p}' that is neither the grid ` +
165+ `(${Object.keys(vars).join(', ')}) nor one of this scene's parameters` +
166+ (paramNames.length ? ` (${paramNames.join(', ')})` : ''),
167+ { fn: MEDIUM_FN, start: fn.span.start, end: fn.span.end },
168+ );
169+ }
170+ }
171+
172+ for (const name of paramNames) {
173+ const v = params[name];
174+ // Missing parameters read as 0, as ModelPlan.setParams has it.
175+ vars[name] = Number.isFinite(v) ? v : 0;
176+ }
177+
178+ const outNames = outputs.map((o) => `${o}__`);
179+ const driver = `[${outNames.join(', ')}] = ${MEDIUM_FN}(${fn.params.join(', ')});`;
180+ const result = inFunction(MEDIUM_FN, () =>
181+ executeCode(
182+ driver,
183+ { initialVariableValues: vars, displayResults: false, implicitCwdPath: null },
184+ [...toolFiles, { name: file, source }],
185+ 'scene-driver.m',
186+ ),
187+ );
188+
189+ const fields: Record<string, Float32Array> = {};
190+ outputs.forEach((name, i) => {
191+ fields[name] = toGridField(
192+ result.variableValues[outNames[i]],
193+ name,
194+ air.npts,
195+ );
196+ });
197+ return fields;
198+}
199+
200+/** One returned field -> npts values, rounded to the solver's f32. */
201+function toGridField(
202+ value: RuntimeValue | undefined,
203+ name: string,
204+ npts: number,
205+): Float32Array {
206+ // A uniform field stays scalar in MATLAB; spread it over the grid.
207+ if (typeof value === 'number') return new Float32Array(npts).fill(value);
208+ if (value !== undefined && isRuntimeTensor(value)) {
209+ if (value.imag) {
210+ throw new ModelCompileError(
211+ `the scene's '${name}' is complex; the medium must be real`,
212+ { fn: MEDIUM_FN },
213+ );
214+ }
215+ // A vector of npts values, either orientation. A reshape is refused
216+ // rather than reordered: the tensor's column-major layout would not match
217+ // the grid's x-fastest order.
218+ if (value.data.length === npts && value.shape.every((d) => d === 1 || d === npts)) {
219+ return new Float32Array(value.data);
220+ }
221+ throw new ModelCompileError(
222+ `the scene's '${name}' is ${value.shape.join(' x ')}, but the grid wants ` +
223+ `one value per point (${npts} x 1)`,
224+ { fn: MEDIUM_FN },
225+ );
226+ }
227+ throw new ModelCompileError(`the scene's '${name}' is not numeric`, { fn: MEDIUM_FN });
228+}
src/studio/bodies.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+/**
2+ * The bodies an instrument can sound over, as data plus a MATLAB fragment.
3+ *
4+ * A body is a rigid shell drawn as a *mask* into the air grid — 0 inside the
5+ * solid, 1 in air — which the solver's masked Laplacian turns into the
6+ * sound-hard (Neumann) condition at no timestep cost (see src/mgpu/ops.ts).
7+ * What a rigid mask cannot do is absorb, so each body paints a thin layer of
8+ * ordinary volume absorption over its surfaces (the smoothed mask's
9+ * transition skin, 4·shell·(1-shell), which peaks exactly at the surface);
10+ * that parameter is what tames a cavity's ring the way real wood does.
11+ *
12+ * Each template contributes the MATLAB lines that compute `shell` (0..1,
13+ * 1 inside the solid) from the grid coordinates and its own parameters; the
14+ * code generator (src/studio/codegen.ts) wraps them into the scene's
15+ * `medium` function. Masks are drawn with a sharp profile (about half a
16+ * cell) on purpose: these shapes are grid-aligned or round, so sharpness
17+ * costs no staircase artefacts, and a plate only two cells thick has to
18+ * reach mask ≈ 0 at its centre or it leaks.
19+ */
20+import type { BodyType, ParamSpec } from './types.ts';
21+
22+export interface BodyTemplate {
23+ type: BodyType;
24+ label: string;
25+ blurb: string;
26+ paramSpecs: ParamSpec[];
27+ /** MATLAB lines assigning `shell` (npts x 1, 0..1). May use x, y, z, h,
28+ * npts and this body's parameters. */
29+ matlab: string[];
30+}
31+
32+const noneBody: BodyTemplate = {
33+ type: 'none',
34+ label: 'Open air',
35+ blurb: 'No body at all: the objects sound in free air.',
36+ paramSpecs: [],
37+ matlab: ['shell = zeros(npts, 1);'],
38+};
39+
40+const boxBody: BodyTemplate = {
41+ type: 'box',
42+ label: 'Box',
43+ blurb: 'A rigid box under the objects, with a round sound hole in its top plate — a dulcimer or guitar body, minus the wood’s own modes.',
44+ paramSpecs: [
45+ { key: 'boxl', label: 'length (m)', value: 0.66, min: 0.3, max: 0.9, step: 0.01,
46+ hint: 'Along x. The default just outreaches the default string.' },
47+ { key: 'boxw', label: 'width (m)', value: 0.16, min: 0.08, max: 0.4, step: 0.005 },
48+ { key: 'boxd', label: 'depth (m)', value: 0.08, min: 0.03, max: 0.25, step: 0.005,
49+ hint: 'How far the box hangs below its top plate (which sits at z = 0). Deeper means a bigger cavity and a lower air resonance.' },
50+ { key: 'thick', label: 'wall thickness (m)', value: 0.016, min: 0.008, max: 0.05, step: 0.002,
51+ hint: 'Plates thinner than about two grid cells leak; the app warns rather than stops you.' },
52+ { key: 'holer', label: 'sound hole radius (m)', value: 0.035, min: 0, max: 0.12, step: 0.005,
53+ hint: '0 seals the box. The hole is what lets the cavity speak.' },
54+ { key: 'holex', label: 'sound hole x (m)', value: -0.05, min: -0.3, max: 0.3, step: 0.01 },
55+ { key: 'absorb', label: 'surface absorption (1/s)', value: 800, min: 0, max: 4000, step: 50,
56+ hint: 'A lossy skin over the shell’s surfaces — the stand-in for wood’s own damping. 0 lets the cavity ring hard.' },
57+ ],
58+ matlab: [
59+ '% The box: the outer solid minus its interior cavity, top plate at z = 0.',
60+ 'sw = 0.45 * h;',
61+ 'box_ox = 0.5 * (1 - tanh((abs(x) - boxl/2) / sw));',
62+ 'box_oy = 0.5 * (1 - tanh((abs(y) - boxw/2) / sw));',
63+ 'box_oz = 0.5 * (1 - tanh((abs(z + boxd/2) - boxd/2) / sw));',
64+ 'box_ix = 0.5 * (1 - tanh((abs(x) - (boxl/2 - thick)) / sw));',
65+ 'box_iy = 0.5 * (1 - tanh((abs(y) - (boxw/2 - thick)) / sw));',
66+ 'box_iz = 0.5 * (1 - tanh((abs(z + boxd/2) - (boxd/2 - thick)) / sw));',
67+ 'shell = max(0, box_ox .* box_oy .* box_oz - box_ix .* box_iy .* box_iz);',
68+ '% The sound hole: a cylinder cut out of the top plate only.',
69+ 'hole_r = sqrt((x - holex).^2 + y.^2);',
70+ 'hole = 0.5 * (1 - tanh((hole_r - holer) / sw));',
71+ 'top = 0.5 * (1 - tanh((abs(z + thick/2) - thick/2) / sw));',
72+ 'shell = max(0, shell - hole .* top);',
73+ ],
74+};
75+
76+const shellBody: BodyTemplate = {
77+ type: 'shell',
78+ label: 'Drum shell',
79+ blurb: 'A rigid cylinder hanging below z = 0, open at the top — put a membrane over it and it is a drum.',
80+ paramSpecs: [
81+ { key: 'shellr', label: 'radius (m)', value: 0.17, min: 0.08, max: 0.3, step: 0.005,
82+ hint: 'A hair larger than the head that sits over it.' },
83+ { key: 'shelld', label: 'depth (m)', value: 0.3, min: 0.05, max: 0.4, step: 0.01,
84+ hint: 'The cavity under the head. Deeper means a lower air resonance.' },
85+ { key: 'thick', label: 'wall thickness (m)', value: 0.016, min: 0.008, max: 0.05, step: 0.002 },
86+ { key: 'closed', label: 'closed bottom (0/1)', value: 1, min: 0, max: 1, step: 1,
87+ hint: '1 puts a plate across the bottom (a closed tom); 0 leaves the cylinder open like a concert tom.' },
88+ { key: 'absorb', label: 'surface absorption (1/s)', value: 800, min: 0, max: 4000, step: 50,
89+ hint: 'A lossy skin over the shell’s surfaces. 0 lets the cavity ring hard.' },
90+ ],
91+ matlab: [
92+ '% The shell: a cylindrical ring from z = -shelld to 0, optional bottom.',
93+ 'sw = 0.45 * h;',
94+ 'shell_r = sqrt(x.^2 + y.^2);',
95+ 'cyl_o = 0.5 * (1 - tanh((shell_r - shellr) / sw));',
96+ 'cyl_i = 0.5 * (1 - tanh((shell_r - (shellr - thick)) / sw));',
97+ 'cyl_z = 0.5 * (1 - tanh((abs(z + shelld/2) - shelld/2) / sw));',
98+ 'bot = 0.5 * (1 - tanh((abs(z + shelld - thick/2) - thick/2) / sw));',
99+ 'shell = min(1, max(0, cyl_o - cyl_i) .* cyl_z + closed * (cyl_i .* bot));',
100+ ],
101+};
102+
103+export const BODY_TEMPLATES: Record<BodyType, BodyTemplate> = {
104+ none: noneBody,
105+ box: boxBody,
106+ shell: shellBody,
107+};
src/studio/codegen.tsadded+416−0View file
@@ -0,0 +1,416 @@
1+/**
2+ * Scene description -> the two MATLAB files, and everything around them.
3+ *
4+ * This is the studio's hinge. A SceneSpec (src/studio/types.ts) says *what*
5+ * is in the scene; this module writes the model .m (init and step for every
6+ * object plus the air, all advanced together) and the scene .m (bodies,
7+ * absorption, and each object's coupling profiles), instantiates the
8+ * external ops each object needs with its grid geometry baked in, and lists
9+ * the constants, fields, state and parameters the host must offer the
10+ * compiler.
11+ *
12+ * The generated files are not an internal detail: the Lab view shows them,
13+ * and they are editable — an edited source compiles through exactly the same
14+ * pipeline, the generator's output being nothing more than a starting point.
15+ * That is why the generator works hard at readability: suffixed names
16+ * (`u_s1`, `cs_s1`) rather than positional arrays, one object's physics kept
17+ * together under its own comment, and the same idioms a hand-written model
18+ * (dulcimer's) uses.
19+ *
20+ * Naming scheme, per object id ID:
21+ * u_ID, um_ID the object's displacement and its history (state)
22+ * xs_ID / ym_ID node coordinates (local, 0..L), pin_ID the pin mask
23+ * n_ID, L_ID node count and length(s) — exact constants
24+ * dxx_ID, lap_ID, the object's stencil and coupling ops
25+ * rad_ID, brg_ID
26+ * prof_ID, bprof_ID where in the air the object radiates / its bridge
27+ * patch drives (scene-built, air-sized)
28+ * The air keeps dulcimer's names: p, pm, c, sig, wall, lapw.
29+ */
30+import type { AirGrid, ObjectGrid } from '../grids.ts';
31+import {
32+ makeBridge, makeDxx, makeDxxxx, makeLap2, makeLapw, makeRadLine, makeRadRect,
33+ type OpInstance,
34+} from '../mgpu/ops.ts';
35+import type { StateField } from '../mgpu/model.ts';
36+import { BODY_TEMPLATES } from './bodies.ts';
37+import { templateOf } from './objects.ts';
38+import {
39+ suffixed, type ObjectSpec, type ParamSpec, type Params, type SceneSpec,
40+} from './types.ts';
41+
42+/** Everything the compiler pipeline needs, derived from one SceneSpec. */
43+export interface GeneratedScene {
44+ modelSource: string;
45+ sceneSource: string;
46+ ops: OpInstance[];
47+ /** Exact scalars offered to the model .m. */
48+ modelConsts: Record<string, number>;
49+ /** Host-uploaded fields offered to the model .m, with initial contents.
50+ * The scene-built ones (c, sig, wall, prof_*) are placeholders here —
51+ * the session evaluates the scene and uploads the real thing. */
52+ modelFields: Record<string, Float32Array>;
53+ /** The scene-built subset of modelFields, in output order for medium(). */
54+ sceneOutputs: string[];
55+ /** Exact scalars offered to the scene .m. */
56+ sceneConsts: Record<string, number>;
57+ state: StateField[];
58+ /** Model parameter names (suffixed), and their slider specs by object. */
59+ modelParamNames: string[];
60+ /** Scene parameter names (placement + body), and their specs. */
61+ sceneParamNames: string[];
62+ /** Each object's grid, by id. */
63+ grids: Record<string, ObjectGrid>;
64+}
65+
66+/** A parameter spec with its key suffixed for one object. */
67+export const suffixedSpecs = (specs: ParamSpec[], id: string): ParamSpec[] =>
68+ specs.map((s) => ({ ...s, key: suffixed(s.key, id) }));
69+
70+/** The parameter values one object contributes, suffixed, defaults filled. */
71+export function suffixedParams(specs: ParamSpec[], values: Params, id: string): Params {
72+ return Object.fromEntries(
73+ specs.map((s) => [suffixed(s.key, id), values[s.key] ?? s.value]),
74+ );
75+}
76+
77+const line = (s: string): string => ` ${s}`;
78+
79+/** The generated model .m: init and step for every object plus the air. */
80+function generateModel(spec: SceneSpec, grids: Record<string, ObjectGrid>): string {
81+ const objs = spec.objects;
82+ const stateOut = objs.flatMap((o) => [`u_${o.id}`, `um_${o.id}`]).concat(['p', 'pm']);
83+ const stepOut = objs.flatMap((o) => [`un_${o.id}`, `uo_${o.id}`]).concat(['pn', 'po']);
84+
85+ // ---- init ---------------------------------------------------------------
86+ const initArgs: string[] = [];
87+ const initBody: string[] = [];
88+ for (const o of objs) {
89+ const id = o.id;
90+ const t = templateOf(o);
91+ initBody.push(`% -- ${t.label.toLowerCase()} ${id}: ${t.verb.toLowerCase()} as an initial condition`);
92+ if (o.type === 'string') {
93+ initArgs.push(`xs_${id}`, `L_${id}`, `pluckpos_${id}`, `amp_${id}`);
94+ initBody.push(
95+ `a_${id} = xs_${id} / (pluckpos_${id} * L_${id});`,
96+ `b_${id} = (L_${id} - xs_${id}) / (L_${id} - pluckpos_${id} * L_${id});`,
97+ `u_${id} = amp_${id} * 0.5 * (a_${id} + b_${id} - abs(a_${id} - b_${id}));`,
98+ `um_${id} = u_${id};`,
99+ );
100+ } else if (o.type === 'bar') {
101+ initArgs.push(`xs_${id}`, `pin_${id}`, `n_${id}`, `L_${id}`, `strikepos_${id}`, `striker_${id}`, `vel_${id}`, 'dt');
102+ initBody.push(
103+ `r_${id} = (xs_${id} - strikepos_${id} * L_${id}) / striker_${id};`,
104+ `u_${id} = zeros(n_${id}, 1);`,
105+ `um_${id} = -(dt * vel_${id}) * (pin_${id} .* exp(-(r_${id} .* r_${id})));`,
106+ );
107+ } else {
108+ initArgs.push(
109+ `xm_${id}`, `ym_${id}`, `pin_${id}`, `n_${id}`, `L1_${id}`, `L2_${id}`,
110+ `strikex_${id}`, `strikey_${id}`, `striker_${id}`, `vel_${id}`, 'dt',
111+ );
112+ initBody.push(
113+ `rr_${id} = ((xm_${id} - strikex_${id} * L1_${id}).^2 + (ym_${id} - strikey_${id} * L2_${id}).^2) / (striker_${id} * striker_${id});`,
114+ `u_${id} = zeros(n_${id}, 1);`,
115+ `um_${id} = -(dt * vel_${id}) * (pin_${id} .* exp(-rr_${id}));`,
116+ );
117+ }
118+ initBody.push('');
119+ }
120+ initBody.push('% -- the air starts silent', 'p = zeros(npts, 1);', 'pm = zeros(npts, 1);');
121+ initArgs.push('npts');
122+ const initSig = `function [${stateOut.join(', ')}] = init(${[...new Set(initArgs)].join(', ')})`;
123+
124+ // ---- step ---------------------------------------------------------------
125+ const stepArgs: string[] = [];
126+ const stepBody: string[] = [];
127+ for (const o of objs) {
128+ const id = o.id;
129+ const t = templateOf(o);
130+ stepArgs.push(`u_${id}`, `um_${id}`, `pin_${id}`);
131+ stepArgs.push(...(o.type === 'membrane' ? [`L1_${id}`, `L2_${id}`] : [`L_${id}`]));
132+ stepBody.push(`% -- ${t.label.toLowerCase()} ${id} --------------------------------------------`);
133+ if (o.type === 'string') {
134+ stepArgs.push(`f0_${id}`, `B_${id}`, `t60_${id}`, `sig1_${id}`);
135+ stepBody.push(
136+ `cs_${id} = 2 * L_${id} * f0_${id}; % tension, as a wave speed`,
137+ `kap2_${id} = B_${id} * (cs_${id} * L_${id} / pi)^2; % stiffness, as inharmonicity`,
138+ `d_${id} = (6.91 / t60_${id}) * dt;`,
139+ `lu_${id} = dxx_${id}(u_${id});`,
140+ `lum_${id} = dxx_${id}(um_${id});`,
141+ `l4_${id} = dxxxx_${id}(u_${id});`,
142+ `un_${id} = pin_${id} .* ((2*u_${id} - (1 - d_${id})*um_${id} + (dt*dt)*((cs_${id}*cs_${id})*lu_${id} - kap2_${id}*l4_${id}) + (2*sig1_${id}*dt)*(lu_${id} - lum_${id})) ./ (1 + d_${id}));`,
143+ );
144+ } else if (o.type === 'bar') {
145+ stepArgs.push(`f1_${id}`, `t60_${id}`, `sig1_${id}`);
146+ stepBody.push(
147+ `kap_${id} = 2 * f1_${id} * (L_${id} * L_${id}) / pi; % stiffness, from the first partial`,
148+ `d_${id} = (6.91 / t60_${id}) * dt;`,
149+ `lu_${id} = dxx_${id}(u_${id});`,
150+ `lum_${id} = dxx_${id}(um_${id});`,
151+ `l4_${id} = dxxxx_${id}(u_${id});`,
152+ `un_${id} = pin_${id} .* ((2*u_${id} - (1 - d_${id})*um_${id} - (dt*dt)*(kap_${id}*kap_${id})*l4_${id} + (2*sig1_${id}*dt)*(lu_${id} - lum_${id})) ./ (1 + d_${id}));`,
153+ );
154+ } else {
155+ stepArgs.push(`f0_${id}`, `t60_${id}`, `sig1_${id}`);
156+ stepBody.push(
157+ `cm_${id} = 2 * f0_${id} / sqrt(1/(L1_${id}*L1_${id}) + 1/(L2_${id}*L2_${id})); % tension, from the (1,1) mode`,
158+ `d_${id} = (6.91 / t60_${id}) * dt;`,
159+ `lu_${id} = lap_${id}(u_${id});`,
160+ `lum_${id} = lap_${id}(um_${id});`,
161+ `un_${id} = pin_${id} .* ((2*u_${id} - (1 - d_${id})*um_${id} + (dt*dt)*(cm_${id}*cm_${id})*lu_${id} + (2*sig1_${id}*dt)*(lu_${id} - lum_${id})) ./ (1 + d_${id}));`,
162+ );
163+ }
164+ stepBody.push('');
165+ }
166+
167+ stepBody.push(
168+ '% -- the coupling: each object\'s motion, spread where its profile says --',
169+ );
170+ let drv = '';
171+ let drvIdx = 0;
172+ for (const o of objs) {
173+ const id = o.id;
174+ stepArgs.push(`gair_${id}`);
175+ stepBody.push(
176+ `acc_${id} = (un_${id} - 2*u_${id} + um_${id}) / (dt*dt);`,
177+ `q_${id} = rad_${id}(acc_${id});`,
178+ );
179+ let term = `gair_${id} * (prof_${id} .* q_${id})`;
180+ if (o.type === 'string') {
181+ stepArgs.push(`gbridge_${id}`);
182+ stepBody.push(`fb_${id} = brg_${id}(un_${id});`);
183+ term += ` - (gbridge_${id} * (cs_${id}*cs_${id}) / L_${id}) * (bprof_${id} .* fb_${id})`;
184+ }
185+ const next = `drv${++drvIdx}`;
186+ stepBody.push(`${next} = ${drv ? `${drv} + ` : ''}${term};`);
187+ drv = next;
188+ }
189+ stepBody.push(
190+ '',
191+ '% -- the air ------------------------------------------------------------',
192+ 'lp = lapw(p, wall);',
193+ 'sd = sig * dt;',
194+ `pn = wall .* ((2*p - (1 - sd) .* pm + (c*dt).^2 .* lp + (dt*dt) * ${drv}) ./ (1 + sd));`,
195+ '',
196+ "% This step's fields become the next step's history. Lines of their own,",
197+ '% so each plans as the copy it is.',
198+ ...objs.map((o) => `uo_${o.id} = u_${o.id};`),
199+ 'po = p;',
200+ );
201+ stepArgs.push('p', 'pm', 'c', 'sig', 'wall');
202+ for (const o of objs) {
203+ stepArgs.push(`prof_${o.id}`);
204+ if (o.type === 'string') stepArgs.push(`bprof_${o.id}`);
205+ }
206+ stepArgs.push('dt');
207+ const stepSig = `function [${stepOut.join(', ')}] = step(${[...new Set(stepArgs)].join(', ')})`;
208+
209+ const objectSummary = objs
210+ .map((o) => {
211+ const g = grids[o.id];
212+ const t = templateOf(o);
213+ return `% ${o.id}: ${t.label.toLowerCase()}, ${
214+ g.kind === 'line' ? `${g.n1} nodes over ${g.L1} m` : `${g.n1} x ${g.n2} nodes over ${g.L1} x ${g.L2} m`
215+ }`;
216+ })
217+ .join('\n');
218+
219+ return `% Generated by soundbox from the scene you built — and editable: this file
220+% is the model, compiled to WebGPU kernels, and Run edits recompiles whatever
221+% you make of it. (Rebuilding the scene from the Build tab regenerates it.)
222+%
223+% Objects in this scene:
224+${objectSummary}
225+%
226+% Every object is advanced by the same explicit leapfrog family: displacement
227+% u and its history um, one update per timestep, pinned at the boundary by
228+% its pin mask. The air carries pressure p on the box grid,
229+%
230+% p_tt + 2 sig p_t = c^2 lapw(p, wall) + s
231+%
232+% where lapw is the wall-masked Laplacian (the rigid body, at no timestep
233+% cost) and s collects every object's contribution: its acceleration, spread
234+% along the scene-built profile of where it sits (and, for a string, its
235+% bridge force driving a patch above the plate). The air does not push back;
236+% at these amplitudes that back-reaction is far below what the models already
237+% neglect.
238+
239+${initSig}
240+${initBody.map(line).join('\n')}
241+end
242+
243+${stepSig}
244+${stepBody.map(line).join('\n')}
245+end
246+`;
247+}
248+
249+/** The generated scene .m: bodies, absorption, coupling profiles. */
250+function generateScene(spec: SceneSpec): { source: string; outputs: string[]; paramNames: string[] } {
251+ const body = BODY_TEMPLATES[spec.body.type];
252+ const outputs = ['c', 'sig', 'wall'];
253+ const args = ['x', 'y', 'z', 'h', 'npts', 'Lx', 'Ly', 'Lz', 'c0'];
254+ const paramNames: string[] = [];
255+
256+ for (const b of body.paramSpecs) {
257+ args.push(b.key);
258+ paramNames.push(b.key);
259+ }
260+
261+ const lines: string[] = [...body.matlab, 'wall = 1 - shell;', ''];
262+ lines.push(
263+ '% Uniform air. The walls being a mask is what keeps this uniform — and',
264+ '% the timestep as large as air allows.',
265+ 'c = c0;',
266+ '',
267+ "% Open boundary at the domain edge" +
268+ (body.paramSpecs.some((p) => p.key === 'absorb')
269+ ? ", plus the body's surface absorption."
270+ : '.'),
271+ body.paramSpecs.some((p) => p.key === 'absorb')
272+ ? 'sig = sponge3(x, y, z, Lx, Ly, Lz, 0.15*Ly, 9000) + absorb * (4 * shell .* (1 - shell));'
273+ : 'sig = sponge3(x, y, z, Lx, Ly, Lz, 0.15*Ly, 9000);',
274+ '',
275+ );
276+
277+ for (const o of spec.objects) {
278+ const id = o.id;
279+ const t = templateOf(o);
280+ // The object's baked size(s) come in as exact constants; its placement
281+ // as scene parameters.
282+ args.push(...(o.type === 'membrane' ? [`L1_${id}`, `L2_${id}`] : [`L_${id}`]));
283+ for (const p of t.placeSpecs) {
284+ const k = suffixed(p.key, id);
285+ args.push(k);
286+ paramNames.push(k);
287+ }
288+ if (o.type === 'membrane') {
289+ outputs.push(`prof_${id}`);
290+ lines.push(
291+ `% ${t.label.toLowerCase()} ${id} radiates from its plane: a thin sheet at z = oz_${id}`,
292+ '% over its extent, normalized so its cross-section integrates to one.',
293+ `aw_${id} = 2*h;`,
294+ `sk_${id} = exp(-((z - oz_${id}).^2) / (aw_${id}*aw_${id})) / (sqrt(pi) * aw_${id});`,
295+ `exx_${id} = 0.5 * (1 + tanh((L1_${id}/2 - abs(x)) / (1.5*h)));`,
296+ `exy_${id} = 0.5 * (1 + tanh((L2_${id}/2 - abs(y)) / (1.5*h)));`,
297+ `prof_${id} = sk_${id} .* exx_${id} .* exy_${id} .* wall;`,
298+ '',
299+ );
300+ } else {
301+ outputs.push(`prof_${id}`);
302+ lines.push(
303+ `% ${t.label.toLowerCase()} ${id} radiates from its line: a tube two cells wide`,
304+ `% around (y = oy_${id}, z = oz_${id}), fading out at its ends, normalized so`,
305+ '% its cross-section integrates to one.',
306+ `aw_${id} = 2*h;`,
307+ `tube_${id} = exp(-((y - oy_${id}).^2 + (z - oz_${id}).^2) / (aw_${id}*aw_${id})) / (pi * aw_${id} * aw_${id});`,
308+ `span_${id} = 0.5 * (1 + tanh((L_${id}/2 - abs(x)) / (1.5*h)));`,
309+ `prof_${id} = tube_${id} .* span_${id} .* wall;`,
310+ );
311+ if (o.type === 'string') {
312+ outputs.push(`bprof_${id}`);
313+ lines.push(
314+ `% ...and its bridge force drives a patch of air just above the plate,`,
315+ `% around its x = +L/2 end. This stands in for a top plate moving; the`,
316+ '% plate\'s own modes are not modelled.',
317+ `pw_${id} = 1.5*h;`,
318+ `pr_${id} = exp(-((x - L_${id}/2).^2 + (y - oy_${id}).^2) / (0.04*0.04)) / (pi * 0.04 * 0.04);`,
319+ `skin_${id} = exp(-(z - pw_${id}).^2 / (pw_${id}*pw_${id})) / (sqrt(pi) * pw_${id});`,
320+ `bprof_${id} = pr_${id} .* skin_${id} .* wall;`,
321+ );
322+ }
323+ lines.push('');
324+ }
325+ }
326+
327+ const source = `% Generated by soundbox — the scene: ${body.label.toLowerCase()}, and where each
328+% object couples into the air. Evaluated once on the CPU (numbl's
329+% interpreter, full MATLAB subset available), so editing it costs no
330+% recompile; the model .m is the compiled half.
331+%
332+% The body is a *mask*: \`wall\` is 0 inside solid and 1 in air, and the
333+% solver's masked Laplacian drops any flux into a masked cell — the rigid
334+% (Neumann) condition, at no timestep cost. What a rigid mask cannot do is
335+% absorb, so \`absorb\` paints a thin lossy skin over the shell's surfaces
336+% (4*shell*(1-shell) peaks exactly at the surface).
337+function [${outputs.join(', ')}] = medium(${args.join(', ')})
338+${lines.map(line).join('\n')}
339+end
340+`;
341+ return { source, outputs, paramNames };
342+}
343+
344+/** Derive everything the pipeline needs from a scene description. */
345+export function generate(
346+ spec: SceneSpec,
347+ air: AirGrid,
348+ dt: number,
349+): GeneratedScene {
350+ if (spec.objects.length === 0) {
351+ throw new Error('a scene needs at least one object (add a string, bar or membrane)');
352+ }
353+ const seen = new Set<string>();
354+ for (const o of spec.objects) {
355+ if (seen.has(o.id)) throw new Error(`duplicate object id '${o.id}'`);
356+ seen.add(o.id);
357+ }
358+
359+ const grids: Record<string, ObjectGrid> = {};
360+ for (const o of spec.objects) grids[o.id] = templateOf(o).makeGrid(o.size, dt);
361+
362+ // Ops, consts, fields, state.
363+ const ops: OpInstance[] = [makeLapw('lapw', air)];
364+ const modelConsts: Record<string, number> = {
365+ npts: air.npts, nx: air.nx, ny: air.ny, nz: air.nz,
366+ };
367+ const sceneConsts: Record<string, number> = {};
368+ const modelFields: Record<string, Float32Array> = {
369+ x: air.x, y: air.y, z: air.z,
370+ };
371+ const state: StateField[] = [];
372+ const modelParamNames: string[] = [];
373+
374+ for (const o of spec.objects) {
375+ const id = o.id;
376+ const g = grids[id];
377+ modelConsts[`n_${id}`] = g.npts;
378+ if (g.kind === 'line') {
379+ modelConsts[`L_${id}`] = g.L1;
380+ sceneConsts[`L_${id}`] = g.L1;
381+ modelFields[`xs_${id}`] = g.xs;
382+ ops.push(makeDxx(`dxx_${id}`, g), makeDxxxx(`dxxxx_${id}`, g), makeRadLine(`rad_${id}`, g, air));
383+ if (o.type === 'string') ops.push(makeBridge(`brg_${id}`, g, air));
384+ } else {
385+ modelConsts[`L1_${id}`] = g.L1;
386+ modelConsts[`L2_${id}`] = g.L2;
387+ sceneConsts[`L1_${id}`] = g.L1;
388+ sceneConsts[`L2_${id}`] = g.L2;
389+ modelFields[`xm_${id}`] = g.xs;
390+ modelFields[`ym_${id}`] = g.ys;
391+ ops.push(makeLap2(`lap_${id}`, g), makeRadRect(`rad_${id}`, g, air));
392+ }
393+ modelFields[`pin_${id}`] = g.pin;
394+ state.push({ name: `u_${id}`, count: g.npts }, { name: `um_${id}`, count: g.npts });
395+ for (const p of templateOf(o).paramSpecs) modelParamNames.push(suffixed(p.key, id));
396+ }
397+ state.push({ name: 'p', count: air.npts }, { name: 'pm', count: air.npts });
398+
399+ const scene = generateScene(spec);
400+ // Scene-built fields are uploaded after evaluation; placeholders size them.
401+ for (const name of scene.outputs) modelFields[name] = new Float32Array(air.npts);
402+
403+ return {
404+ modelSource: generateModel(spec, grids),
405+ sceneSource: scene.source,
406+ ops,
407+ modelConsts,
408+ modelFields,
409+ sceneOutputs: scene.outputs,
410+ sceneConsts,
411+ state,
412+ modelParamNames,
413+ sceneParamNames: scene.paramNames,
414+ grids,
415+ };
416+}
src/studio/objects.tsadded+259−0View file
@@ -0,0 +1,259 @@
1+/**
2+ * The three kinds of vibrating object, as data: their parameters and slider
3+ * ranges, how their grids are sized, and where their partials should land.
4+ *
5+ * The physics each type contributes to the generated model .m lives in
6+ * src/studio/codegen.ts; this file is the registry around it. The split is
7+ * the same one dulcimer draws between its .m files and its registries: what
8+ * an object *is* (its scheme) is MATLAB the Lab view shows, and everything
9+ * around it (defaults, ranges, hints, sizing) is host data.
10+ *
11+ * Grid sizing uses each type's stability condition at the sliders' worst
12+ * case, so moving a parameter never forces a recompile — see src/grids.ts.
13+ *
14+ * - A **string** is the stiff damped string of the dulcimer project:
15+ * u_tt = cs^2 u_xx - kap^2 u_xxxx - 2 sig0 u_t + 2 sig1 (u_xx)_t, pinned
16+ * ends, plucked by a triangular initial displacement. It couples to the
17+ * air two ways: radiating along its own line, and pulling on a bridge
18+ * patch at its x = +L/2 end.
19+ * - A **bar** is the cs = 0 case with the stiffness doing all the work — a
20+ * glockenspiel bar, loosely — struck by a mallet-shaped initial velocity.
21+ * Pinned ends (a real bar is free-free; see the README for what that
22+ * changes). Its partials go as n^2 f1, which is the bell-like sound.
23+ * - A **membrane** is the 2D wave equation with tension and damping, pinned
24+ * at its rim, struck like the bar. Its partials are the inharmonic
25+ * f0 * sqrt((m/L1)^2 + (n/L2)^2) / sqrt(1/L1^2 + 1/L2^2) set, which is
26+ * why a drum goes "thud" rather than sounding a chord.
27+ */
28+import { makeLineGrid, makeRectGrid, type ObjectGrid } from '../grids.ts';
29+import type { ObjectSpec, ObjectType, ParamSpec } from './types.ts';
30+
31+export interface ObjectTemplate {
32+ type: ObjectType;
33+ label: string;
34+ /** What exciting this object is called ('Pluck', 'Strike'). */
35+ verb: string;
36+ blurb: string;
37+ /** Baked extents and their ranges; editing one rebuilds. */
38+ sizeSpecs: ParamSpec[];
39+ /** Model parameters (compiled-side, free to move). */
40+ paramSpecs: ParamSpec[];
41+ /** Placement parameters (scene-side, cheap to move). */
42+ placeSpecs: ParamSpec[];
43+ /** Build this object's grid at timestep dt, stable for the sliders' worst
44+ * case. */
45+ makeGrid(size: { L1: number; L2?: number }, dt: number): ObjectGrid;
46+ /** Where this object's partials should land, for the spectrum overlay. */
47+ partials(spec: ObjectSpec, count: number): number[];
48+}
49+
50+const max = (specs: ParamSpec[], key: string): number =>
51+ specs.find((p) => p.key === key)?.max ?? 0;
52+
53+const stringTemplate: ObjectTemplate = {
54+ type: 'string',
55+ label: 'String',
56+ verb: 'Pluck',
57+ blurb: 'A stiff, damped string, plucked; radiates along its line and drives a bridge patch at its far end.',
58+ sizeSpecs: [
59+ {
60+ key: 'L1', label: 'length (m)', value: 0.6, min: 0.3, max: 0.9, step: 0.05,
61+ hint: 'The speaking length. Changing it recompiles (the grid is sized from it).',
62+ },
63+ ],
64+ paramSpecs: [
65+ {
66+ key: 'f0', label: 'pitch (Hz)', value: 294, min: 80, max: 880, step: 1, musical: true,
67+ hint: 'The fundamental the string is tuned to. The wave speed follows: cs = 2·L·f0.',
68+ },
69+ {
70+ key: 'B', label: 'inharmonicity', value: 0.0001, min: 0, max: 0.002, step: 0.00002,
71+ hint: 'Bending stiffness, as the inharmonicity coefficient B: partial n sounds near n·f0·sqrt(1 + B·n²). Zero is an ideal string; ~1e-4 a light steel string.',
72+ },
73+ {
74+ key: 't60', label: 'decay t60 (s)', value: 4, min: 0.2, max: 8, step: 0.1, musical: true,
75+ hint: 'Seconds for the string to decay 60 dB.',
76+ },
77+ {
78+ key: 'sig1', label: 'brightness decay', value: 0.005, min: 0, max: 0.05, step: 0.001, musical: true,
79+ hint: 'Frequency-dependent damping (m²/s). High partials die faster than low, so the note starts bright and mellows; zero keeps it buzzing.',
80+ },
81+ {
82+ key: 'pluckpos', label: 'pluck position', value: 0.22, min: 0.08, max: 0.92, step: 0.01, musical: true,
83+ hint: 'Where along the string it is plucked, as a fraction of length. Takes effect on the next excite.',
84+ },
85+ {
86+ key: 'amp', label: 'pluck height (m)', value: 0.002, min: 0.0002, max: 0.005, step: 0.0002,
87+ hint: 'How far the string is pulled before release. Linear equations: this scales everything and changes nothing else.',
88+ },
89+ {
90+ key: 'gair', label: 'line radiation', value: 0.3, min: 0, max: 2, step: 0.05,
91+ hint: 'Gain on the string radiating directly along its length (idealized; a thin string barely does this in reality). 0 switches the route off.',
92+ },
93+ {
94+ key: 'gbridge', label: 'bridge drive', value: 1, min: 0, max: 2, step: 0.05,
95+ hint: 'Gain on the string’s pull at its far end driving a patch of air above the plate — the real instrument’s main route. 0 switches it off.',
96+ },
97+ ],
98+ placeSpecs: [
99+ { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005,
100+ hint: 'Sideways offset of the string’s line.' },
101+ { key: 'oz', label: 'height (m)', value: 0.02, min: 0.005, max: 0.15, step: 0.005,
102+ hint: 'Height of the string above z = 0 (the top of a body, if there is one).' },
103+ ],
104+ makeGrid(size, dt) {
105+ const cs = 2 * size.L1 * max(this.paramSpecs, 'f0');
106+ const kap2 = (max(this.paramSpecs, 'B') * cs * cs * size.L1 * size.L1) / (Math.PI * Math.PI);
107+ return makeLineGrid(size.L1, dt, { cs, kap2, sig1: max(this.paramSpecs, 'sig1') });
108+ },
109+ partials(spec, count) {
110+ const { f0 = 0, B = 0 } = spec.params;
111+ return Array.from({ length: count }, (_, i) => {
112+ const n = i + 1;
113+ return n * f0 * Math.sqrt(1 + B * n * n);
114+ });
115+ },
116+};
117+
118+const barTemplate: ObjectTemplate = {
119+ type: 'bar',
120+ label: 'Bar',
121+ verb: 'Strike',
122+ blurb: 'A stiff bar with no tension — a chime; struck with a mallet-shaped velocity, partials at n²·f1.',
123+ sizeSpecs: [
124+ {
125+ key: 'L1', label: 'length (m)', value: 0.3, min: 0.15, max: 0.5, step: 0.05,
126+ hint: 'Changing it recompiles (the grid is sized from it).',
127+ },
128+ ],
129+ paramSpecs: [
130+ {
131+ key: 'f1', label: 'pitch (Hz)', value: 780, min: 200, max: 2000, step: 5, musical: true,
132+ hint: 'The first partial. The stiffness follows: kap = 2·f1·L²/π; higher partials land at n²·f1 (4·f1, 9·f1, ...), which is the chime sound.',
133+ },
134+ {
135+ key: 't60', label: 'decay t60 (s)', value: 6, min: 0.5, max: 12, step: 0.1, musical: true,
136+ hint: 'Seconds to decay 60 dB. Metal rings long.',
137+ },
138+ {
139+ key: 'sig1', label: 'brightness decay', value: 0.001, min: 0, max: 0.02, step: 0.0005, musical: true,
140+ hint: 'Frequency-dependent damping (m²/s): the strike’s clang fades to a pure ring.',
141+ },
142+ {
143+ key: 'strikepos', label: 'strike position', value: 0.3, min: 0.1, max: 0.9, step: 0.01, musical: true,
144+ hint: 'Where the mallet lands, as a fraction of length. Takes effect on the next excite.',
145+ },
146+ {
147+ key: 'striker', label: 'mallet width (m)', value: 0.025, min: 0.01, max: 0.08, step: 0.005,
148+ hint: 'Width of the struck patch. A narrow, hard mallet excites more of the high partials.',
149+ },
150+ {
151+ key: 'vel', label: 'strike speed (m/s)', value: 1, min: 0.1, max: 5, step: 0.1,
152+ hint: 'Initial velocity the mallet imparts. Linear equations: sets scale only.',
153+ },
154+ {
155+ key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05,
156+ hint: 'Gain on the bar radiating along its line. 0 mutes it.',
157+ },
158+ ],
159+ placeSpecs: [
160+ { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005 },
161+ { key: 'oz', label: 'height (m)', value: 0.05, min: 0.005, max: 0.15, step: 0.005 },
162+ ],
163+ makeGrid(size, dt) {
164+ const kap = (2 * max(this.paramSpecs, 'f1') * size.L1 * size.L1) / Math.PI;
165+ return makeLineGrid(size.L1, dt, {
166+ cs: 0, kap2: kap * kap, sig1: max(this.paramSpecs, 'sig1'),
167+ });
168+ },
169+ partials(spec, count) {
170+ const { f1 = 0 } = spec.params;
171+ return Array.from({ length: count }, (_, i) => (i + 1) * (i + 1) * f1);
172+ },
173+};
174+
175+const membraneTemplate: ObjectTemplate = {
176+ type: 'membrane',
177+ label: 'Membrane',
178+ verb: 'Strike',
179+ blurb: 'A 2D membrane pinned at its rim — a drum head; struck, with the inharmonic mode set that makes a drum thud rather than chord.',
180+ sizeSpecs: [
181+ {
182+ key: 'L1', label: 'width (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01,
183+ hint: 'Extent along x. Changing it recompiles.',
184+ },
185+ {
186+ key: 'L2', label: 'depth (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01,
187+ hint: 'Extent along y. Changing it recompiles.',
188+ },
189+ ],
190+ paramSpecs: [
191+ {
192+ key: 'f0', label: 'pitch (Hz)', value: 110, min: 60, max: 300, step: 1, musical: true,
193+ hint: 'The (1,1) fundamental the head is tuned to; the tension’s wave speed follows from it and the size.',
194+ },
195+ {
196+ key: 't60', label: 'decay t60 (s)', value: 1.5, min: 0.2, max: 6, step: 0.1, musical: true,
197+ hint: 'Seconds to decay 60 dB. Drums are short.',
198+ },
199+ {
200+ key: 'sig1', label: 'brightness decay', value: 0.0005, min: 0, max: 0.01, step: 0.0002, musical: true,
201+ hint: 'Frequency-dependent damping (m²/s): the attack’s slap fades first.',
202+ },
203+ {
204+ key: 'strikex', label: 'strike x', value: 0.35, min: 0.1, max: 0.9, step: 0.01, musical: true,
205+ hint: 'Where the stick lands along x, as a fraction. Off-centre excites more modes. Takes effect on the next excite.',
206+ },
207+ {
208+ key: 'strikey', label: 'strike y', value: 0.5, min: 0.1, max: 0.9, step: 0.01,
209+ hint: 'Where the stick lands along y, as a fraction.',
210+ },
211+ {
212+ key: 'striker', label: 'stick width (m)', value: 0.04, min: 0.01, max: 0.1, step: 0.005,
213+ hint: 'Radius of the struck patch.',
214+ },
215+ {
216+ key: 'vel', label: 'strike speed (m/s)', value: 1.5, min: 0.1, max: 5, step: 0.1,
217+ hint: 'Initial velocity the stick imparts. Sets scale only.',
218+ },
219+ {
220+ key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05,
221+ hint: 'Gain on the head radiating from its plane. 0 mutes it.',
222+ },
223+ ],
224+ placeSpecs: [
225+ { key: 'oz', label: 'height (m)', value: 0.02, min: 0, max: 0.15, step: 0.005,
226+ hint: 'Height of the head’s plane above z = 0 — just above a drum shell’s rim.' },
227+ ],
228+ makeGrid(size, dt) {
229+ const L2 = size.L2 ?? size.L1;
230+ const c =
231+ (2 * max(this.paramSpecs, 'f0')) /
232+ Math.sqrt(1 / (size.L1 * size.L1) + 1 / (L2 * L2));
233+ return makeRectGrid(size.L1, L2, dt, { c, sig1: max(this.paramSpecs, 'sig1') });
234+ },
235+ partials(spec, count) {
236+ const { f0 = 0 } = spec.params;
237+ const L1 = spec.size.L1;
238+ const L2 = spec.size.L2 ?? L1;
239+ const base = Math.sqrt(1 / (L1 * L1) + 1 / (L2 * L2));
240+ const out: number[] = [];
241+ for (let m = 1; m <= 4; m++) {
242+ for (let n = 1; n <= 4; n++) {
243+ out.push((f0 * Math.sqrt((m / L1) ** 2 + (n / L2) ** 2)) / base);
244+ }
245+ }
246+ return [...new Set(out.map((f) => Math.round(f * 100) / 100))]
247+ .sort((a, b) => a - b)
248+ .slice(0, count);
249+ },
250+};
251+
252+export const OBJECT_TEMPLATES: Record<ObjectType, ObjectTemplate> = {
253+ string: stringTemplate,
254+ bar: barTemplate,
255+ membrane: membraneTemplate,
256+};
257+
258+export const templateOf = (spec: ObjectSpec): ObjectTemplate =>
259+ OBJECT_TEMPLATES[spec.type];
src/studio/presets.tsadded+132−0View file
@@ -0,0 +1,132 @@
1+/**
2+ * The preset scenes the Play layer offers — each one an ordinary SceneSpec,
3+ * nothing a user could not build by hand in the Build tab. A preset is a
4+ * starting point, not a mode: picking one loads its spec into the builder,
5+ * and everything stays editable from there.
6+ */
7+import type { SceneSpec } from './types.ts';
8+
9+export interface Preset {
10+ key: string;
11+ label: string;
12+ blurb: string;
13+ /** What the excite button says for this scene. */
14+ verb: string;
15+ spec: SceneSpec;
16+ /** Where the microphone starts, metres. */
17+ mic: { x: number; y: number; z: number };
18+}
19+
20+export const PRESETS: Preset[] = [
21+ {
22+ key: 'dulcimer',
23+ label: 'Dulcimer',
24+ blurb: 'One plucked string over a rigid box with a sound hole — the scene the dulcimer project hardcodes, here just a preset.',
25+ verb: 'Pluck',
26+ spec: {
27+ objects: [
28+ {
29+ id: 's1', type: 'string', size: { L1: 0.6 },
30+ params: {}, place: {},
31+ },
32+ ],
33+ body: { type: 'box', params: {} },
34+ },
35+ mic: { x: 0.12, y: 0.08, z: 0.1 },
36+ },
37+ {
38+ key: 'drum',
39+ label: 'Drum',
40+ blurb: 'A struck membrane over a cylindrical shell. The head’s inharmonic modes plus the cavity under it are what make it a drum.',
41+ verb: 'Strike',
42+ spec: {
43+ objects: [
44+ {
45+ id: 'm1', type: 'membrane', size: { L1: 0.32, L2: 0.32 },
46+ params: {}, place: {},
47+ },
48+ ],
49+ body: { type: 'shell', params: {} },
50+ },
51+ mic: { x: 0.15, y: 0.1, z: 0.12 },
52+ },
53+ {
54+ key: 'chime',
55+ label: 'Chime',
56+ blurb: 'A stiff bar in open air, struck. No tension, so the stiffness sets the partials — at n²·f1, which is the bell-like sound.',
57+ verb: 'Strike',
58+ spec: {
59+ objects: [
60+ {
61+ id: 'b1', type: 'bar', size: { L1: 0.3 },
62+ params: {}, place: { oz: 0.05 },
63+ },
64+ ],
65+ body: { type: 'none', params: {} },
66+ },
67+ mic: { x: 0.12, y: 0.08, z: 0.1 },
68+ },
69+ {
70+ key: 'bare',
71+ label: 'Bare string',
72+ blurb: 'The control experiment: the same plucked string with no body at all. Compare it to the dulcimer to hear what the box adds.',
73+ verb: 'Pluck',
74+ spec: {
75+ objects: [
76+ {
77+ id: 's1', type: 'string', size: { L1: 0.6 },
78+ params: { gair: 1, gbridge: 0 }, place: {},
79+ },
80+ ],
81+ body: { type: 'none', params: {} },
82+ },
83+ mic: { x: 0.12, y: 0.08, z: 0.1 },
84+ },
85+ {
86+ key: 'duet',
87+ label: 'Duet',
88+ blurb: 'Two strings a fifth apart over one box, plucked together — the smallest ensemble, and the point of composing scenes.',
89+ verb: 'Pluck',
90+ spec: {
91+ objects: [
92+ {
93+ id: 's1', type: 'string', size: { L1: 0.6 },
94+ params: { f0: 294 }, place: { oy: -0.03 },
95+ },
96+ {
97+ id: 's2', type: 'string', size: { L1: 0.6 },
98+ params: { f0: 441, pluckpos: 0.3 }, place: { oy: 0.03 },
99+ },
100+ ],
101+ body: { type: 'box', params: { boxw: 0.22 } },
102+ },
103+ mic: { x: 0.12, y: 0.08, z: 0.1 },
104+ },
105+ {
106+ key: 'kit',
107+ label: 'Drum and chime',
108+ blurb: 'A membrane over its shell and a bar beside it, struck together — two kinds of physics in one air.',
109+ verb: 'Strike',
110+ spec: {
111+ objects: [
112+ {
113+ id: 'm1', type: 'membrane', size: { L1: 0.3, L2: 0.3 },
114+ params: { f0: 100 }, place: {},
115+ },
116+ {
117+ id: 'b1', type: 'bar', size: { L1: 0.25 },
118+ params: { f1: 880 }, place: { oy: -0.12, oz: 0.06 },
119+ },
120+ ],
121+ body: { type: 'shell', params: { shellr: 0.16 } },
122+ },
123+ mic: { x: 0.18, y: 0.1, z: 0.12 },
124+ },
125+];
126+
127+export const presetByKey = (key: string): Preset | undefined =>
128+ PRESETS.find((p) => p.key === key);
129+
130+/** Deep-copy a preset's spec so edits do not bleed back into the preset. */
131+export const cloneSpec = (spec: SceneSpec): SceneSpec =>
132+ JSON.parse(JSON.stringify(spec)) as SceneSpec;
src/studio/session.tsadded+283−0View file
@@ -0,0 +1,283 @@
1+/**
2+ * One running scene: the air grid, every object's grid, the generated (or
3+ * hand-edited) .m files compiled to GPU plans, the timestep, the microphone.
4+ *
5+ * Everything that is not rendering. The app and the tests both go through
6+ * this, so there is one place that decides how a SceneSpec becomes something
7+ * running on the GPU — and nothing about it is browser-specific beyond
8+ * needing a GPUDevice.
9+ *
10+ * The order of construction matters and is worth spelling out. The air grid
11+ * fixes h; with the speed of air that fixes a provisional timestep (the CFL
12+ * condition); the generator then sizes every object's grid at that dt and
13+ * instantiates the ops. The scene .m is evaluated next — normally it keeps
14+ * the medium at c0 and the provisional dt stands, but an edited scene that
15+ * puts a faster medium somewhere lowers dt, and the object grids are resized
16+ * once at the final value. Objects have no say in the timestep; each chooses
17+ * the finest node spacing that is stable at the air's dt for anything its
18+ * sliders can reach (see src/grids.ts), which is why moving a slider never
19+ * recompiles.
20+ */
21+import { makeAirGrid, stableDt, type AirGrid, type ObjectGrid } from '../grids.ts';
22+import { OpPlan } from '../mgpu/ops.ts';
23+import { GpuModel, type ModelParams } from '../mgpu/model.ts';
24+import { Scene } from '../scene/scene.ts';
25+import { Recorder } from '../audio/recorder.ts';
26+import { C_AIR, CFL, DOMAIN_X } from '../units.ts';
27+import { generate, suffixedParams, type GeneratedScene } from './codegen.ts';
28+import { templateOf } from './objects.ts';
29+import { BODY_TEMPLATES } from './bodies.ts';
30+import { defaultsOf, type Params, type SceneSpec } from './types.ts';
31+
32+export interface StudioSessionOptions {
33+ device: GPUDevice;
34+ spec: SceneSpec;
35+ /** Model parameter values by suffixed key; defaults fill the gaps. */
36+ params?: Params;
37+ /** Scene parameter values (placement + body) by key; defaults fill gaps. */
38+ sceneParams?: Params;
39+ /** Override the generated sources — the Lab editor's working copies. */
40+ modelSource?: string;
41+ sceneSource?: string;
42+ /** Air grid points along x; y and z get half each. */
43+ nx: number;
44+ /** Domain length along x, metres. */
45+ Lx?: number;
46+ /** Grid fields one kernel may read, overriding what the device allows.
47+ * Only for tests. */
48+ operandBudget?: number;
49+}
50+
51+/** Model + scene parameter defaults for a spec, suffixed, spec values kept. */
52+export function specParams(spec: SceneSpec): { params: Params; sceneParams: Params } {
53+ const params: Params = {};
54+ const sceneParams: Params = {};
55+ for (const o of spec.objects) {
56+ const t = templateOf(o);
57+ Object.assign(params, suffixedParams(t.paramSpecs, o.params, o.id));
58+ Object.assign(sceneParams, suffixedParams(t.placeSpecs, o.place, o.id));
59+ }
60+ Object.assign(
61+ sceneParams,
62+ defaultsOf(BODY_TEMPLATES[spec.body.type].paramSpecs),
63+ spec.body.params,
64+ );
65+ return { params, sceneParams };
66+}
67+
68+export class StudioSession {
69+ readonly device: GPUDevice;
70+ readonly spec: SceneSpec;
71+ readonly air: AirGrid;
72+ readonly gpu: GpuModel;
73+ readonly generated: GeneratedScene;
74+ /** The sources actually compiled (generated, or the editor's overrides). */
75+ readonly modelSource: string;
76+ readonly sceneSource: string;
77+ /** The microphone: the pressure at one point, every timestep. */
78+ readonly recorder: Recorder;
79+
80+ /** Model time and step count since the last excite. */
81+ t = 0;
82+ steps = 0;
83+
84+ #scene: Scene;
85+ #params: Params;
86+ #sceneParams: Params;
87+ #dt: number;
88+
89+ private constructor(init: {
90+ device: GPUDevice;
91+ spec: SceneSpec;
92+ air: AirGrid;
93+ gpu: GpuModel;
94+ generated: GeneratedScene;
95+ modelSource: string;
96+ sceneSource: string;
97+ recorder: Recorder;
98+ scene: Scene;
99+ params: Params;
100+ sceneParams: Params;
101+ dt: number;
102+ }) {
103+ this.device = init.device;
104+ this.spec = init.spec;
105+ this.air = init.air;
106+ this.gpu = init.gpu;
107+ this.generated = init.generated;
108+ this.modelSource = init.modelSource;
109+ this.sceneSource = init.sceneSource;
110+ this.recorder = init.recorder;
111+ this.#scene = init.scene;
112+ this.#params = init.params;
113+ this.#sceneParams = init.sceneParams;
114+ this.#dt = init.dt;
115+ }
116+
117+ static async create(opts: StudioSessionOptions): Promise<StudioSession> {
118+ const { device, spec } = opts;
119+ const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X);
120+
121+ const defaults = specParams(spec);
122+ const params = { ...defaults.params, ...opts.params };
123+ const sceneParams = { ...defaults.sceneParams, ...opts.sceneParams };
124+
125+ // A provisional timestep from the speed of air; the scene is evaluated
126+ // against it, and only an edited scene with a faster medium changes it.
127+ let dt = stableDt(air.h, C_AIR, CFL);
128+ let generated = generate(spec, air, dt);
129+ const sceneSource = opts.sceneSource ?? generated.sceneSource;
130+
131+ const buildScene = (): Scene =>
132+ Scene.create({
133+ air,
134+ source: sceneSource,
135+ outputs: generated.sceneOutputs,
136+ consts: generated.sceneConsts,
137+ paramNames: generated.sceneParamNames,
138+ params: sceneParams,
139+ });
140+ let scene = buildScene();
141+ const dtFinal = stableDt(air.h, Math.max(scene.cmax, C_AIR), CFL);
142+ if (dtFinal < dt) {
143+ // A faster medium tightens the CFL bound; resize the object grids once
144+ // at the final timestep. The scene's fields do not depend on dt.
145+ dt = dtFinal;
146+ generated = generate(spec, air, dt);
147+ }
148+
149+ const modelSource = opts.modelSource ?? generated.modelSource;
150+ const ops = new OpPlan(device, generated.ops);
151+
152+ // The scene's evaluated fields replace the generator's placeholders.
153+ const fields = { ...generated.modelFields, ...scene.fields };
154+
155+ const gpu = await GpuModel.create({
156+ device,
157+ ops,
158+ consts: generated.modelConsts,
159+ fields,
160+ source: modelSource,
161+ paramNames: generated.modelParamNames,
162+ state: generated.state,
163+ operandBudget: opts.operandBudget,
164+ });
165+ gpu.setDt(dt);
166+
167+ // The microphone listens to the host-owned pressure buffer, which is
168+ // where both `init` and `step` leave their result.
169+ const recorder = await Recorder.create({
170+ device,
171+ field: gpu.stateBuffer('p')!,
172+ nx: air.nx,
173+ ny: air.ny,
174+ nz: air.nz,
175+ });
176+
177+ const session = new StudioSession({
178+ device, spec, air, gpu, generated, modelSource, sceneSource,
179+ recorder, scene, params, sceneParams, dt,
180+ });
181+ gpu.setParams(params);
182+ return session;
183+ }
184+
185+ get scene(): Scene {
186+ return this.#scene;
187+ }
188+
189+ get dt(): number {
190+ return this.#dt;
191+ }
192+
193+ get params(): Params {
194+ return this.#params;
195+ }
196+
197+ get sceneParams(): Params {
198+ return this.#sceneParams;
199+ }
200+
201+ /** Each object's grid, by id. */
202+ get grids(): Record<string, ObjectGrid> {
203+ return this.generated.grids;
204+ }
205+
206+ /**
207+ * Re-evaluate the scene under a running model — a body edit, an object
208+ * moved. It is data, not code, so this needs no recompile. The timestep is
209+ * deliberately left alone: it was set from max(cmax, c_air) at build time,
210+ * and a scene edit that *raises* the fastest speed above that needs a
211+ * rebuild anyway (the caller compares `dtWanted` and rebuilds when they
212+ * disagree).
213+ */
214+ setScene(sceneParams: Params, source?: string): void {
215+ const scene = Scene.create({
216+ air: this.air,
217+ source: source ?? this.sceneSource,
218+ outputs: this.generated.sceneOutputs,
219+ consts: this.generated.sceneConsts,
220+ paramNames: this.generated.sceneParamNames,
221+ params: sceneParams,
222+ });
223+ this.#scene = scene;
224+ this.#sceneParams = sceneParams;
225+ this.gpu.uploadFields(scene.fields);
226+ }
227+
228+ /** The timestep the current scene would ask for. Differs from `dt` only
229+ * when a scene edit changed the fastest speed, which calls for a rebuild. */
230+ get dtWanted(): number {
231+ return stableDt(this.air.h, Math.max(this.#scene.cmax, C_AIR), CFL);
232+ }
233+
234+ setParams(params: ModelParams): void {
235+ this.#params = params;
236+ this.gpu.setParams(params);
237+ }
238+
239+ /** Run `init`: every object drawn into its excitation, silent air, t = 0. */
240+ excite(): void {
241+ this.gpu.init();
242+ this.recorder.clear();
243+ this.t = 0;
244+ this.steps = 0;
245+ }
246+
247+ /** Put the microphone at the grid point nearest (x, y, z), metres. */
248+ setMic(x: number, y: number, z: number): void {
249+ const { Lx, Ly, Lz, h } = this.air;
250+ this.recorder.setProbe(
251+ (x + Lx / 2) / h - 0.5,
252+ (y + Ly / 2) / h - 0.5,
253+ (z + Lz / 2) / h - 0.5,
254+ );
255+ }
256+
257+ /** Advance `n` steps. Synchronous: records and submits, nothing read back.
258+ * The microphone samples inside the same submission, once per step. */
259+ step(n = 1): void {
260+ this.gpu.step(n, (enc) => this.recorder.encode(enc));
261+ this.t += n * this.#dt;
262+ this.steps += n;
263+ }
264+
265+ /** Wait for the submitted steps to finish, without reading anything back. */
266+ sync(): Promise<undefined> {
267+ return this.device.queue.onSubmittedWorkDone();
268+ }
269+
270+ /** Read a named field back to the CPU. */
271+ read(name: string): Promise<Float32Array> {
272+ return this.gpu.read(name);
273+ }
274+
275+ describe(): { init: string[]; step: string[] } {
276+ return this.gpu.describe();
277+ }
278+
279+ destroy(): void {
280+ this.recorder.destroy();
281+ this.gpu.destroy();
282+ }
283+}
src/studio/spectrum.tsadded+245−0View file
@@ -0,0 +1,245 @@
1+/**
2+ * The spectrum of what the microphone heard, against where the partials
3+ * *should* be — twice over.
4+ *
5+ * For every object two predictions are drawn. The dashed markers are the
6+ * continuous equations' partials (a string's n·f0·sqrt(1+Bn²), a bar's
7+ * n²·f1, a membrane's inharmonic mode set). The solid markers are the
8+ * *discrete scheme's own* dispersion relation: the same operators the GPU
9+ * runs have sine eigenmodes with eigenvalue lam_n = (4/h²) sin²(n π h / 2L)
10+ * per axis, so mode n of a string rings at sqrt(cs² lam_n + kap² lam_n²)/2π
11+ * exactly. Where the two disagree, that gap *is* the numerical dispersion —
12+ * on a bar's coarse grid it is dozens of cents, and you can hear it. The
13+ * measured peaks land on the solid markers, which is the point: the solver
14+ * is faithful to its scheme, and the scheme is honest about its distance
15+ * from the continuum.
16+ */
17+import type { ObjectGrid } from '../grids.ts';
18+import { templateOf } from './objects.ts';
19+import type { ObjectSpec } from './types.ts';
20+
21+/** Log-magnitude spectrum of a recorded trace, by radix-2 FFT with a Hann
22+ * window. Returns dB relative to the peak bin, and the bin spacing. */
23+export function computeSpectrum(
24+ trace: Float32Array,
25+ dt: number,
26+ fmax: number,
27+): { db: Float32Array; df: number } {
28+ // Enough samples for ~5 Hz resolution, capped to keep the FFT instant.
29+ let n = 1;
30+ while (n < trace.length && n < 1 << 17) n <<= 1;
31+ if (n > trace.length) n >>= 1;
32+ n = Math.max(n, 2);
33+ const re = new Float64Array(n);
34+ const im = new Float64Array(n);
35+ for (let i = 0; i < n; i++) {
36+ const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1));
37+ re[i] = trace[i] * w;
38+ }
39+ fft(re, im);
40+ const df = 1 / (n * dt);
41+ const bins = Math.max(1, Math.min(n / 2, Math.ceil(fmax / df)));
42+ const db = new Float32Array(bins);
43+ let peak = 1e-300;
44+ for (let i = 0; i < bins; i++) {
45+ const m = Math.hypot(re[i], im[i]);
46+ db[i] = m;
47+ if (m > peak) peak = m;
48+ }
49+ for (let i = 0; i < bins; i++) db[i] = 20 * Math.log10(Math.max(db[i] / peak, 1e-8));
50+ return { db, df };
51+}
52+
53+/** In-place radix-2 FFT. */
54+function fft(re: Float64Array, im: Float64Array): void {
55+ const n = re.length;
56+ for (let i = 1, j = 0; i < n; i++) {
57+ let bit = n >> 1;
58+ for (; j & bit; bit >>= 1) j ^= bit;
59+ j ^= bit;
60+ if (i < j) {
61+ [re[i], re[j]] = [re[j], re[i]];
62+ [im[i], im[j]] = [im[j], im[i]];
63+ }
64+ }
65+ for (let len = 2; len <= n; len <<= 1) {
66+ const ang = (-2 * Math.PI) / len;
67+ const wr = Math.cos(ang);
68+ const wi = Math.sin(ang);
69+ for (let i = 0; i < n; i += len) {
70+ let cr = 1;
71+ let ci = 0;
72+ for (let k = 0; k < len / 2; k++) {
73+ const ur = re[i + k];
74+ const ui = im[i + k];
75+ const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
76+ const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
77+ re[i + k] = ur + vr;
78+ im[i + k] = ui + vi;
79+ re[i + k + len / 2] = ur - vr;
80+ im[i + k + len / 2] = ui - vi;
81+ const nr = cr * wr - ci * wi;
82+ ci = cr * wi + ci * wr;
83+ cr = nr;
84+ }
85+ }
86+ }
87+}
88+
89+export interface PartialSet {
90+ id: string;
91+ label: string;
92+ /** The continuous equations' partials. */
93+ continuous: number[];
94+ /** The discrete scheme's — where the peaks actually land. */
95+ discrete: number[];
96+}
97+
98+/** The Dirichlet second-difference eigenvalue for mode n. */
99+const lam = (n: number, h: number, L: number): number =>
100+ (4 / (h * h)) * Math.sin((n * Math.PI * h) / (2 * L)) ** 2;
101+
102+/** Both predictions for one object, up to fmax. */
103+export function predictedPartials(
104+ spec: ObjectSpec,
105+ grid: ObjectGrid,
106+ params: Record<string, number>,
107+ fmax: number,
108+): PartialSet {
109+ const t = templateOf(spec);
110+ const p = (key: string): number => params[`${key}_${spec.id}`] ?? 0;
111+ const within = (fs: number[]): number[] => fs.filter((f) => f > 0 && f <= fmax);
112+ let discrete: number[] = [];
113+ const specNow: ObjectSpec = { ...spec, params: Object.fromEntries(
114+ t.paramSpecs.map((s) => [s.key, p(s.key)]),
115+ ) };
116+ const continuous = within(t.partials(specNow, 16));
117+
118+ if (spec.type === 'string') {
119+ const cs = 2 * grid.L1 * p('f0');
120+ const kap2 = (p('B') * cs * cs * grid.L1 * grid.L1) / (Math.PI * Math.PI);
121+ // Modes exist up to n = n1 - 2 (the interior dimension).
122+ for (let n = 1; n <= grid.n1 - 2; n++) {
123+ const l = lam(n, grid.h1, grid.L1);
124+ discrete.push(Math.sqrt(cs * cs * l + kap2 * l * l) / (2 * Math.PI));
125+ }
126+ } else if (spec.type === 'bar') {
127+ const kap = (2 * p('f1') * grid.L1 * grid.L1) / Math.PI;
128+ for (let n = 1; n <= grid.n1 - 2; n++) {
129+ discrete.push((kap * lam(n, grid.h1, grid.L1)) / (2 * Math.PI));
130+ }
131+ } else {
132+ const L2 = grid.L2;
133+ const cm =
134+ (2 * p('f0')) / Math.sqrt(1 / (grid.L1 * grid.L1) + 1 / (L2 * L2));
135+ for (let m = 1; m <= Math.min(6, grid.n1 - 2); m++) {
136+ for (let n = 1; n <= Math.min(6, grid.n2 - 2); n++) {
137+ const l = lam(m, grid.h1, grid.L1) + lam(n, grid.h2, grid.L2);
138+ discrete.push((cm * Math.sqrt(l)) / (2 * Math.PI));
139+ }
140+ }
141+ discrete.sort((a, b) => a - b);
142+ }
143+ discrete = within(discrete).slice(0, 16);
144+ return { id: spec.id, label: `${t.label} ${spec.id}`, continuous, discrete };
145+}
146+
147+/** One hue per object, shared by the overlay and the spectrum. */
148+export const OBJECT_COLORS = [
149+ '#ffd076', '#7fd1ff', '#98e698', '#ff9e9e', '#d2a8ff', '#ffc4e6', '#c9d67f',
150+];
151+export const objectColor = (index: number): string =>
152+ OBJECT_COLORS[index % OBJECT_COLORS.length];
153+
154+export interface SpectrumDrawOptions {
155+ trace: Float32Array;
156+ dt: number;
157+ fmax: number;
158+ partials: PartialSet[];
159+ dark: boolean;
160+}
161+
162+/** Draw the spectrum with the partial markers into a 2D canvas. */
163+export function drawSpectrum(canvas: HTMLCanvasElement, o: SpectrumDrawOptions): void {
164+ const ctx = canvas.getContext('2d');
165+ if (!ctx) return;
166+ const dpr = devicePixelRatio;
167+ const rect = canvas.getBoundingClientRect();
168+ const w = Math.max(1, Math.round(rect.width * dpr));
169+ const h = Math.max(1, Math.round(rect.height * dpr));
170+ if (canvas.width !== w || canvas.height !== h) {
171+ canvas.width = w;
172+ canvas.height = h;
173+ }
174+ ctx.clearRect(0, 0, w, h);
175+ const ink = o.dark ? 'rgba(230,233,236,0.92)' : 'rgba(31,35,40,0.92)';
176+ const faint = o.dark ? 'rgba(154,164,175,0.3)' : 'rgba(87,96,106,0.3)';
177+ const padL = 34 * dpr;
178+ const padB = 18 * dpr;
179+ const padT = 6 * dpr;
180+ const plotW = w - padL - 6 * dpr;
181+ const plotH = h - padT - padB;
182+ const dbFloor = -90;
183+ const fx = (f: number): number => padL + (plotW * f) / o.fmax;
184+ const fy = (db: number): number =>
185+ padT + plotH * Math.min(1, Math.max(0, -db / -dbFloor));
186+
187+ // Grid lines and labels every kilohertz (every 200 Hz under 1.5 kHz).
188+ ctx.font = `${10 * dpr}px system-ui, sans-serif`;
189+ ctx.fillStyle = faint;
190+ ctx.strokeStyle = faint;
191+ ctx.lineWidth = dpr;
192+ const tick = o.fmax > 1500 ? 1000 : 200;
193+ for (let f = tick; f < o.fmax; f += tick) {
194+ ctx.beginPath();
195+ ctx.moveTo(fx(f), padT);
196+ ctx.lineTo(fx(f), padT + plotH);
197+ ctx.stroke();
198+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, fx(f) + 2 * dpr, h - 6 * dpr);
199+ }
200+ for (const db of [0, -30, -60]) {
201+ ctx.fillText(`${db}`, 4 * dpr, fy(db) + 3 * dpr);
202+ }
203+
204+ // Partial markers behind the curve: solid = the scheme's own modes,
205+ // dashed = the continuous equations'.
206+ o.partials.forEach((set, i) => {
207+ const color = objectColor(i);
208+ ctx.strokeStyle = color;
209+ ctx.globalAlpha = 0.85;
210+ ctx.setLineDash([]);
211+ ctx.lineWidth = 1.4 * dpr;
212+ for (const f of set.discrete) {
213+ if (f > o.fmax) continue;
214+ ctx.beginPath();
215+ ctx.moveTo(fx(f), padT);
216+ ctx.lineTo(fx(f), padT + plotH * 0.98);
217+ ctx.stroke();
218+ }
219+ ctx.setLineDash([3 * dpr, 4 * dpr]);
220+ ctx.globalAlpha = 0.55;
221+ ctx.lineWidth = dpr;
222+ for (const f of set.continuous) {
223+ if (f > o.fmax) continue;
224+ ctx.beginPath();
225+ ctx.moveTo(fx(f), padT);
226+ ctx.lineTo(fx(f), padT + plotH * 0.98);
227+ ctx.stroke();
228+ }
229+ ctx.setLineDash([]);
230+ ctx.globalAlpha = 1;
231+ });
232+
233+ // The measured spectrum.
234+ const { db, df } = computeSpectrum(o.trace, o.dt, o.fmax);
235+ ctx.strokeStyle = ink;
236+ ctx.lineWidth = 1.4 * dpr;
237+ ctx.beginPath();
238+ for (let i = 0; i < db.length; i++) {
239+ const xx = fx(i * df);
240+ const yy = fy(db[i]);
241+ if (i === 0) ctx.moveTo(xx, yy);
242+ else ctx.lineTo(xx, yy);
243+ }
244+ ctx.stroke();
245+}
src/studio/types.tsadded+70−0View file
@@ -0,0 +1,70 @@
1+/**
2+ * What a scene *is*: a list of vibrating objects, a body for them to sound
3+ * over, and nothing else. Everything the app builds — the generated model .m,
4+ * the generated scene .m, the op instances, the sliders — is derived from
5+ * this description (src/studio/codegen.ts), so this file is the studio's
6+ * whole vocabulary.
7+ *
8+ * Two kinds of number live here, and the split decides what an edit costs:
9+ *
10+ * - `size` fields are baked into the grids and the coupling ops, so changing
11+ * one recompiles (a fresh session, like changing the air grid).
12+ * - `params` (model parameters: pitches, decays, gains) are GPU uniforms —
13+ * free to move — and `place` (placement in the air: an object's y offset
14+ * and height, the body's shape) are scene parameters, which cost one CPU
15+ * re-evaluation of the scene .m and a few buffer uploads. Neither
16+ * recompiles.
17+ *
18+ * Object ids are short and stable ('s1', 'm2'); the code generator suffixes
19+ * every per-object name with them, so the generated MATLAB stays readable in
20+ * the Lab view.
21+ */
22+
23+export type Params = Record<string, number>;
24+
25+/** A tunable scalar and its slider. */
26+export interface ParamSpec {
27+ key: string;
28+ label: string;
29+ value: number;
30+ min: number;
31+ max: number;
32+ step: number;
33+ /** Shown as a tooltip. */
34+ hint?: string;
35+ /** Shown in the Play layer (the handful of knobs a player would reach
36+ * for); everything else appears only in Build. */
37+ musical?: boolean;
38+}
39+
40+export type ObjectType = 'string' | 'bar' | 'membrane';
41+
42+export interface ObjectSpec {
43+ /** Unique in the scene: 's1', 'b1', 'm1', ... */
44+ id: string;
45+ type: ObjectType;
46+ /** Baked extents, metres: L1 along x (and L2 along y, for a membrane). */
47+ size: { L1: number; L2?: number };
48+ /** Model parameter values, by unsuffixed key (f0, t60, ...). */
49+ params: Params;
50+ /** Placement values, by unsuffixed key (oy, oz). Scene parameters. */
51+ place: Params;
52+}
53+
54+export type BodyType = 'none' | 'box' | 'shell';
55+
56+export interface BodySpec {
57+ type: BodyType;
58+ params: Params;
59+}
60+
61+export interface SceneSpec {
62+ objects: ObjectSpec[];
63+ body: BodySpec;
64+}
65+
66+export const defaultsOf = (specs: ParamSpec[]): Params =>
67+ Object.fromEntries(specs.map((p) => [p.key, p.value]));
68+
69+/** The suffixed name a per-object thing gets in the generated MATLAB. */
70+export const suffixed = (key: string, id: string): string => `${key}_${id}`;
src/tools.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+/**
2+ * The shared MATLAB utilities in `tools/`, as interpreter workspace files.
3+ *
4+ * A scene is evaluated by numbl's interpreter (see src/scene/scene.ts), which
5+ * resolves a call like `sponge(...)` against the workspace files it is handed.
6+ * Everything in `tools/` is handed to every such run, so any .m can call any
7+ * tool by name — MATLAB's own path semantics, where the file name is the
8+ * function name.
9+ *
10+ * These are *not* available to the models: a model's step compiles to WGSL,
11+ * where none of this exists.
12+ */
13+const sources = import.meta.glob('../tools/*.m', {
14+ query: '?raw',
15+ eager: true,
16+ import: 'default',
17+}) as Record<string, string>;
18+
19+export interface ToolFile {
20+ name: string;
21+ source: string;
22+}
23+
24+/** Every tool, named as MATLAB wants it (`randnfunsphere.m`). */
25+export const toolFiles: ToolFile[] = Object.entries(sources)
26+ .map(([path, source]) => ({ name: path.slice(path.lastIndexOf('/') + 1), source }))
27+ .sort((a, b) => a.name.localeCompare(b.name));
src/units.tsadded+43−0View file
@@ -0,0 +1,43 @@
1+/**
2+ * Everything in SI: metres, seconds, hertz, metres per second.
3+ *
4+ * Physical units are what make the numbers here mean something. The string's
5+ * fundamental is a real pitch, the box is a real size you could build, the
6+ * timestep is a real duration, and the microphone's trace plays back at the
7+ * pitch a microphone there would have heard. They also keep the method's
8+ * limits visible: a grid solver resolves a wavelength with some number of
9+ * cells, so a fixed grid is a low-frequency method, and how low is a number
10+ * the app can show rather than hide.
11+ */
12+
13+/** Speed of sound in air at about 20 °C, m/s. */
14+export const C_AIR = 343;
15+
16+/** The domain, metres: a box around the instrument, twice as long along the
17+ * string (x) as across (y) and up (z), centred on the origin. The top plate
18+ * of the dulcimer body sits at z = 0 and the string runs along x just above
19+ * it. */
20+export const DOMAIN_X = 1.0;
21+export const DOMAIN_YZ = 0.5;
22+
23+/** Fraction of the stability limit the timestep is taken at. Fixed rather
24+ * than a control: everything downstream (the string grid, the recording's
25+ * sample rate) follows from dt, and one good value is worth more here than a
26+ * slider. */
27+export const CFL = 0.5;
28+
29+/** Cells per wavelength below which what is on screen (and in the recording)
30+ * is as much grid dispersion as it is sound. */
31+export const POOR_RESOLUTION = 8;
32+
33+/** A length in metres, written the way a person would say it. */
34+export const fmtLength = (m: number): string =>
35+ Math.abs(m) < 1 ? `${(1000 * m).toPrecision(3)} mm` : `${m.toPrecision(3)} m`;
36+
37+/** A duration in seconds, likewise. */
38+export const fmtTime = (s: number): string => {
39+ const a = Math.abs(s);
40+ if (a > 0 && a < 1e-3) return `${(1e6 * s).toPrecision(3)} µs`;
41+ if (a < 1) return `${(1e3 * s).toPrecision(3)} ms`;
42+ return `${s.toPrecision(3)} s`;
43+};
test/checks.tsadded+377−0View file
@@ -0,0 +1,377 @@
1+/**
2+ * The checks, against the real pipeline: SceneSpec -> generated MATLAB ->
3+ * numbl lowering -> generated WGSL -> GPU. Physics first — does each kind of
4+ * object sound the pitch it is tuned to, does the sealed box actually seal,
5+ * do two objects in one scene both reach the microphone — and the planner's
6+ * mechanics after.
7+ *
8+ * Everything runs on deliberately small grids: these are correctness checks,
9+ * and a 32-point air grid already carries every code path the 128-point one
10+ * does.
11+ */
12+import { StudioSession } from '../src/studio/session.ts';
13+import { cloneSpec, presetByKey } from '../src/studio/presets.ts';
14+import type { Params, SceneSpec } from '../src/studio/types.ts';
15+import { planPlayback } from '../src/audio/play.ts';
16+
17+type Check = (name: string, ok: boolean, detail: string) => void;
18+type Log = (s: string) => void;
19+
20+const peakOf = (a: Float32Array): number => {
21+ let m = 0;
22+ for (const v of a) m = Math.max(m, Math.abs(v));
23+ return m;
24+};
25+
26+const spec = (key: string): SceneSpec => cloneSpec(presetByKey(key)!.spec);
27+
28+async function makeSession(
29+ device: GPUDevice,
30+ sceneSpec: SceneSpec,
31+ nx: number,
32+ params: Params = {},
33+ sceneParams: Params = {},
34+ operandBudget?: number,
35+): Promise<StudioSession> {
36+ return StudioSession.create({
37+ device, spec: sceneSpec, nx, params, sceneParams, operandBudget,
38+ });
39+}
40+
41+/** Frequency content of a series sampled every `sdt` seconds. */
42+function dftAt(series: number[], sdt: number, f: number): number {
43+ let re = 0;
44+ let im = 0;
45+ for (let i = 0; i < series.length; i++) {
46+ const w = 2 * Math.PI * f * i * sdt;
47+ re += series[i] * Math.cos(w);
48+ im -= series[i] * Math.sin(w);
49+ }
50+ return Math.hypot(re, im) / series.length;
51+}
52+
53+/** Run a session and sample one state element every `every` steps. */
54+async function sampleField(
55+ s: StudioSession,
56+ field: string,
57+ index: number,
58+ totalSteps: number,
59+ every: number,
60+): Promise<{ series: number[]; sdt: number }> {
61+ const series: number[] = [];
62+ for (let done = 0; done < totalSteps; done += every) {
63+ s.step(every);
64+ const u = await s.read(field);
65+ series.push(u[index]);
66+ }
67+ return { series, sdt: every * s.dt };
68+}
69+
70+/** The compiled plan: per-object external ops present, excitation in place,
71+ * mic riding along. */
72+export async function planChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
73+ const s = await makeSession(device, spec('dulcimer'), 32);
74+ const { step } = s.describe();
75+ for (const l of step) log(` ${l}`);
76+ const externals = step.filter((l) => l.startsWith('external'));
77+ const wanted = ['dxx_s1(u_s1)', 'dxx_s1(um_s1)', 'dxxxx_s1(u_s1)', 'rad_s1(acc_s1)', 'brg_s1(un_s1)', 'lapw(p, wall)'];
78+ for (const w of wanted) {
79+ check(
80+ `the step uses ${w}`,
81+ externals.some((l) => l.includes(w)),
82+ externals.length ? externals.join('; ') : 'no external ops planned',
83+ );
84+ }
85+
86+ s.excite();
87+ const u = await s.read('u_s1');
88+ const amp = s.params.amp_s1;
89+ const peak = peakOf(u);
90+ check(
91+ 'the pluck draws the string to its set height',
92+ Math.abs(peak - amp) < 0.15 * amp,
93+ `peak |u| = ${peak.toExponential(3)}, amp = ${amp}`,
94+ );
95+ check('the ends are pinned', u[0] === 0 && u[u.length - 1] === 0, `u[0]=${u[0]}, u[end]=${u[u.length - 1]}`);
96+
97+ s.step(100);
98+ check(
99+ 'the microphone samples once per timestep',
100+ s.recorder.count === 100,
101+ `${s.recorder.count} samples after 100 steps`,
102+ );
103+ const plan = planPlayback(s.recorder.count, s.dt);
104+ check(
105+ 'playback is real time at the solver rate',
106+ plan.realTime && Math.abs(plan.rate * s.dt - 1) < 1e-9,
107+ `rate ${plan.rate.toFixed(0)} Hz, dt ${s.dt.toExponential(3)}`,
108+ );
109+ s.destroy();
110+}
111+
112+/** The generated sources look like what the Lab view promises. */
113+export async function codegenChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
114+ const s = await makeSession(device, spec('duet'), 32);
115+ const m = s.generated.modelSource;
116+ const sc = s.generated.sceneSource;
117+ check(
118+ 'the generated model carries both strings',
119+ m.includes('un_s1 = pin_s1') && m.includes('un_s2 = pin_s2'),
120+ 'looked for un_s1 and un_s2 updates',
121+ );
122+ check(
123+ 'the generated scene builds a profile per object',
124+ sc.includes('prof_s1 =') && sc.includes('prof_s2 =') && sc.includes('bprof_s2 ='),
125+ 'looked for prof_s1, prof_s2, bprof_s2',
126+ );
127+ log(` model: ${m.split('\n').length} lines, scene: ${sc.split('\n').length} lines`);
128+ s.destroy();
129+}
130+
131+/** A string sounds the pitch it is tuned to. */
132+export async function stringChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
133+ // An ideal string (no stiffness, no damping to speak of), watched at a
134+ // point for a dozen periods; the zero crossings say the frequency.
135+ const f0 = 294;
136+ const s = await makeSession(device, spec('dulcimer'), 32, {
137+ f0_s1: f0, B_s1: 0, sig1_s1: 0, t60_s1: 8,
138+ });
139+ s.excite();
140+ const g = s.grids.s1;
141+ const node = Math.round(g.n1 * 0.4);
142+ const periods = 12;
143+ const stepsTotal = Math.round(periods / f0 / s.dt);
144+ const { series } = await sampleField(s, 'u_s1', node, stepsTotal, 10);
145+ let crossings = 0;
146+ for (let i = 1; i < series.length; i++) {
147+ if ((series[i - 1] < 0 && series[i] >= 0) || (series[i - 1] >= 0 && series[i] < 0)) crossings++;
148+ }
149+ const measured = crossings / 2 / (stepsTotal * s.dt);
150+ log(` ${crossings} zero crossings over ${(stepsTotal * s.dt * 1000).toFixed(1)} ms -> ${measured.toFixed(1)} Hz`);
151+ check(
152+ `the string sounds its fundamental (${f0} Hz)`,
153+ Math.abs(measured - f0) < 0.04 * f0,
154+ `measured ${measured.toFixed(1)} Hz`,
155+ );
156+
157+ // The decay knob means what it says: at half of t60 the string should be
158+ // down 30 dB.
159+ const t60 = 0.5;
160+ const s2 = await makeSession(device, spec('dulcimer'), 32, {
161+ t60_s1: t60, B_s1: 0, sig1_s1: 0,
162+ });
163+ s2.excite();
164+ const before = peakOf(await s2.read('u_s1'));
165+ s2.step(Math.round(t60 / 2 / s2.dt));
166+ const after = peakOf(await s2.read('u_s1'));
167+ const db = 20 * Math.log10(after / before);
168+ log(` |u| ${before.toExponential(2)} -> ${after.toExponential(2)} in ${t60 / 2} s (${db.toFixed(1)} dB)`);
169+ check(
170+ 't60 decays the string on schedule',
171+ Math.abs(db + 30) < 4,
172+ `${db.toFixed(1)} dB over t60/2, want -30`,
173+ );
174+ s.destroy();
175+ s2.destroy();
176+}
177+
178+/**
179+ * A bar rings at the frequencies its own discrete dispersion relation
180+ * predicts. The bar grid is coarse by construction (an explicit stiff scheme
181+ * wants coarse grids), so the continuous n² f1 comb is audibly detuned; what
182+ * must hold exactly is the scheme's own relation: mode n of the pinned
183+ * fourth-difference operator has eigenvalue lam_n = (4/h²) sin²(n π h / 2L),
184+ * and rings at f_n = kap·lam_n / 2π. This is the "you can hear the
185+ * dispersion" check: the same numbers the Lab spectrum overlay draws.
186+ */
187+export async function barChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
188+ const f1 = 780;
189+ const s = await makeSession(device, spec('chime'), 32, {
190+ f1_b1: f1, sig1_b1: 0, t60_b1: 12,
191+ });
192+ s.excite();
193+ const g = s.grids.b1;
194+ const kap = (2 * f1 * g.L1 * g.L1) / Math.PI;
195+ const lam = (n: number): number =>
196+ (4 / (g.h1 * g.h1)) * Math.sin((n * Math.PI * g.h1) / (2 * g.L1)) ** 2;
197+ const fd = (n: number): number => (kap * lam(n) * lam(n) === 0 ? 0 : (kap * lam(n)) / (2 * Math.PI));
198+ const node = Math.round(g.n1 * 0.37);
199+ const total = Math.round(0.06 / s.dt);
200+ const { series, sdt } = await sampleField(s, 'u_b1', node, total, 4);
201+ const comb = (dftAt(series, sdt, fd(1)) + dftAt(series, sdt, fd(2))) / 2;
202+ const off =
203+ (dftAt(series, sdt, 1.4 * fd(1)) + dftAt(series, sdt, Math.sqrt(fd(1) * fd(2)))) / 2;
204+ log(
205+ ` discrete modes ${fd(1).toFixed(0)}, ${fd(2).toFixed(0)} Hz ` +
206+ `(continuous would be ${f1}, ${4 * f1}); comb ${comb.toExponential(2)}, off ${off.toExponential(2)}`,
207+ );
208+ check(
209+ 'the bar rings where its discrete dispersion relation says',
210+ comb > 5 * off,
211+ `comb/off = ${(comb / off).toFixed(1)}`,
212+ );
213+ s.destroy();
214+}
215+
216+/** A membrane struck at its centre sounds its (1,1) fundamental. */
217+export async function membraneChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
218+ const f0 = 110;
219+ const s = await makeSession(device, spec('drum'), 32, {
220+ f0_m1: f0, sig1_m1: 0, t60_m1: 6, strikex_m1: 0.5, strikey_m1: 0.5,
221+ });
222+ s.excite();
223+ const g = s.grids.m1;
224+ const centre = Math.round(g.n1 / 2) + g.n1 * Math.round(g.n2 / 2);
225+ const total = Math.round(10 / f0 / s.dt);
226+ const { series, sdt } = await sampleField(s, 'u_m1', centre, total, 10);
227+ const at = dftAt(series, sdt, f0);
228+ const off = (dftAt(series, sdt, 1.31 * f0) + dftAt(series, sdt, 0.71 * f0)) / 2;
229+ log(` membrane at f0 ${at.toExponential(2)}, off ${off.toExponential(2)}`);
230+ check(
231+ `the membrane sounds its (1,1) mode (${f0} Hz)`,
232+ at > 4 * off,
233+ `on/off = ${(at / off).toFixed(1)}`,
234+ );
235+ s.destroy();
236+}
237+
238+/** The wall mask is a wall: a sealed box keeps the sound out. */
239+export async function wallChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
240+ // Bridge drive only (the direct route off), so the source sits above the
241+ // plate; a microphone inside a sealed box should hear far less than one
242+ // outside beside it.
243+ const seconds = 0.06;
244+ const run = async (mic: [number, number, number], holer: number): Promise<number> => {
245+ const s = await makeSession(
246+ device,
247+ spec('dulcimer'),
248+ 64,
249+ { gair_s1: 0, gbridge_s1: 1 },
250+ { holer, absorb: 0, boxd: 0.12, thick: 0.02 },
251+ );
252+ s.setMic(...mic);
253+ s.excite();
254+ s.step(Math.round(seconds / s.dt));
255+ const trace = await s.recorder.read();
256+ s.destroy();
257+ return peakOf(trace);
258+ };
259+ const inside = await run([0, 0, -0.06], 0);
260+ const outside = await run([0, 0, 0.08], 0);
261+ log(` |p| inside the sealed box ${inside.toExponential(2)}, outside ${outside.toExponential(2)}`);
262+ check(
263+ 'a sealed box keeps the sound out',
264+ inside < 0.05 * outside,
265+ `inside/outside = ${(inside / outside).toExponential(2)}`,
266+ );
267+
268+ const open = await run([0, 0, -0.06], 0.05);
269+ log(` |p| in the cavity: sealed ${inside.toExponential(2)}, open ${open.toExponential(2)}`);
270+ check(
271+ 'the sound hole lets the cavity speak',
272+ open > 5 * inside,
273+ `open/sealed = ${(open / inside).toExponential(2)}`,
274+ );
275+}
276+
277+/** Two objects in one scene both reach the microphone. */
278+export async function duetChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
279+ const s = await makeSession(device, spec('duet'), 64, {
280+ B_s1: 0, B_s2: 0, sig1_s1: 0, sig1_s2: 0,
281+ });
282+ s.setMic(0.12, 0.08, 0.1);
283+ s.excite();
284+ const seconds = 0.12;
285+ const total = Math.round(seconds / s.dt);
286+ for (let done = 0; done < total; done += 2048) {
287+ s.step(Math.min(2048, total - done));
288+ await s.sync();
289+ }
290+ const trace = await s.recorder.read();
291+ const series = [...trace];
292+ const at = (f: number): number => dftAt(series, s.dt, f);
293+ const s1 = at(294);
294+ const s2 = at(441);
295+ const off = (at(360) + at(520)) / 2;
296+ log(` duet: 294 Hz ${s1.toExponential(2)}, 441 Hz ${s2.toExponential(2)}, off ${off.toExponential(2)}`);
297+ check('the first string reaches the microphone', s1 > 4 * off, `294/off = ${(s1 / off).toFixed(1)}`);
298+ check('the second string reaches the microphone', s2 > 4 * off, `441/off = ${(s2 / off).toFixed(1)}`);
299+ s.destroy();
300+}
301+
302+/** Moving an object is a scene edit: no recompile, and the profile follows. */
303+export async function placementChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
304+ const s = await makeSession(device, spec('bare'), 32);
305+ const before = s.scene.fields.prof_s1.slice();
306+ s.setScene({ ...s.sceneParams, oy_s1: 0.1 });
307+ const after = s.scene.fields.prof_s1;
308+ let moved = 0;
309+ for (let i = 0; i < before.length; i++) {
310+ if (Math.abs(before[i] - after[i]) > 1e-6) moved++;
311+ }
312+ log(` ${moved} profile cells changed after moving the string`);
313+ check('moving an object moves its coupling profile', moved > 0, `${moved} cells changed`);
314+ s.excite();
315+ s.step(50);
316+ const p = await s.read('p');
317+ let bad = 0;
318+ for (const v of p) if (!Number.isFinite(v)) bad++;
319+ check('the moved scene still runs', bad === 0, `${bad} non-finite values`);
320+ s.destroy();
321+}
322+
323+/** Air sound sits on the string's partial comb, not somewhere else. */
324+export async function spectrumChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
325+ const f0 = 294;
326+ const s = await makeSession(device, spec('dulcimer'), 64, { f0_s1: f0 });
327+ s.setMic(0.12, 0.08, 0.1);
328+ s.excite();
329+ const seconds = 0.12;
330+ const total = Math.round(seconds / s.dt);
331+ for (let done = 0; done < total; done += 2048) {
332+ s.step(Math.min(2048, total - done));
333+ await s.sync();
334+ }
335+ const trace = await s.recorder.read();
336+ const series = [...trace];
337+ const comb = (dftAt(series, s.dt, f0) + dftAt(series, s.dt, 2 * f0) + dftAt(series, s.dt, 3 * f0)) / 3;
338+ const off = (dftAt(series, s.dt, 1.41 * f0) + dftAt(series, s.dt, 2.53 * f0)) / 2;
339+ log(` comb ${comb.toExponential(2)}, off-comb ${off.toExponential(2)}`);
340+ check(
341+ 'the microphone hears the string’s partials',
342+ comb > 8 * off,
343+ `comb/off = ${(comb / off).toFixed(1)}`,
344+ );
345+ s.destroy();
346+}
347+
348+/** A starved operand budget splits kernels instead of failing. */
349+export async function splitChecks(device: GPUDevice, check: Check, log: Log): Promise<void> {
350+ const s = await makeSession(device, spec('dulcimer'), 32, {}, {}, 2);
351+ const { step } = s.describe();
352+ const parts = step.filter((l) => l.includes('_part')).length;
353+ log(` ${parts} split kernels at budget 2`);
354+ check('a tight binding budget splits the update', parts > 0, `${parts} split kernels`);
355+ s.excite();
356+ s.step(50);
357+ const p = await s.read('p');
358+ let bad = 0;
359+ for (const v of p) if (!Number.isFinite(v)) bad++;
360+ check('the split model still runs', bad === 0, `${bad} non-finite values`);
361+
362+ // The split result must agree with the unsplit one.
363+ const s2 = await makeSession(device, spec('dulcimer'), 32);
364+ s2.excite();
365+ s2.step(50);
366+ const p2 = await s2.read('p');
367+ let worst = 0;
368+ const scale = Math.max(peakOf(p), 1e-30);
369+ for (let i = 0; i < p.length; i++) worst = Math.max(worst, Math.abs(p[i] - p2[i]));
370+ check(
371+ 'split and unsplit agree',
372+ worst < 1e-4 * scale,
373+ `worst |diff| = ${worst.toExponential(2)} of peak ${scale.toExponential(2)}`,
374+ );
375+ s.destroy();
376+ s2.destroy();
377+}
tools/sponge3.madded+23−0View file
@@ -0,0 +1,23 @@
1+% Absorption profile for an open boundary, in three dimensions.
2+%
3+% s = sponge3(x, y, z, Lx, Ly, Lz, w, smax)
4+%
5+% All in SI: positions and widths in metres, the result in inverse seconds.
6+% Zero in the interior, ramping up quadratically over a layer of width w
7+% inside each face of the box [-Lx/2, Lx/2] x [-Ly/2, Ly/2] x [-Lz/2, Lz/2]
8+% and reaching smax at the wall. Added to a scene's absorption, this is what
9+% makes the finite grid stand in for open air: a wave that leaves the
10+% instrument is attenuated before it reaches the outer boundary, and whatever
11+% reflects off that boundary is attenuated again on the way back.
12+%
13+% The ramp is gradual on purpose. An absorbing layer is itself an impedance
14+% mismatch, so a sudden one reflects; spreading it over a couple of
15+% wavelengths keeps that reflection small. It is not a perfectly matched
16+% layer, and at grazing incidence it does leak.
17+function s = sponge3(x, y, z, Lx, Ly, Lz, w, smax)
18+ dx = max(0, w - (Lx/2 - abs(x))) / w;
19+ dy = max(0, w - (Ly/2 - abs(y))) / w;
20+ dz = max(0, w - (Lz/2 - abs(z))) / w;
21+ d = max(max(dx, dy), dz);
22+ s = smax * d.^2;
23+end
tsconfig.jsonadded+15−0View file
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "types": ["@webgpu/types", "node"],
8+ "strict": true,
9+ "noEmit": true,
10+ "allowImportingTsExtensions": true,
11+ "verbatimModuleSyntax": true,
12+ "skipLibCheck": true
13+ },
14+ "include": ["src", "test", "scripts"]
15+}
vite.config.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+import { defineConfig } from 'vite';
2+import { realpathSync } from 'node:fs';
3+import { resolve } from 'node:path';
4+
5+// numbl is a local `file:` dependency, so node_modules/numbl is a symlink to
6+// the sibling checkout. Its package `exports` map only publishes the runtime
7+// entry points, not the compiler internals we need (parser + JIT lowering), so
8+// we reach them through a path alias — the same arrangement
9+// acoustic-scattering-2d and math-webgpu-sandbox use.
10+// Realpath'd through the symlink: dev serves modules under their real ids, so
11+// aliasing the node_modules path would give the same file two identities (one
12+// per spelling) and run its side effects twice — the interpreter's builtin
13+// registry throws on the second.
14+const numblSrc = realpathSync(resolve(import.meta.dirname, 'node_modules/numbl/src'));
15+
16+export default defineConfig({
17+ base: './',
18+ resolve: {
19+ alias: { 'numbl-src': numblSrc },
20+ },
21+ server: {
22+ // the alias resolves outside the project root (through the symlink)
23+ fs: { allow: [import.meta.dirname, numblSrc] },
24+ },
25+ build: {
26+ target: 'es2022',
27+ },
28+});