d8098edAn interactive tutorial on the wave equation in acoustics: strings, drums and rooms, measured by earJeremy Magland 1# CLAUDE.md
3Notes for anyone, human or agent, working in this repository. Read [README.md](README.md) first for what the tutorial contains.
5## The shape of the thing
7A Vite + React app that embeds [numbl](https://numbl.org) as a library (`numbl/browser`, `createNumblSession`), in the same pattern as the sibling `laplace-dirichlet` and `particle-filters` tutorials: no `numbl-project.json`, and the deploy is a plain `vite build`. The UI, engine, plotting layer and test scripts were taken from `particle-filters` and changed as little as possible; what is new is `src/audio/`, the chapters, and the library.
9```
10src/
11 App.tsx sidebar, hash routing, chapter switching
12 audio/Listen.tsx the play buttons: one AudioContext, one clip at a time
13 ui/ Lab (one runnable section), ChapterView, Controls, Markdown
14 engine/ the numbl session wrapper
15 plot/ a small hand-rolled SVG layer, plus a canvas field plot
16 figures/ static diagrams, keyed in figures/index.ts
17 matlab/ the shared wv_*.m library, shown on its own page
18 chapters/
19 intro/ front matter, no labs
20 common/panels.tsx SeriesPlot, NumTable, Row and the number formatters
21 membrane/ driver.m and panels.tsx shared by Chapters 4 and 5
22 chNN/ text.md, the editable .m script, chapter.tsx, and a
23 driver.m where the chapter has its own
24```
26A **chapter** is prose in one `text.md` with `<!--lab:id-->` and `<!--fig:id-->` markers. A **lab** is a driver (fixed, hidden), an editable script (the method), declarative controls, and a render function. `main.m` is `driver.m` + `"\n"` + the script, so the script's functions become local functions of the driver. Each driver's header comment states the contract its script must meet, and every driver computes the exact answer itself: an edited script cannot flatter itself.
28| driver | chapters | the script must define |
29|---|---|---|
30| `ch01/driver.m` | 1 | `y = pluck(t, f0, beta, where, xp, fmax, T60)` |
31| `ch02/driver.m` | 2 | `[y, U] = leapfrog(u, uold, lam, M, bc, jr, K)` |
32| `ch03/driver.m` | 3 | `y = ks(x0, D, tuning, M)` |
33| `membrane/driver.m` | 4, 5 | `[y, U] = drum2d(u, uold, lam, M, scheme, ir, jr, K)` |
34| `ch06/driver.m` | 6 | `[C, E, W, Nn, S, in] = diskstencil(N, method)` |
35| `ch07/driver.m` | 7 | `U = openbox(u0, uold, lam, M, method, W, keep)` |
36| `ch08/driver.m` | 8 | `h = images(L, xs, xr, beta, fs, tmax, taps)` |
37| `ch09/driver.m` | 9 | `y = room3d(n, lam, M, is, ir, src)` |
39## Sound
41`wv_audio.m` packs a clip as 16-bit integers scaled by a factor the driver chooses. Every clip in one comparison is given the same scale, so that relative loudness on the page is relative size in the computation; that is a rule, and the `Listen` component's note says so to the reader. It also fades each end by 4 ms, and raises any clip below 8 kHz to a whole multiple of its rate by zero-padding its spectrum, because browsers are only required to play 8 kHz and up and a coarse simulation's own rate can be lower (Chapter 9's is 3.1 kHz).
43Simulations produce sound at their own rate, 1/dt, which is only 44.1 kHz where the chapter makes the time step one sample on purpose (Chapters 2 and 3). The player hands the rate to Web Audio, which resamples.
45`Listen.tsx` keeps one `AudioContext` for the page, created on the first click because browsers refuse audio before a user gesture, and stops whatever is playing before starting anything else. An unstable run (Chapter 2, N = 201) is silenced from the moment it exceeds fifty times the exact signal, and every clip is clamped, so nothing can reach the speakers at full scale by accident.
47## Measuring frequency
49Every frequency in the tutorial comes from `wv_peakfreq`: a sin⁸ taper, the peak of a zero-padded FFT inside a band, and Newton's method on |X(f)|² with exact derivatives. On a clean partial it is good to about 1e-10 relative, so a measured pitch error is the method's and not the estimator's. Wherever a projection onto an exact eigenvector is available (the sine vectors in Chapters 2, 4 and 5), the driver measures that projection, which is a single sinusoid. Where it is not (Chapter 6), it projects onto the continuum mode; the taper's sidelobes are what keep the neighbouring modes out.
51The drivers never use a dispersion relation to measure anything. The relations appear only as predictions, and the tests check that measurement and prediction agree to 1e-7 cents.
53## Working with numbl
55**This tutorial was developed against numbl 0.4.21.** Several of the things below were found by profiling it, and the scripts are written around them.
57### Things that silently put a loop on the interpreter
59All of these made a loop run five to twenty times slower, with no error. Check with `NUMBL_JIT_LOG=1`, which prints every decline with its reason.
61- **Indexing a 1-by-1 variable**, as in `rec(r)` when `rec` happens to be a scalar. That is why `leapfrog.m` takes a single receiver index `jr` rather than a list.
62- **A variable assigned on only one branch of an `if` inside the loop**, including the index of a `for` loop that sits inside one branch. `images.m` therefore has two separate loop nests, one per way of placing an arrival, rather than one nest with a branch in it; initialising the variables first did not help.
63- **`beta ^ n` where `n` is not provably a whole number.** `images.m` reads the power from a table.
64- **`sqrt` and `log`**, which numbl types as possibly complex. Wrap them in `real()` inside a compiled loop, as `images.m` does, or keep them outside.
66### Three subscripts are slow; one is fast
68A three-dimensional stencil written as `U(i,j,c)` ran at about 1e7 updates per second; the same loop on a flat vector with neighbour offsets `q +- 1`, `q +- sy`, `q +- sz` ran at about 5e7. `room3d.m` is written the flat way. The two-dimensional scripts still use two subscripts, which run at about 3e7 and were fast enough; converting them is a possible speed-up that has not been measured.
70### Scalar loops beat vectorised slices here, as in laplace-dirichlet
72Every stencil is a written-out loop. The one-dimensional leapfrog step was six times faster as a loop than as a slice expression.
74### `jsonencode` flattens, and refuses complex values
76As in the sibling tutorials, anything matrix-shaped goes through `wv_rows` and any per-method vector through `wv_vec`. NaN and Inf cross as `null`, and the TypeScript side uses `Num = number | null`.
78### The browser is slower than the CLI, by up to two times
80The time per node per step in Chapter 9 is about 38 ns in the CLI and about 70 in Chrome. Nothing in the prose depends on the absolute numbers except Chapter 9's extrapolation, whose text quotes the CLI figure and says that the lab prints its own.
82## Measuring time honestly
84Timings are `tic`/`toc` inside the MATLAB layer around the method's call and nothing else; computing the exact answer, measuring frequencies and encoding sound are not charged. Where a timing is compared across methods (Chapter 5), the driver keeps the fastest of several runs while they are cheap, with the rule `rep < 20 && tot < 0.3 && (rep < 3 || tot < 0.05)`. Timings are never asserted as values in the tests, only as rough ratios or orderings, and the prose quotes them as "about".
86## Testing
88```
89npm run test:matlab # ~90 s: the .m layer through the numbl CLI
90npm run build && npm run test:browser [--shots] [--only <name>]
91npm run figures # tmp/figs/, one PNG per static figure
92```
94Every assertion in `test:matlab` is a number stated in the prose, checked to the precision it is quoted to. If one fails, either the code or the text is wrong, and the text is not allowed to win by default. `node scripts/matlab-test.mjs --only ch5` runs one chapter.
96`test:browser` opens every chapter, presses run on the labs that wait to be asked, and checks that nothing throws and that the figures contain data. It cannot judge a picture or a sound. `--shots` writes full-page screenshots into `tmp/`; `npm run figures` writes each static figure at the size it is drawn, which is the only way to catch a label that collides with something.
98## Static figures
100`src/figures/` holds diagrams drawn as inline SVG against a 716-unit viewBox, using `plot/palette.ts` for colour so that a series means the same thing in a diagram as in a plot. Every curve in them that looks computed is computed, from the same closed forms the chapters use (d'Alembert's solution, the dispersion relations). Text is placed by hand; look at `tmp/figs/` after changing one.
102## Plotting
104`src/plot/` is the sibling tutorials' SVG layer. `palette.ts` assigns the eight validated hexes to methods (`fd2`, `fd4`, `modal`, `waveguide`, `boundary`, `exact`, `alt`) and adds `familyColor`, shades of one hue for one method at several settings of a parameter. Two chapters borrow a slot on purpose and say so where they do: Chapter 5 gives fourth order in space only the waveguide's amber, and Chapter 7 gives the damping layer fd4's orange. `chapters/common/panels.tsx` holds `SeriesPlot`, which draws any list of series with markers, stems, guides, reference slopes, end labels and a hover read-out.
106## Deployment
108`.mochi/workflows/deploy.yml` runs on every push to `main`: `npm ci`, `npm run build`, then `upload-pages-artifact` and `deploy-pages`, which the vault substitutes with its own implementations. `actions/cache` is not implemented in the vault's CI, so `setup-node` must not be given `cache: npm`. A vault site is served sandboxed, in an opaque origin, so `localStorage` and friends throw; the app uses none. Web Audio is expected to work there after a click, and `test:site` checks that it does.
110## Things deliberately not done
112- **Stiff strings, and loss inside the finite-difference schemes.** Chapter 1's listening loss is applied to the modal sum only; the schemes are lossless so that their pitch errors can be measured exactly. A stiff string needs a fourth spatial derivative and changes the dispersion relation; Chapter 3 names the filters a waveguide would need and does not build them.
113- **Fourth-order boundary treatment for the disk.** Chapter 6 says that Chapter 5's schemes would gain nothing there without one, and stops.
114- **Small-cell remedies for Shortley–Weller.** Named in Chapter 6, not implemented.
115- **Frequency-dependent wall absorption and diffuse reflection.** Chapter 8's image model reflects the same fraction at every angle and frequency and says so.
116- **A PML in three dimensions, and an absorbing room in Chapter 9.** The 3-D room is rigid so that its exact solution is the image sum.
117- **Any Robin (impedance) boundary.** Defined in the introduction, used nowhere.