An interactive tutorial on the wave equation in acoustics: strings, drums and rooms, measured by ear
97 changed files+14827−0
.gitignoreadded+6−0View file
@@ -0,0 +1,6 @@
1+node_modules
2+dist
3+.DS_Store
4+*.local
5+tmp
6+__pycache__
.mochi/workflows/deploy.ymladded+42−0View file
@@ -0,0 +1,42 @@
1+# Build the page and publish it as this repository's site, on every push to
2+# main. The site lands at /tutorials/wave-acoustics/site/.
3+#
4+# Two mochi-specific points. `actions/cache` is not implemented, so
5+# `setup-node` must not be given `cache: npm`; the install is done cold every
6+# run. And the site is served from a sub-path rather than a domain root, which
7+# is why vite.config.ts sets `base: './'` — the build emits relative URLs and
8+# needs no base path passed in.
9+name: site
10+
11+on:
12+ push:
13+ branches: [main]
14+ workflow_dispatch:
15+
16+concurrency:
17+ group: site
18+ cancel-in-progress: false
19+
20+jobs:
21+ build:
22+ runs-on: ubuntu-latest
23+ steps:
24+ - uses: actions/checkout@v4
25+
26+ - uses: actions/setup-node@v4
27+ with:
28+ node-version: 22
29+
30+ # puppeteer is only used by scripts/browser-test.mjs, which does not run
31+ # here; skip the ~150 MB Chrome download
32+ - run: npm ci
33+ env:
34+ PUPPETEER_SKIP_DOWNLOAD: "true"
35+
36+ - run: npm run build
37+
38+ - uses: actions/upload-pages-artifact@v3
39+ with:
40+ path: dist
41+
42+ - uses: actions/deploy-pages@v4
CLAUDE.mdadded+117−0View file
@@ -0,0 +1,117 @@
1+# CLAUDE.md
2+
3+Notes for anyone, human or agent, working in this repository. Read [README.md](README.md) first for what the tutorial contains.
4+
5+## The shape of the thing
6+
7+A 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.
8+
9+```
10+src/
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+```
25+
26+A **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.
27+
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)` |
38+
39+## Sound
40+
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).
42+
43+Simulations 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.
44+
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.
46+
47+## Measuring frequency
48+
49+Every 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.
50+
51+The 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.
52+
53+## Working with numbl
54+
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.
56+
57+### Things that silently put a loop on the interpreter
58+
59+All 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.
60+
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.
65+
66+### Three subscripts are slow; one is fast
67+
68+A 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.
69+
70+### Scalar loops beat vectorised slices here, as in laplace-dirichlet
71+
72+Every stencil is a written-out loop. The one-dimensional leapfrog step was six times faster as a loop than as a slice expression.
73+
74+### `jsonencode` flattens, and refuses complex values
75+
76+As 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`.
77+
78+### The browser is slower than the CLI, by up to two times
79+
80+The 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.
81+
82+## Measuring time honestly
83+
84+Timings 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".
85+
86+## Testing
87+
88+```
89+npm run test:matlab # ~90 s: the .m layer through the numbl CLI
90+npm run build && npm run test:browser [--shots] [--only <name>]
91+npm run figures # tmp/figs/, one PNG per static figure
92+```
93+
94+Every 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.
95+
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.
97+
98+## Static figures
99+
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.
101+
102+## Plotting
103+
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.
105+
106+## Deployment
107+
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.
109+
110+## Things deliberately not done
111+
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.
README.mdadded+69−0View file
@@ -0,0 +1,69 @@
1+# Waves and sound: the wave equation, and what its errors sound like
2+
3+An interactive tutorial on the wave equation in acoustics, for strings, drums and rooms, with every method written as a MATLAB-syntax script that you can edit and run in the page, and almost every result playable as sound. It runs in your browser through [numbl](https://numbl.org); there is no server, nothing to install, and nothing is sent anywhere.
4+
5+The organising idea is the one shared with the sibling tutorials on the Dirichlet problem and on particle filters: every problem has an answer that is known exactly, so the error of every computation is known exactly too, and what is measured is what accuracy a method delivers for what cost. What is particular to this one is the unit of error. Numerical errors in acoustics are heard, so they are measured as a listener would notice them, as a pitch error in cents or a level in decibels, and each lab lets the reader listen to the exact answer, the computed one and, often, their difference.
6+
7+## Where the exact answers come from
8+
9+**Modes.** The string, the rectangle and the disk separate, and their standing waves are known in closed form: sines, and Bessel functions whose zeros the shared library computes to rounding error.
10+
11+**d'Alembert's solution.** For the string, a sum of two travelling waves that shares no algebra with the modal sum; Chapter 1 checks each against the other.
12+
13+**Images.** A rectangular room with rigid walls is exactly free space with a lattice of mirror-image sources (Chapters 8 and 9).
14+
15+**Causality.** An explicit scheme on a grid larger by M + 2 nodes on every side cannot feel its own edges for M steps, so inside the region of interest it is the scheme's exact free-space solution (Chapter 7).
16+
17+## The chapters
18+
19+**Introduction**: the acoustic wave equation and its boundary conditions, the four sources of exact answers, and why errors are measured in cents.
20+
21+**Strings**
22+
23+1. **Sound from an exact solution**: the plucked string by d'Alembert and by Fourier, the gaps a pluck leaves in the harmonic series, and the aliasing that sampling an exact, non-band-limited solution produces: from −26 dB at A2 to −11 dB at E7 for the bridge force, predicted in closed form.
24+2. **Leapfrog, and the magic time step**: the CFL limit, the Courant number 1 at which the scheme is exact to rounding error over 110,000 steps, the pitch of every partial measured and matching the dispersion relation to 10⁻⁷ cents, and the sound of numerical dispersion as a click travels round a loop of tube.
25+3. **The digital waveguide**: the exact scheme without its grid, several hundred times faster at 3200 nodes; Karplus–Strong, its f⁻³ decay law, and a tuning error of up to 47 cents that fractional delays reduce below one cent.
26+
27+**Drums**
28+
29+4. **A square drum**: an inharmonic spectrum, a stability limit of 1/√2, no magic time step, and a pitch error that depends on the direction of travel.
30+5. **Fewer points per wavelength**: fourth order in space only (a trap: second order in the end), and fourth order in space and time by the modified-equation method, about five times cheaper at 5 cents and fifteen at 1 cent.
31+6. **A round drum on a square grid**: Bessel modes and what they sound like, a staircase rim that is first-order and erratic, and a Shortley–Weller rim that is second-order at a price paid in time step.
32+
33+**Rooms**
34+
35+7. **Letting sound out**: a rigid wall, Mur's condition (−23 dB), a damping layer (−21 dB at 40 nodes) and a perfectly matched layer (−62 dB at 5 nodes, −107 at 40), against a reference exact by causality, and heard.
36+8. **A room, by image sources**: impulse responses, auralisation by convolution, and reverberation time measured against Sabine, Eyring and the lattice prediction that the image model implies, which is Eyring times a room-shape factor of 1.17.
37+9. **A room, by finite differences**: 3-D leapfrog converging at second order to the exact image sum for rigid walls, and the fourth-power cost law that puts one second of full-band sound in an 81 m³ room at about seven years of computation.
38+
39+A **shared MATLAB library** page lists the function files every lab boots with.
40+
41+## Using it
42+
43+Each lab has controls above its figures and, behind the "show script" button, the method itself. Edit the script and press run: the session reboots with your version compiled in, and the figures and sounds are whatever your code produces. Ctrl-Enter (Cmd-Enter) runs from inside the editor, and "reset" restores the original. Some labs run as soon as the chapter opens; the heavier ones wait for the run button, which the caption says. Start listening with the volume low.
44+
45+## Requirements
46+
47+numbl **0.4.21 or later** (the version this was developed and tested against). See [CLAUDE.md](CLAUDE.md) for the numbl performance rules the scripts are written around.
48+
49+## Development
50+
51+```
52+npm install
53+npm run dev # vite dev server
54+npm run build # typecheck and bundle to dist/
55+npm run test:matlab # the .m layer, through the numbl CLI (needs a numbl checkout); about 90 s
56+npm run test:browser # the built app in headless Chrome
57+npm run figures # one screenshot per static figure, into tmp/figs/
58+npm run test:site # the deployed site, in headless Chrome
59+```
60+
61+`test:matlab` needs a numbl clone; set `NUMBL_DIR` if it is not at `~/src/numbl`, and build its CLI (`npm run build:cli` in that checkout) after updating it. Every assertion in it is a number claimed somewhere in the prose, so a failure means either the code or the text is wrong. `test:browser` needs `npm run build` first, and takes `--shots` to write a full-page screenshot of each chapter into `tmp/`, and `--only <name>` to run one chapter.
62+
63+## Deployment
64+
65+`.mochi/workflows/deploy.yml` builds the page on every push to `main` and publishes it as the repository's site on the vault, at `/tutorials/wave-acoustics/site/`. The build is a plain `npm ci && npm run build`; `vite.config.ts` uses a relative `base`, so the sub-path needs no configuration. The vault serves sites sandboxed, which makes `localStorage` throw rather than degrade: nothing here touches it. `npm run test:site` loads the deployed page, checks that numbl runs there and that the audio player starts, which serving the build locally cannot tell you.
66+
67+## Licence
68+
69+Apache-2.0.
index.htmladded+17−0View file
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <meta
8+ name="description"
9+ content="An interactive tutorial on the wave equation in acoustics: plucked strings, drums and rooms, solved by modal synthesis, finite differences, digital waveguides and image sources, measured against exact solutions for accuracy and cost, and played back so that each error can be heard. Every method is an editable MATLAB-syntax script that runs in your browser."
10+ />
11+ <title>Waves and sound: the wave equation, and what its errors sound like</title>
12+ </head>
13+ <body>
14+ <div id="root"></div>
15+ <script type="module" src="/src/main.tsx"></script>
16+ </body>
17+</html>
package-lock.jsonadded+4952−0View file
This diff is 4,957 lines long and is not shown.
package.jsonadded+40−0View file
@@ -0,0 +1,40 @@
1+{
2+ "name": "wave-acoustics",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "An interactive tutorial on the wave equation in acoustics: strings, drums and rooms, with every method written as an editable MATLAB-syntax script that runs in the browser through numbl, measured against an exact solution, and played back as sound",
7+ "license": "Apache-2.0",
8+ "scripts": {
9+ "dev": "vite",
10+ "build": "tsc -b && vite build",
11+ "preview": "vite preview",
12+ "typecheck": "tsc -b",
13+ "test:matlab": "node scripts/matlab-test.mjs",
14+ "test:browser": "node scripts/browser-test.mjs",
15+ "test:site": "node scripts/site-check.mjs",
16+ "figures": "node scripts/figure-shots.mjs"
17+ },
18+ "dependencies": {
19+ "@codemirror/language": "^6.11.3",
20+ "@codemirror/legacy-modes": "^6.5.2",
21+ "@codemirror/state": "^6.5.2",
22+ "@codemirror/theme-one-dark": "^6.1.3",
23+ "@codemirror/view": "^6.38.6",
24+ "codemirror": "^6.0.2",
25+ "katex": "^0.16.11",
26+ "marked": "^15.0.0",
27+ "numbl": "^0.4.21",
28+ "react": "^19.2.0",
29+ "react-dom": "^19.2.0"
30+ },
31+ "devDependencies": {
32+ "@types/katex": "^0.16.7",
33+ "@types/react": "^19.2.5",
34+ "@types/react-dom": "^19.2.3",
35+ "@vitejs/plugin-react": "^5.1.1",
36+ "puppeteer": "^24.0.0",
37+ "typescript": "~5.9.3",
38+ "vite": "^7.2.4"
39+ }
40+}
public/favicon.svgadded+7−0View file
@@ -0,0 +1,7 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+ <rect width="64" height="64" rx="12" fill="#1a1a19" />
3+ <!-- the exact standing wave -->
4+ <path d="M8 32 C16 14 24 14 32 32 C40 50 48 50 56 32" fill="none" stroke="#3c3c38" stroke-width="3" stroke-linecap="round" />
5+ <!-- a slightly mistuned copy of it: the error being listened for -->
6+ <path d="M8 32 C17 12 25 13 33 32 C41 51 49 51 56 30" fill="none" stroke="#3987e5" stroke-width="3" stroke-linecap="round" />
7+</svg>
scripts/browser-test.mjsadded+167−0View file
@@ -0,0 +1,167 @@
1+#!/usr/bin/env node
2+// Drives the built app in a headless browser: every chapter is opened, every
3+// lab is run, and the page is checked for console errors and for figures that
4+// actually contain data.
5+//
6+// Several labs here are deliberately heavy -- a dimension sweep, a pair of
7+// MCMC chains -- so the settle timeout is generous and a full pass takes a few
8+// minutes.
9+//
10+// npm run build && node scripts/browser-test.mjs [--shots] [--only <id>]
11+//
12+// --shots writes a full-page screenshot per chapter to tmp/, which is the only
13+// way to judge layout; the assertions here cover behaviour, not appearance.
14+import { spawn } from 'node:child_process'
15+import { mkdirSync } from 'node:fs'
16+import { dirname, join } from 'node:path'
17+import { fileURLToPath } from 'node:url'
18+import puppeteer from 'puppeteer'
19+
20+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21+const PORT = 5198
22+const URL = `http://localhost:${PORT}/`
23+const SHOTS = process.argv.includes('--shots')
24+const ONLY = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : null
25+
26+let failures = 0
27+const check = (name, ok, detail = '') => {
28+ console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`)
29+ if (!ok) failures++
30+}
31+
32+const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], {
33+ cwd: root,
34+ stdio: ['ignore', 'pipe', 'pipe'],
35+ detached: true,
36+})
37+let stopped = false
38+const stop = () => {
39+ if (stopped) return
40+ stopped = true
41+ try {
42+ process.kill(-server.pid, 'SIGKILL')
43+ } catch {
44+ /* already gone */
45+ }
46+}
47+process.on('exit', stop)
48+process.on('SIGINT', () => {
49+ stop()
50+ process.exit(130)
51+})
52+
53+{
54+ const deadline = Date.now() + 30000
55+ let up = false
56+ while (Date.now() < deadline) {
57+ try {
58+ const res = await fetch(URL)
59+ if (res.ok) {
60+ up = true
61+ break
62+ }
63+ } catch {
64+ /* not listening yet */
65+ }
66+ await new Promise((r) => setTimeout(r, 250))
67+ }
68+ if (!up) {
69+ stop()
70+ throw new Error(`vite preview never answered on ${URL}`)
71+ }
72+}
73+
74+mkdirSync(join(root, 'tmp'), { recursive: true })
75+const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
76+const page = await browser.newPage()
77+await page.setViewport({ width: 1400, height: 1100 })
78+
79+const problems = []
80+// numbl announces which LAPACK bridge it picked on console.error; that is
81+// information, not a fault
82+const BENIGN = /using bridge:/
83+page.on('console', (m) => {
84+ if (m.type() === 'error' && !BENIGN.test(m.text())) problems.push(`console.error: ${m.text()}`)
85+})
86+page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`))
87+page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`))
88+
89+const settle = (timeout = 420000) =>
90+ page.waitForFunction(
91+ () => {
92+ const labs = [...document.querySelectorAll('.lab')]
93+ if (labs.length === 0) return document.querySelector('.chapter') !== null
94+ return labs.every((l) => {
95+ const busy = [...l.querySelectorAll('button')].some((b) => /running/.test(b.textContent))
96+ return !busy && !l.querySelector('.lab-placeholder')
97+ })
98+ },
99+ { timeout, polling: 500 },
100+ )
101+
102+try {
103+ await page.goto(URL, { waitUntil: 'networkidle2' })
104+ const chapters = await page.evaluate(() =>
105+ [...document.querySelectorAll('.nav-item')].map((b) => b.textContent.replace(/^\d+/, '').trim()),
106+ )
107+ check('the sidebar lists chapters', chapters.length > 1, `${chapters.length} entries`)
108+
109+ for (let i = 0; i < chapters.length; i++) {
110+ const name = chapters[i]
111+ if (ONLY && !name.toLowerCase().includes(ONLY.toLowerCase())) continue
112+ console.log(`\n${name}`)
113+ await page.evaluate((k) => {
114+ document.querySelectorAll('.nav-item')[k].click()
115+ }, i)
116+ await new Promise((r) => setTimeout(r, 300))
117+ // labs that wait to be asked: ask them
118+ await page.evaluate(() => {
119+ for (const lab of document.querySelectorAll('.lab')) {
120+ if (!lab.querySelector('.lab-placeholder')) continue
121+ const b = [...lab.querySelectorAll('button')].find((x) => /^run$/.test(x.textContent.trim()))
122+ b?.click()
123+ }
124+ })
125+ await new Promise((r) => setTimeout(r, 300))
126+ try {
127+ await settle()
128+ } catch {
129+ check(`${name}: labs finish`, false, 'timed out')
130+ continue
131+ }
132+ const info = await page.evaluate(() => {
133+ const labs = [...document.querySelectorAll('.lab')]
134+ return {
135+ labs: labs.length,
136+ errors: [...document.querySelectorAll('.lab-error')].map((e) => e.textContent.slice(0, 300)),
137+ drawn: [...document.querySelectorAll('.lab-figures svg path')].filter(
138+ (p) => (p.getAttribute('d') ?? '').length > 12,
139+ ).length,
140+ canvases: document.querySelectorAll('.lab-figures canvas').length,
141+ stats: [...document.querySelectorAll('.stat-value')].map((s) => s.textContent),
142+ }
143+ })
144+ check(`${name}: no MATLAB error`, info.errors.length === 0, info.errors.join(' | '))
145+ if (info.labs > 0) {
146+ check(
147+ `${name}: figures carry data`,
148+ info.drawn + info.canvases > 0,
149+ `${info.drawn} paths, ${info.canvases} canvases`,
150+ )
151+ }
152+ if (info.labs > 0) console.log(` ${info.stats.join(' ')}`)
153+ if (SHOTS) {
154+ const file = join(root, 'tmp', `ch-${String(i + 1).padStart(2, '0')}.png`)
155+ await page.screenshot({ path: file, fullPage: true })
156+ console.log(` shot: ${file}`)
157+ }
158+ }
159+
160+ check('no console errors', problems.length === 0, problems.slice(0, 4).join(' | '))
161+} finally {
162+ await browser.close()
163+ stop()
164+}
165+
166+console.log(failures === 0 ? '\nall checks passed' : `\n${failures} check(s) failed`)
167+process.exit(failures === 0 ? 0 : 1)
scripts/figure-shots.mjsadded+35−0View file
@@ -0,0 +1,35 @@
1+#!/usr/bin/env node
2+// Screenshots every static figure on its own, into tmp/figs/.
3+//
4+// npm run build && node scripts/figure-shots.mjs
5+//
6+// The full-page shots from browser-test.mjs --shots are too coarse to judge a
7+// diagram by: text in these figures is placed by hand, so a label that fits at
8+// one width collides at another, and the only way to know is to look at each
9+// one at the size it is drawn.
10+import { spawn } from 'node:child_process'
11+import { mkdirSync } from 'node:fs'
12+import { dirname, join } from 'node:path'
13+import { fileURLToPath } from 'node:url'
14+import puppeteer from 'puppeteer'
15+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
16+const PORT = 5199
17+const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], { cwd: root, stdio: 'ignore', detached: true })
18+const stop = () => { try { process.kill(-server.pid, 'SIGKILL') } catch {} }
19+process.on('exit', stop)
20+const URL = `http://localhost:${PORT}/`
21+for (let i = 0; i < 120; i++) { try { const r = await fetch(URL); if (r.ok) break } catch {} await new Promise(r => setTimeout(r, 250)) }
22+mkdirSync(`${root}/tmp/figs`, { recursive: true })
23+const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
24+const page = await browser.newPage()
25+await page.setViewport({ width: 1000, height: 900 })
26+const chapters = ['introduction','exact','leapfrog','waveguide','square','order','round','open','images','fdtd']
27+let k = 0
28+for (const c of chapters) {
29+ await page.goto(`${URL}#/${c}`, { waitUntil: 'networkidle2' })
30+ await new Promise(r => setTimeout(r, 700))
31+ const figs = await page.$$('figure.static-figure')
32+ for (const f of figs) { await f.screenshot({ path: `${root}/tmp/figs/${String(++k).padStart(2,'0')}-${c}.png` }) }
33+}
34+await browser.close(); stop()
35+console.log('done', k)
scripts/matlab-test.mjsadded+326−0View file
@@ -0,0 +1,326 @@
1+#!/usr/bin/env node
2+// Checks the MATLAB layer against the numbers the chapters claim, through the
3+// numbl CLI, with no browser involved.
4+//
5+// npm run test:matlab (NUMBL_DIR defaults to ~/src/numbl)
6+//
7+// Every assertion here is a statement made somewhere in the prose, so if one
8+// of these fails either the code or the text is wrong, and the text is not
9+// allowed to win by default.
10+//
11+// Almost everything measured in this tutorial is deterministic, so most
12+// tolerances are tight: a quoted number is checked to the precision it is
13+// quoted to. The exceptions are wall-clock times, which are never asserted
14+// as values, only as orderings, and the reverberation times of Chapter 8,
15+// which depend on one source-receiver pair and are quoted to two figures.
16+import { join, dirname } from 'node:path'
17+import { fileURLToPath } from 'node:url'
18+import { runLab } from './runlab.mjs'
19+
20+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21+const src = (p) => join(root, 'src', 'chapters', p)
22+const ONLY = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : null
23+
24+let failures = 0
25+const check = (name, ok, detail = '') => {
26+ console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`)
27+ if (!ok) failures++
28+}
29+const near = (a, b, tol) => Number.isFinite(a) && Math.abs(a - b) <= tol
30+/** within `frac` of b, relatively */
31+const rel = (a, b, frac) => Number.isFinite(a) && Math.abs(a - b) <= frac * Math.abs(b)
32+const note = (m) => 440 * 2 ** ((m - 69) / 12)
33+const maxAbs = (a) => Math.max(...a.filter((v) => v != null).map(Math.abs))
34+const section = (id, title) => {
35+ if (ONLY && ONLY !== id) return false
36+ console.log(`\n${title}`)
37+ return true
38+}
39+
40+// ---------------------------------------------------------------- Chapter 1
41+if (section('ch1', 'Chapter 1: an exact string, and aliasing')) {
42+ const D = src('ch01/driver.m')
43+ const S = src('ch01/pluck.m')
44+ const pl = (where) =>
45+ runLab(D, S, { mode: 'pluck', fs: 44100, f0: 110, beta: 0.2, where, xp: 0.12, T: 2.5, T60: 3, fmax: 6000 }).out
46+ const b = pl('bridge')
47+ check('200 partials below Nyquist at 110 Hz', b.nyq === 200, String(b.nyq))
48+ check('12 of the first 60 missing at beta = 0.2', b.missing === 12, String(b.missing))
49+ check('band-limited vs dAlembert, bridge: 5.0e-2', rel(b.reldiff, 0.05, 0.02), b.reldiff.toExponential(3))
50+ const p = pl('pickup')
51+ check('band-limited vs dAlembert, pickup: 3.5e-4', rel(p.reldiff, 3.5e-4, 0.03), p.reldiff.toExponential(3))
52+ const table = {
53+ bridge: { 45: -26.0, 57: -22.0, 69: -18.9, 81: -15.9, 93: -12.7, 100: -11.4 },
54+ pickup: { 45: -69.2, 57: -60.0, 69: -51.0, 81: -42.0, 93: -32.0, 100: -28.6 },
55+ }
56+ for (const where of ['bridge', 'pickup']) {
57+ for (const [m, want] of Object.entries(table[where])) {
58+ const { out } = runLab(D, S, { mode: 'alias', fs: 44100, f0: note(Number(m)), beta: 0.2, where, xp: 0.12, T: 1.5, T60: 1.2 })
59+ check(`${where}, note ${m}: folded energy ${want} dB`, near(out.measured, want, 0.06), out.measured.toFixed(2))
60+ if (Number(m) !== 45) {
61+ check(` and the prediction agrees to 0.03 dB`, near(out.measured, out.predicted, 0.035), (out.measured - out.predicted).toFixed(3))
62+ } else {
63+ check(` and at A2 they differ by about 1 dB (bridge) or less`, Math.abs(out.measured - out.predicted) < 1.2, (out.measured - out.predicted).toFixed(2))
64+ }
65+ }
66+ }
67+}
68+
69+// ---------------------------------------------------------------- Chapter 2
70+if (section('ch2', 'Chapter 2: leapfrog and the magic time step')) {
71+ const D = src('ch02/driver.m')
72+ const S = src('ch02/leapfrog.m')
73+ const run = (N) => runLab(D, S, { mode: 'run', fs: 44100, f0: 44100 / 400, N, T: 2.5, where: 'bridge', beta: 0.2, xp: 0.12 }).out
74+ const r200 = run(200)
75+ check('N = 200 is exact to ~1e-12', r200.relerr < 1e-11 && r200.relerr > 1e-14, r200.relerr.toExponential(2))
76+ const r100 = run(100)
77+ check('N = 100: relative error 0.72', near(r100.relerr, 0.72, 0.005), r100.relerr.toFixed(3))
78+ check('N = 100: partial 1 is 0.05 cents flat', near(r100.pred1, -0.053, 0.002), r100.pred1.toFixed(4))
79+ check('N = 100: partial 10 is 5.4 cents flat', near(r100.pred10, -5.36, 0.01), r100.pred10.toFixed(3))
80+ const r50 = run(50)
81+ check('N = 50: error 1.04, partial 10 is 27 cents flat', near(r50.relerr, 1.04, 0.01) && near(r50.pred10, -26.8, 0.1),
82+ `${r50.relerr.toFixed(3)}, ${r50.pred10.toFixed(2)}`)
83+ const r201 = run(201)
84+ check('N = 201 blows up after 69 steps, 1.6 ms', r201.unstable && near(r201.blowup, (69 / 44100) * 1000, 1e-9), String(r201.blowup))
85+
86+ const { out: c } = runLab(D, S, { mode: 'cents', f0: 44100 / 400, N: 100, lams: [0.25, 0.5, 0.75, 0.9, 1] })
87+ check('measured and predicted cents agree below 1e-7', c.agree < 1e-7, c.agree.toExponential(2))
88+ check('at lambda = 1 every error is below 1e-7 cents', maxAbs(c.meas[4]) < 1e-7, maxAbs(c.meas[4]).toExponential(2))
89+ // where each predicted curve crosses -5 cents, in points per wavelength
90+ const cross = (k) => {
91+ const pred = c.pred[k]
92+ for (let n = 1; n < pred.length; n++) if (pred[n] < -5) return (2 * c.N) / (n + 0.5)
93+ return NaN
94+ }
95+ for (const [k, want] of [[0, 23], [1, 21], [2, 16], [3, 11]]) {
96+ check(`5 cents at lambda = ${c.lams[k]} needs about ${want} points per wavelength`, near(cross(k), want, 1.2), cross(k).toFixed(1))
97+ }
98+ check('near two points per wavelength the error is several hundred cents',
99+ c.pred[1][c.pred[1].length - 1] < -300, c.pred[1][c.pred[1].length - 1].toFixed(0))
100+
101+ const click = (lam) => runLab(D, S, { mode: 'click', fs: 44100, c: 343, L: 17.15, lam, T: 1.2, sigt: 3e-5 }).out
102+ const k1 = click(1)
103+ check('the click at lambda = 1 is exact to ~3e-11', k1.rel < 1e-10, k1.rel.toExponential(2))
104+ const k5 = click(0.5)
105+ check('at lambda = 0.5 the grid carries up to 7.35 kHz', near(k5.fcut, 7350, 10), k5.fcut.toFixed(0))
106+ check('and the waveform error is 1.7', near(k5.rel, 1.7, 0.05), k5.rel.toFixed(3))
107+ const k25 = click(0.25)
108+ check('at lambda = 0.25, up to 3.55 kHz', near(k25.fcut, 3550, 10), k25.fcut.toFixed(0))
109+ // the group velocity quoted in the text
110+ const w = 2 * Math.PI * 5000 / 44100
111+ const kh = 2 * Math.asin(Math.sin(w / 2) / 0.5)
112+ const vg = Math.cos(kh / 2) / Math.cos(w / 2)
113+ check('group velocity at 5 kHz is about three quarters of c', near(vg, 0.765, 0.01), vg.toFixed(3))
114+ check('so after 334 m it is about 0.3 s late', near((334 / 343) * (1 / vg - 1), 0.3, 0.01), ((334 / 343) * (1 / vg - 1)).toFixed(3))
115+}
116+
117+// ---------------------------------------------------------------- Chapter 3
118+if (section('ch3', 'Chapter 3: the digital waveguide')) {
119+ const D = src('ch03/driver.m')
120+ const S = src('ch03/ks.m')
121+ const { out: e } = runLab(D, S, { mode: 'equiv', fs: 44100, T: 1, beta: 0.2, Ns: [25, 100, 400, 1600, 3200] })
122+ check('the loop and leapfrog agree to ~1e-11', Math.max(...e.diff) < 1e-10, Math.max(...e.diff).toExponential(2))
123+ check('leapfrog time grows with N; the loop does not',
124+ e.tl[4] > 10 * e.tl[1] && e.tw[4] < 5 * Math.max(e.tw[1], 0.5), `${e.tl.map((v) => v.toFixed(0))} / ${e.tw.map((v) => v.toFixed(1))}`)
125+ check('at N = 3200 the loop is several hundred times faster', e.tl[4] / e.tw[4] > 200, (e.tl[4] / e.tw[4]).toFixed(0))
126+
127+ const notes = Array.from({ length: 25 }, (_, i) => 36 + 3 * i)
128+ const { out: k } = runLab(D, S, { mode: 'keys', fs: 44100, beta: 0.2, notes, Tn: 0.5, scale: [84, 86], step: 0.22, ring: 0.9 })
129+ const worst = (row) => row.reduce((m, v) => (Math.abs(v) > Math.abs(m) ? v : m), 0)
130+ const lowNotes = notes.map((m, i) => (note(m) < 262 ? Math.abs(k.cents[0][i]) : 0))
131+ check('whole-sample loop within 2 cents up to about 260 Hz', Math.max(...lowNotes) < 2, Math.max(...lowNotes).toFixed(2))
132+ const iw = k.cents[0].findIndex((v) => v === worst(k.cents[0]))
133+ check('whole-sample loop worst 47 cents, at F#7', near(worst(k.cents[0]), 46.9, 0.2) && notes[iw] === 102, `${worst(k.cents[0]).toFixed(2)} at ${notes[iw]}`)
134+ check('linear interpolation worst 0.14 cents', near(Math.abs(worst(k.cents[1])), 0.14, 0.01), worst(k.cents[1]).toFixed(3))
135+ check('allpass worst 0.63 cents', near(Math.abs(worst(k.cents[2])), 0.63, 0.01), worst(k.cents[2]).toFixed(3))
136+ const t60 = (m) => k.t60[0][notes.indexOf(m)]
137+ check('C2 rings about 9700 s', rel(t60(36), 9740, 0.02), t60(36).toFixed(0))
138+ check('A4 32 s, C6 2.4 s, C8 36 ms', rel(t60(69), 32.2, 0.02) && rel(t60(84), 2.43, 0.02) && rel(t60(108), 0.0362, 0.03),
139+ `${t60(69).toFixed(1)}, ${t60(84).toFixed(2)}, ${t60(108).toFixed(4)}`)
140+ const pred = (f) => -3 / (f * Math.log10(Math.cos((Math.PI * f) / 44100)))
141+ check('and each agrees with the loop-gain formula to a few per cent', rel(t60(69), pred(note(69)), 0.02) && rel(t60(108), pred(note(108)), 0.05),
142+ `${pred(note(69)).toFixed(1)}, ${pred(note(108)).toFixed(4)}`)
143+ const ratios = k.t60[1].map((v, i) => v / k.t60[0][i])
144+ check('linear interpolation cuts decay time by up to about a half', Math.min(...ratios) > 0.44 && Math.min(...ratios) < 0.52, Math.min(...ratios).toFixed(2))
145+}
146+
147+// ------------------------------------------------------------ Chapters 4, 5
148+const MEM = src('membrane/driver.m')
149+const DRUM = { c: 150 * Math.SQRT2, kmax: 90, width: 0.03, xr: 0.77, yr: 0.63, fmax: 3000, T: 1.0, xs: 0.31, ys: 0.42 }
150+if (section('ch4', 'Chapter 4: a square drum')) {
151+ const S = src('ch04/drum.m')
152+ const st = (N, lam) => runLab(MEM, S, { mode: 'strike', ...DRUM, N, lam, scheme: 'fd2' }).out
153+ const s48 = st(48, 0.7)
154+ check('N = 48: 14.5 kHz, error 0.59, about 2600 modes', near(s48.fs, 14546, 1) && near(s48.rel, 0.59, 0.005) && Math.abs(s48.nmodes - 2600) < 50,
155+ `${s48.fs.toFixed(0)}, ${s48.rel.toFixed(3)}, ${s48.nmodes}`)
156+ check('N = 64: 0.54; N = 24: 0.80', near(st(64, 0.7).rel, 0.54, 0.006) && near(st(24, 0.7).rel, 0.80, 0.006))
157+ const { out: c } = runLab(MEM, S, { mode: 'cents', N: 64, lams: [0.3, 0.5, Math.SQRT1_2], rs: [8, 16, 32], nang: 9 })
158+ check('measured and predicted agree to better than 1e-6 cents', c.agree < 1e-6, c.agree.toExponential(2))
159+ const at = (m, n, k) => c.meas[k][c.m.findIndex((v, i) => v === m && c.n[i] === n)]
160+ check('16 ppw, axis: -5.4 at 1/sqrt2, -10.0 at 0.3', near(at(8, 1, 2), -5.35, 0.06) && near(at(8, 1, 0), -9.96, 0.06),
161+ `${at(8, 1, 2).toFixed(2)}, ${at(8, 1, 0).toFixed(2)}`)
162+ check('16 ppw, diagonal: 0 and -5.1', near(at(6, 6, 2), 0, 1e-6) && near(at(6, 6, 0), -5.14, 0.06), `${at(6, 6, 2).toExponential(1)}, ${at(6, 6, 0).toFixed(2)}`)
163+ check('8 ppw axis -23, 4 ppw axis -101', near(at(16, 1, 2), -22.7, 0.2) && near(at(32, 2, 2), -100.7, 0.5), `${at(16, 1, 2).toFixed(1)}, ${at(32, 2, 2).toFixed(1)}`)
164+ // the leading-order formula, -2847 (cos^4 + sin^4 - lam^2) / P^2, at each
165+ // mode's own angle and wavelength; the spread between an axis mode and a
166+ // diagonal one then comes out the same at both Courant numbers
167+ const lead = (m, n, lam) => {
168+ const th = Math.atan2(n, m)
169+ const P = (2 * 64) / Math.hypot(m, n)
170+ return (-2847 * (Math.cos(th) ** 4 + Math.sin(th) ** 4 - lam * lam)) / (P * P)
171+ }
172+ for (const [k, lam] of [[0, 0.3], [2, Math.SQRT1_2]]) {
173+ const got = at(6, 6, k) - at(8, 1, k)
174+ const want = lead(6, 6, lam) - lead(8, 1, lam)
175+ check(`the direction spread at lambda = ${lam.toFixed(3)} follows the leading-order formula`, rel(got, want, 0.03), `${got.toFixed(2)} vs ${want.toFixed(2)}`)
176+ }
177+ const P5 = Math.sqrt((2847 * 0.5) / 5)
178+ check('5 cents along the axes needs about 17 points per wavelength', near(P5, 17, 0.5), P5.toFixed(1))
179+}
180+
181+if (section('ch5', 'Chapter 5: fewer points per wavelength')) {
182+ const S = src('ch05/drum.m')
183+ const { out: o } = runLab(MEM, S, {
184+ mode: 'orders', Ns: [16, 24, 32, 48, 64, 96], schemes: ['fd2', 'fd4', 'fd44'], lams: [0.7, 0.6, 0.7], modes: [4, 3, 9, 2, 14, 1],
185+ })
186+ const w = o.worst
187+ const last = 5
188+ check('second order: 409 cents at 16, 7.7 at 96, order 2.03', near(w[0][0], 409, 1) && near(w[0][last], 7.69, 0.02) && near(o.orders[0][last], 2.03, 0.02),
189+ `${w[0][0].toFixed(0)}, ${w[0][last].toFixed(2)}, ${o.orders[0][last].toFixed(3)}`)
190+ check('fourth order in both: 245 to 0.19, order 4.0', near(w[2][0], 245, 1) && near(w[2][last], 0.187, 0.003) && o.orders[2].slice(1).every((q) => near(q, 4.0, 0.1)),
191+ `${w[2][0].toFixed(0)}, ${w[2][last].toFixed(3)}, ${o.orders[2].slice(1).map((q) => q.toFixed(2))}`)
192+ check('fourth order in both at 48: 3.0 cents, below any second-order grid', near(w[2][3], 2.97, 0.03) && Math.min(...w[0]) > 3,
193+ w[2][3].toFixed(2))
194+ check('space only: 23 cents at 24 against 146; 5.1 at 96', near(w[1][1], 23.3, 0.2) && near(w[0][1], 146, 1) && near(w[1][last], 5.10, 0.03),
195+ `${w[1][1].toFixed(1)}, ${w[0][1].toFixed(0)}, ${w[1][last].toFixed(2)}`)
196+ check('space only ends near second order', near(o.orders[1][last], 1.77, 0.05), o.orders[1][last].toFixed(2))
197+ const nm = 3
198+ check('at 96 the lowest mode is 0.7 cents sharp (space only), 0.1 flat (second order)',
199+ near(o.cents[1 * nm][last], 0.69, 0.02) && near(o.cents[0][last], -0.095, 0.005), `${o.cents[nm][last].toFixed(3)}, ${o.cents[0][last].toFixed(3)}`)
200+ check('fourth order in both costs about 4.5 times as much per step', o.ms[2][last] / o.ms[0][last] > 3 && o.ms[2][last] / o.ms[0][last] < 6.5,
201+ (o.ms[2][last] / o.ms[0][last]).toFixed(2))
202+ check('space only costs about 2.5 times the plain scheme at 96', o.ms[1][last] / o.ms[0][last] > 1.8 && o.ms[1][last] / o.ms[0][last] < 3.5,
203+ (o.ms[1][last] / o.ms[0][last]).toFixed(2))
204+ const st = (s, N) => runLab(MEM, S, { mode: 'strike', ...DRUM, N, lam: { fd2: 0.7, fd4: 0.6, fd44: 0.7 }[s], scheme: s }).out
205+ const a = st('fd2', 32).rel
206+ const b = st('fd4', 32).rel
207+ const cc = st('fd44', 32).rel
208+ check('N = 32: 0.69, 0.81, 0.37', near(a, 0.69, 0.006) && near(b, 0.81, 0.006) && near(cc, 0.37, 0.006), `${a.toFixed(3)} ${b.toFixed(3)} ${cc.toFixed(3)}`)
209+ const d24 = st('fd44', 24)
210+ const f48 = st('fd2', 48)
211+ check('fourth order at 24 beats second order at 48, for less work', near(d24.rel, 0.46, 0.006) && near(f48.rel, 0.59, 0.006) && d24.ms < f48.ms,
212+ `${d24.rel.toFixed(3)} in ${d24.ms.toFixed(0)} ms, ${f48.rel.toFixed(3)} in ${f48.ms.toFixed(0)} ms`)
213+}
214+
215+// ---------------------------------------------------------------- Chapter 6
216+if (section('ch6', 'Chapter 6: a round drum')) {
217+ const D = src('ch06/driver.m')
218+ const S = src('ch06/disk.m')
219+ const { out: h } = runLab(D, S, {
220+ mode: 'hear', fs: 22050, f01: 150, T: 1.5, width: 0.03, mmax: 40, kmax: 14, fmax: 4000, r0: 0.4, rr: 0.55, thr: 2.1, tau: 0.5,
221+ })
222+ const want = [1, 1.593, 2.136, 2.295, 2.653, 2.917]
223+ check('the lowest ratios are 1, 1.593, 2.136, 2.295, 2.653, 2.917', want.every((v, i) => near(h.tratio[i], v, 0.0006)), h.tratio.slice(0, 6).map((v) => v.toFixed(4)).join(' '))
224+ const { out: c } = runLab(D, S, { mode: 'hear', fs: 22050, f01: 150, T: 0.2, width: 0.03, mmax: 40, kmax: 14, fmax: 4000, r0: 0, rr: 0.55, thr: 2.1, tau: 0.5 })
225+ check('struck at the centre, only m = 0 sounds', c.m.every((m) => m === 0), `${c.m.length} partials`)
226+ check('at ratios 1, 2.295, 3.598, 4.903', [1, 2.295, 3.598, 4.903].every((v, i) => near(c.ratio[i], v, 0.001)), c.ratio.slice(0, 4).map((v) => v.toFixed(3)).join(' '))
227+
228+ const { out: g } = runLab(D, S, { mode: 'grid', Ns: [16, 24, 32, 40, 48, 64], methods: ['staircase', 'sw'], modes: [0, 1, 1, 1], periods: 10 })
229+ const st = g.worst[0]
230+ const sw = g.worst[1]
231+ check('staircase: 54 cents at 16, 17 at 64', near(st[0], 53.9, 0.3) && near(st[5], 17.2, 0.2), `${st[0].toFixed(1)}, ${st[5].toFixed(1)}`)
232+ check('staircase: fitted order about 0.8, and it rises from 24 to 32', near(Math.log(st[0] / st[5]) / Math.log(4), 0.82, 0.05) && st[2] > st[1],
233+ (Math.log(st[0] / st[5]) / Math.log(4)).toFixed(2))
234+ check('staircase errors are all flat', g.cents.slice(0, 2).flat().every((v) => v < 0))
235+ check('Shortley-Weller: 7.4 at 16, 0.69 at 64, order 1.7', near(sw[0], 7.43, 0.05) && near(sw[5], 0.688, 0.01) && near(Math.log(sw[0] / sw[5]) / Math.log(4), 1.72, 0.03),
236+ `${sw[0].toFixed(2)}, ${sw[5].toFixed(3)}`)
237+ const ratio = st.map((v, i) => v / sw[i])
238+ check('better by a factor of 7 to 25 on every grid', Math.min(...ratio) > 7 && Math.max(...ratio) < 26, `${Math.min(...ratio).toFixed(1)} to ${Math.max(...ratio).toFixed(1)}`)
239+ check('below 5 cents by N = 24', sw[1] < 5)
240+ check('its Courant number ranges 0.26 to 0.62; the staircase keeps 0.67',
241+ near(Math.min(...g.lam[1]), 0.257, 0.005) && near(Math.max(...g.lam[1]), 0.615, 0.005) && g.lam[0].every((l) => near(l, 0.672, 0.001)))
242+ check('Shortley-Weller at 16 beats the staircase at 64 at a small fraction of the time', sw[0] < st[5] && g.ms[1][0] < g.ms[0][5] / 15,
243+ `${g.ms[1][0].toFixed(0)} ms against ${g.ms[0][5].toFixed(0)} ms`)
244+}
245+
246+// ---------------------------------------------------------------- Chapter 7
247+if (section('ch7', 'Chapter 7: letting sound out')) {
248+ const D = src('ch07/driver.m')
249+ const S = src('ch07/openbox.m')
250+ const REGION = { N: 100, L: 2, lam: 0.7, sigma: 0.06, xs: 0.7, ys: 1.1, T: 0.0085 }
251+ const { out: o } = runLab(D, S, { mode: 'sweep', ...REGION, methods: ['rigid', 'mur', 'sponge', 'pml'], Ws: [5, 10, 20, 40] })
252+ const r = o.refl
253+ check('rigid +2.4 dB, Mur -22.7 dB', near(r[0][0], 2.44, 0.05) && near(r[1][0], -22.74, 0.05), `${r[0][0].toFixed(2)}, ${r[1][0].toFixed(2)}`)
254+ const sponge = [-4.1, -8.8, -14.8, -21.3]
255+ const pml = [-62.1, -80.2, -95.2, -107.3]
256+ check('damping layer -4.1, -8.8, -14.8, -21.3', sponge.every((v, j) => near(r[2][j], v, 0.06)), r[2].map((v) => v.toFixed(2)).join(' '))
257+ check('PML -62.1, -80.2, -95.2, -107.3', pml.every((v, j) => near(r[3][j], v, 0.1)), r[3].map((v) => v.toFixed(2)).join(' '))
258+ check('the widest damping layer does no better than Mur', r[2][3] > r[1][0])
259+ check('a five-node PML is about 40 dB better than anything else', r[1][0] - r[3][0] > 38)
260+ // a timing, so only its rough size is checked
261+ check('a five-node PML costs about twice Mur, or a little more', o.ms[3][0] / o.ms[1][0] > 1.4 && o.ms[3][0] / o.ms[1][0] < 3.5, (o.ms[3][0] / o.ms[1][0]).toFixed(2))
262+ // the snapshot of the default lab
263+ const { out: s } = runLab(D, S, { mode: 'snap', ...REGION, tsnap: 0.0042, method: 'sponge', W: 10 })
264+ check('the snapshot lab reproduces the sweep', near(s.refl, r[2][1], 1e-9), s.refl.toFixed(2))
265+ // Mur's oblique reflection, as quoted
266+ const mur = (a) => (1 - Math.cos(a)) / (1 + Math.cos(a))
267+ check('Mur reflects 0.17 at 45 degrees and a third at 60', near(mur(Math.PI / 4), 0.17, 0.005) && near(mur(Math.PI / 3), 1 / 3, 1e-9))
268+}
269+
270+// ---------------------------------------------------------------- Chapter 8
271+if (section('ch8', 'Chapter 8: a room, by images')) {
272+ const D = src('ch08/driver.m')
273+ const S = src('ch08/images.m')
274+ const ROOM = { L: [6, 4.5, 3], xs: [1.5, 1.2, 1.4], xr: [4.3, 3.1, 1.6] }
275+ const { out: d } = runLab(D, S, { mode: 'decay', ...ROOM, fs: 16000, alphas: [0.05, 0.1, 0.2, 0.3, 0.5, 0.7], tcap: 1.5, taps: 8 })
276+ const meas = [2.47, 1.28, 0.59, 0.37, 0.21, 0.12]
277+ const lat = [2.55, 1.24, 0.59, 0.37, 0.19, 0.11]
278+ const sab = [2.23, 1.12, 0.56, 0.37, 0.22, 0.16]
279+ const eyr = [2.17, 1.06, 0.5, 0.31, 0.16, 0.09]
280+ const two = (a, b) => a.every((v, i) => Math.abs(b[i] - v) <= 0.006 + 0.005 * v)
281+ check('measured T60 as tabulated', two(meas, d.rt), d.rt.map((v) => v.toFixed(3)).join(' '))
282+ check('lattice as tabulated', two(lat, d.lattice), d.lattice.map((v) => v.toFixed(3)).join(' '))
283+ check('Sabine as tabulated', two(sab, d.sabine), d.sabine.map((v) => v.toFixed(3)).join(' '))
284+ check('Eyring as tabulated', two(eyr, d.eyring), d.eyring.map((v) => v.toFixed(3)).join(' '))
285+ const dev = d.rt.map((v, i) => v / d.lattice[i] - 1)
286+ check('measured within about 3 % of the lattice up to 0.3', dev.slice(0, 4).every((v) => Math.abs(v) < 0.035), dev.map((v) => v.toFixed(3)).join(' '))
287+ check('and 10 % and 14 % long at 0.5 and 0.7', near(dev[4], 0.1, 0.015) && near(dev[5], 0.14, 0.015))
288+ const fac = d.lattice.map((v, i) => v / d.eyring[i])
289+ check('the lattice is a fixed multiple of Eyring, 1.17', fac.every((v) => near(v, 1.175, 0.01)), fac.map((v) => v.toFixed(3)).join(' '))
290+ const sl = d.sabine.map((v, i) => v / d.lattice[i])
291+ check('Sabine short of the lattice by 13 % at 0.05 and long by 46 % at 0.7', near(sl[0], 0.87, 0.01) && near(sl[5], 1.46, 0.01), `${sl[0].toFixed(3)}, ${sl[5].toFixed(3)}`)
292+ const room = (alpha, taps) => runLab(D, S, { mode: 'room', ...ROOM, fs: 22050, alpha, taps, tcap: 1.2, notes: [60, 64], step: 0.24 }).out
293+ const r = room(0.2, 8)
294+ check('the default room: 0.59 measured, 0.78 broadband, direct at 9.9 ms', near(r.rt, 0.59, 0.006) && near(r.rtbroad, 0.78, 0.006) && near(r.direct, 9.9, 0.05),
295+ `${r.rt.toFixed(3)}, ${r.rtbroad.toFixed(3)}, ${r.direct.toFixed(2)}`)
296+ const r0 = room(0.2, 0)
297+ check('nearest-sample placement changes the decay by about one per cent', Math.abs(r0.rt / r.rt - 1) < 0.02, (r0.rt / r.rt - 1).toFixed(4))
298+ check('alpha 0.1: about 1.3 s; alpha 0.6: about 0.15 s', near(room(0.1, 8).rt, 1.28, 0.03) && near(room(0.6, 8).rt, 0.155, 0.01))
299+ const schroeder = 2000 * Math.sqrt(r.rt / 81)
300+ check('the Schroeder frequency is about 170 Hz', near(schroeder, 170, 5), schroeder.toFixed(0))
301+}
302+
303+// ---------------------------------------------------------------- Chapter 9
304+if (section('ch9', 'Chapter 9: a room, by finite differences')) {
305+ const D = src('ch09/driver.m')
306+ const S = src('ch09/room3d.m')
307+ const BOX = { L: [3.2, 2.4, 2.0], lam: 0.55, sigma: 0.0005, xs: [0.8, 0.6, 0.8], xr: [2.4, 1.6, 1.2] }
308+ const { out: o } = runLab(D, S, { mode: 'converge', ...BOX, T: 0.03, Ns: [16, 24, 32, 48, 64] })
309+ const want = [1.29, 0.92, 0.48, 0.21, 0.11]
310+ check('errors 1.29, 0.92, 0.48, 0.21, 0.11', want.every((v, i) => near(o.err[i], v, 0.006)), o.err.map((v) => v.toFixed(3)).join(' '))
311+ check('observed orders 0.84, 2.26, 2.06, 2.15', [0.84, 2.26, 2.06, 2.15].every((v, i) => near(o.orders[i + 1], v, 0.02)))
312+ check('points per wavelength 1.5 to 6.0 at 1.1 kHz', near(o.fhi, 1136, 1) && near(o.ppw[0], 1.5, 0.02) && near(o.ppw[4], 6.0, 0.05))
313+ const p1 = 6 * Math.sqrt(o.err[4] / 0.01)
314+ check('about 20 points per wavelength for 1 per cent', near(p1, 20, 1), p1.toFixed(1))
315+ check('about 1.6e10 nodes for the full band', rel(o.fullcells, 1.6e10, 0.02), o.fullcells.toExponential(2))
316+ check('the time per node settles at a few tens of ns', o.rate > 10 && o.rate < 80, o.rate.toFixed(1))
317+ const yearsAt38 = (o.fullband / o.rate) * 38 / 3.156e7
318+ const hoursAt38 = (o.lowband / o.rate) * 38 / 3600
319+ check('at 38 ns: about seven years, and about six hours to 2 kHz', near(yearsAt38, 7, 0.3) && near(hoursAt38, 6.1, 0.3), `${yearsAt38.toFixed(2)} y, ${hoursAt38.toFixed(2)} h`)
320+ const { out: h } = runLab(D, S, { mode: 'hear', ...BOX, T: 0.3, fs: 22050, Ns: [16, 32] })
321+ check('the listening grids run at 3.1 and 6.2 kHz', near(h.fsk[0], 3118, 1) && near(h.fsk[1], 6236, 1), h.fsk.map((v) => v.toFixed(0)).join(', '))
322+ check('and their clips were raised to a playable rate', h.clips.every((c) => c.fs >= 8000), h.clips.map((c) => c.fs.toFixed(0)).join(', '))
323+}
324+
325+console.log(failures === 0 ? '\nall checks passed' : `\n${failures} check(s) failed`)
326+process.exit(failures === 0 ? 0 : 1)
scripts/runlab.mjsadded+48−0View file
@@ -0,0 +1,48 @@
1+// Run one lab's MATLAB layer through the numbl CLI, outside the browser.
2+//
3+// node scripts/runlab.mjs <driver.m> <script.m> '<params json>' [--full]
4+//
5+// It composes main.m exactly as the app does (driver, newline, script), copies
6+// the shared library into a scratch directory, runs it, and prints out.json.
7+// NUMBL_DIR points at a numbl checkout (default ~/src/numbl).
8+import { execFileSync } from 'node:child_process'
9+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, copyFileSync } from 'node:fs'
10+import { tmpdir } from 'node:os'
11+import { join, dirname, resolve } from 'node:path'
12+import { fileURLToPath } from 'node:url'
13+
14+const here = dirname(fileURLToPath(import.meta.url))
15+const root = resolve(here, '..')
16+const numbl = process.env.NUMBL_DIR ?? join(process.env.HOME, 'src', 'numbl')
17+const cli = join(numbl, 'dist-cli', 'cli.js')
18+
19+export function runLab(driverPath, scriptPath, params, opts = {}) {
20+ const dir = mkdtempSync(join(tmpdir(), 'wv-'))
21+ const lib = join(root, 'src', 'matlab')
22+ for (const f of readdirSync(lib)) if (f.endsWith('.m')) copyFileSync(join(lib, f), join(dir, f))
23+ const driver = readFileSync(driverPath, 'utf8')
24+ const script = readFileSync(scriptPath, 'utf8')
25+ writeFileSync(join(dir, 'main.m'), `${driver}\n${script}`)
26+ writeFileSync(join(dir, 'params.json'), JSON.stringify(params))
27+ const t0 = Date.now()
28+ const stdout = execFileSync('node', [cli, 'run', 'main.m'], { cwd: dir, encoding: 'utf8' })
29+ const ms = Date.now() - t0
30+ const out = JSON.parse(readFileSync(join(dir, 'out.json'), 'utf8'))
31+ if (opts.verbose && stdout.trim()) process.stdout.write(stdout)
32+ return { out, stdout, ms, dir }
33+}
34+
35+if (process.argv[1] === fileURLToPath(import.meta.url)) {
36+ const [driverPath, scriptPath, paramsJson] = process.argv.slice(2)
37+ if (!driverPath || !scriptPath) {
38+ console.error("usage: node scripts/runlab.mjs <driver.m> <script.m> '<params json>'")
39+ process.exit(2)
40+ }
41+ const { out, stdout, ms } = runLab(driverPath, scriptPath, JSON.parse(paramsJson ?? '{}'))
42+ if (stdout.trim()) process.stdout.write(stdout)
43+ const show = process.argv.includes('--full')
44+ console.log(`[${ms} ms]`)
45+ console.log(JSON.stringify(out, null, show ? 1 : 0).slice(0, show ? 1e9 : 4000))
46+}
47+
48+mkdirSync(join(root, 'tmp'), { recursive: true })
scripts/site-check.mjsadded+82−0View file
@@ -0,0 +1,82 @@
1+#!/usr/bin/env node
2+// Loads the deployed site in a headless browser and checks that it actually
3+// runs there, which serving it locally cannot tell you.
4+//
5+// node scripts/site-check.mjs [url]
6+//
7+// The vault serves sites sandboxed: `allow-scripts` without `allow-same-origin`
8+// puts the page in an opaque origin, where `localStorage` throws and a worker
9+// can only be started from a blob URL. numbl does start its worker that way, so
10+// the tutorial is expected to work, but "expected to" is not a test, and
11+// Chapter 1's first lab runs numbl on load, so the check is simply whether its
12+// figures arrive.
13+import puppeteer from 'puppeteer'
14+
15+const url = process.argv[2] ?? 'https://vault1.magland.org/tutorials/wave-acoustics/site/'
16+
17+let failures = 0
18+const check = (name, ok, detail = '') => {
19+ console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`)
20+ if (!ok) failures++
21+}
22+
23+const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
24+const page = await browser.newPage()
25+await page.setViewport({ width: 1400, height: 1000 })
26+
27+// numbl announces which LAPACK bridge it picked on console.error; that is
28+// information, not a fault.
29+const BENIGN = /using bridge:/
30+const problems = []
31+page.on('console', (m) => {
32+ if (m.type() === 'error' && !BENIGN.test(m.text())) problems.push(`console.error: ${m.text()}`)
33+})
34+page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`))
35+page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`))
36+
37+try {
38+ const res = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 })
39+ check('the site answers', res !== null && res.ok(), `HTTP ${res?.status()}`)
40+ check('the shell rendered', (await page.$('.sidebar')) !== null)
41+
42+ // Chapter 1's first lab runs on sight: if numbl booted inside the sandbox,
43+ // its figures appear; if the worker could not start, they never do.
44+ await page.evaluate(() => {
45+ const b = [...document.querySelectorAll('.nav-item')].find((x) => /An exact string/.test(x.textContent))
46+ b?.click()
47+ })
48+ await page.waitForFunction(
49+ () => {
50+ const lab = document.querySelector('.lab')
51+ if (!lab) return false
52+ const busy = [...lab.querySelectorAll('button')].some((b) => /running/.test(b.textContent))
53+ return !busy && !lab.querySelector('.lab-placeholder')
54+ },
55+ { timeout: 120000, polling: 500 },
56+ )
57+ const info = await page.evaluate(() => ({
58+ drawn: [...document.querySelectorAll('.lab-figures svg path')].filter(
59+ (p) => (p.getAttribute('d') ?? '').length > 12,
60+ ).length,
61+ errors: [...document.querySelectorAll('.lab-error')].map((e) => e.textContent.slice(0, 200)),
62+ stats: [...document.querySelectorAll('.stat-value')].map((s) => s.textContent),
63+ }))
64+ check('numbl ran in the sandboxed origin', info.errors.length === 0, info.errors.join(' | '))
65+
66+ // the sandbox is also where audio could fail: press the first listen
67+ // button and check that the player started rather than reporting an error
68+ await page.evaluate(() => document.querySelector('.listen-btn')?.click())
69+ await new Promise((r) => setTimeout(r, 800))
70+ const audio = await page.evaluate(() => ({
71+ on: document.querySelector('.listen-btn.on') !== null,
72+ err: document.querySelector('.listen .lab-error')?.textContent ?? '',
73+ }))
74+ check('the audio player starts in the sandboxed origin', audio.on && audio.err === '', audio.err)
75+ check('the lab drew its figures', info.drawn > 0, `${info.drawn} paths ${info.stats.join(' ')}`)
76+ check('no console errors', problems.length === 0, problems.slice(0, 3).join(' | '))
77+} finally {
78+ await browser.close()
79+}
80+
81+console.log(failures === 0 ? '\nthe deployed site works' : `\n${failures} check(s) failed`)
82+process.exit(failures === 0 ? 0 : 1)
src/App.tsxadded+96−0View file
@@ -0,0 +1,96 @@
1+import { useCallback, useEffect, useState } from 'react'
2+import ChapterView from './ui/ChapterView.tsx'
3+import LibraryView from './ui/LibraryView.tsx'
4+import { CHAPTERS } from './chapters/index.ts'
5+import { PARTS } from './ui/types.ts'
6+
7+/** The introduction is front matter, so the chapter numbers start after it. */
8+const NUMBERED = CHAPTERS.filter((c) => c.part !== 'Introduction')
9+
10+function readHash(): string {
11+ const h = window.location.hash.replace(/^#\/?/, '')
12+ return h === '' ? CHAPTERS[0].id : h
13+}
14+
15+export default function App() {
16+ const [route, setRoute] = useState(readHash)
17+ useEffect(() => {
18+ const on = () => setRoute(readHash())
19+ window.addEventListener('hashchange', on)
20+ return () => window.removeEventListener('hashchange', on)
21+ }, [])
22+
23+ const go = useCallback((id: string) => {
24+ window.location.hash = `/${id}`
25+ window.scrollTo({ top: 0 })
26+ }, [])
27+
28+ const chapter = CHAPTERS.find((c) => c.id === route)
29+ const index = CHAPTERS.findIndex((c) => c.id === route)
30+
31+ return (
32+ <div className="app">
33+ <nav className="sidebar">
34+ <div className="brand">
35+ <div className="brand-title">Waves and sound</div>
36+ <div className="brand-sub">the wave equation, and what its errors sound like</div>
37+ </div>
38+ {PARTS.map((part) => (
39+ <div className="nav-part" key={part}>
40+ <div className="nav-part-title">{part}</div>
41+ <ol>
42+ {CHAPTERS.filter((c) => c.part === part).map((c) => (
43+ <li key={c.id}>
44+ <button className={c.id === route ? 'nav-item active' : 'nav-item'} onClick={() => go(c.id)}>
45+ <span className="nav-num">{NUMBERED.indexOf(c) + 1 || ''}</span>
46+ {c.nav}
47+ </button>
48+ </li>
49+ ))}
50+ </ol>
51+ </div>
52+ ))}
53+ <div className="nav-part">
54+ <div className="nav-part-title">Reference</div>
55+ <ol>
56+ <li>
57+ <button className={route === 'library' ? 'nav-item active' : 'nav-item'} onClick={() => go('library')}>
58+ <span className="nav-num" />
59+ shared MATLAB library
60+ </button>
61+ </li>
62+ </ol>
63+ </div>
64+ <div className="nav-foot">
65+ MATLAB syntax runs in your browser through <a href="https://numbl.org">numbl</a>. Nothing is installed and
66+ nothing is sent anywhere.
67+ </div>
68+ </nav>
69+
70+ <main className="main">
71+ {route === 'library' ? (
72+ <LibraryView />
73+ ) : chapter ? (
74+ <>
75+ <ChapterView chapter={chapter} />
76+ <nav className="pager">
77+ {index > 0 && <button onClick={() => go(CHAPTERS[index - 1].id)}>← {CHAPTERS[index - 1].nav}</button>}
78+ <span className="lab-bar-gap" />
79+ {index < CHAPTERS.length - 1 && (
80+ <button onClick={() => go(CHAPTERS[index + 1].id)}>{CHAPTERS[index + 1].nav} →</button>
81+ )}
82+ </nav>
83+ </>
84+ ) : (
85+ <p className="lab-error">no such chapter: {route}</p>
86+ )}
87+ </main>
88+
89+ {/* Sits above every page and takes no clicks, so it can neither be
90+ dismissed nor get in the way of a control underneath it. */}
91+ <aside className="ai-disclaimer" role="note">
92+ <strong>Note:</strong> written by AI, not fully checked by a human.
93+ </aside>
94+ </div>
95+ )
96+}
src/audio/Listen.tsxadded+129−0View file
@@ -0,0 +1,129 @@
1+// Plays a computed signal through the Web Audio API.
2+//
3+// Clips arrive from the MATLAB layer as 16-bit integers (see wv_audio.m) with
4+// their own sample rate, which need not be 44.1 kHz: a coarse simulation
5+// produces sound at whatever rate its time step implies, and the audio system
6+// resamples it. One AudioContext serves the whole page and is created on the
7+// first click, because browsers refuse to start audio before a user gesture.
8+// Starting a clip stops whatever else was playing, so that two comparisons
9+// never overlap.
10+import { useEffect, useState } from 'react'
11+
12+export interface Clip {
13+ fs: number
14+ /** samples scaled to about +-29000; see wv_audio.m */
15+ pcm: number[]
16+}
17+
18+let ctx: AudioContext | null = null
19+let current: { node: AudioBufferSourceNode; id: string } | null = null
20+const listeners = new Set<(id: string | null) => void>()
21+
22+function announce(id: string | null) {
23+ for (const l of listeners) l(id)
24+}
25+
26+function stop() {
27+ if (current) {
28+ try {
29+ current.node.stop()
30+ } catch {
31+ /* already stopped */
32+ }
33+ current = null
34+ }
35+ announce(null)
36+}
37+
38+function play(clip: Clip, id: string, gain: number): string | null {
39+ try {
40+ if (!ctx) ctx = new AudioContext()
41+ if (ctx.state === 'suspended') void ctx.resume()
42+ stop()
43+ const n = clip.pcm.length
44+ const buf = ctx.createBuffer(1, n, clip.fs)
45+ const ch = buf.getChannelData(0)
46+ const k = gain / 32768
47+ for (let i = 0; i < n; i++) ch[i] = clip.pcm[i] * k
48+ const node = ctx.createBufferSource()
49+ node.buffer = buf
50+ node.connect(ctx.destination)
51+ node.onended = () => {
52+ if (current?.node === node) {
53+ current = null
54+ announce(null)
55+ }
56+ }
57+ node.start()
58+ current = { node, id }
59+ announce(id)
60+ return null
61+ } catch (err) {
62+ return err instanceof Error ? err.message : String(err)
63+ }
64+}
65+
66+export interface ListenItem {
67+ label: string
68+ clip: Clip | undefined
69+ /** extra amplification applied at playback, for a quiet error signal */
70+ gain?: number
71+}
72+
73+let serial = 0
74+
75+/**
76+ * A row of play buttons. Every clip in one row was scaled by the same factor
77+ * in the MATLAB layer, unless its label says otherwise, so that the relative
78+ * loudness of the clips is the relative size of the signals.
79+ */
80+export default function Listen({ items, note }: { items: ListenItem[]; note?: string }) {
81+ const [base] = useState(() => `listen-${++serial}`)
82+ const [playing, setPlaying] = useState<string | null>(null)
83+ const [error, setError] = useState<string | null>(null)
84+ useEffect(() => {
85+ const l = (id: string | null) => setPlaying(id)
86+ listeners.add(l)
87+ return () => {
88+ listeners.delete(l)
89+ }
90+ }, [])
91+ // leaving the page, or re-running the lab, should not leave sound behind
92+ useEffect(() => () => {
93+ if (current?.id.startsWith(base)) stop()
94+ }, [base])
95+
96+ return (
97+ <div className="listen">
98+ <span className="listen-title">listen</span>
99+ {items.map((it, k) => {
100+ const id = `${base}-${k}`
101+ const on = playing === id
102+ const secs = it.clip ? it.clip.pcm.length / it.clip.fs : 0
103+ return (
104+ <button
105+ key={it.label}
106+ className={on ? 'listen-btn on' : 'listen-btn'}
107+ disabled={!it.clip}
108+ onClick={() => {
109+ if (on) {
110+ stop()
111+ return
112+ }
113+ if (!it.clip) return
114+ setError(play(it.clip, id, it.gain ?? 1))
115+ }}
116+ >
117+ <span className="listen-icon" aria-hidden="true">
118+ {on ? '■' : '▶'}
119+ </span>
120+ {it.label}
121+ <span className="listen-dur">{secs.toFixed(1)} s</span>
122+ </button>
123+ )
124+ })}
125+ {note && <span className="listen-note">{note}</span>}
126+ {error && <span className="lab-error">audio unavailable: {error}</span>}
127+ </div>
128+ )
129+}
src/chapters/ch01/chapter.tsxadded+207−0View file
@@ -0,0 +1,207 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Params } from '../../engine/types.ts'
3+import type { Num } from '../../engine/types.ts'
4+import driver from './driver.m?raw'
5+import script from './pluck.m?raw'
6+import text from './text.md?raw'
7+import Listen, { type Clip } from '../../audio/Listen.tsx'
8+import { SeriesPlot, fmtDb, fmtErr } from '../common/panels.tsx'
9+import { faded, ink, method } from '../../plot/palette.ts'
10+import { FS, noteControl, noteHz } from '../shared.ts'
11+
12+interface PluckOut {
13+ fs: number
14+ f0: number
15+ nyq: number
16+ tf: number[]
17+ exact: number[]
18+ ts: number[]
19+ synth: number[]
20+ n: number[]
21+ cdb: number[]
22+ missing: number
23+ fr: number[]
24+ db: number[]
25+ clip: Clip
26+ reldiff: number
27+}
28+
29+interface AliasOut {
30+ fs: number
31+ f0: number
32+ nyq: number
33+ measured: number
34+ predicted: number
35+ fr: number[]
36+ dbnaive: Num[]
37+ dbband: Num[]
38+ falias: number[]
39+ harm: number[]
40+ clipNaive: Clip
41+ clipBand: Clip
42+ clipDiff: Clip
43+ ratio: number
44+}
45+
46+const WHERE = {
47+ kind: 'select' as const,
48+ key: 'where',
49+ label: 'listen to',
50+ options: [
51+ { value: 'bridge', label: 'the force on the bridge (partials fall as 1/n)' },
52+ { value: 'pickup', label: 'the displacement at a pickup (1/n²)' },
53+ ],
54+}
55+
56+const BETA = { kind: 'slider' as const, key: 'beta', label: 'pluck position β', min: 0.05, max: 0.5, step: 0.01 }
57+
58+const kHz = (v: number) => (v >= 1000 ? `${v / 1000}k` : String(v))
59+
60+function PluckPanels({ d }: { d: PluckOut }) {
61+ return (
62+ <>
63+ <SeriesPlot
64+ title="three periods of the signal: d'Alembert's exact solution (grey) and the band-limited modal sum at the audio sample instants"
65+ height={200}
66+ xLabel="ms"
67+ series={[
68+ { x: d.tf, y: d.exact, color: ink.reference, label: "d'Alembert, exact", width: 2 },
69+ {
70+ x: d.ts,
71+ y: d.synth,
72+ color: method.modal,
73+ label: `modal sum, ${d.nyq} partials`,
74+ width: 1.4,
75+ },
76+ ]}
77+ />
78+ <SeriesPlot
79+ title="amplitude of each partial, relative to the largest; the gaps are the harmonics the pluck position does not excite"
80+ height={170}
81+ xLabel="partial n"
82+ yLabel="dB"
83+ yDomain={[-80, 3]}
84+ series={[
85+ {
86+ x: d.n,
87+ y: d.cdb,
88+ color: method.modal,
89+ stems: true,
90+ base: -80,
91+ shape: 'circle',
92+ line: false,
93+ label: 'exact modal amplitude',
94+ tip: (i) => [`n = ${d.n[i]}, ${(d.n[i] * d.f0).toFixed(0)} Hz`, `${d.cdb[i].toFixed(1)} dB`],
95+ },
96+ ]}
97+ legend={false}
98+ />
99+ <SeriesPlot
100+ title="spectrum of the sound as played, first half second, with the listening loss"
101+ height={170}
102+ xLabel="Hz"
103+ yLabel="dB"
104+ yDomain={[-90, 3]}
105+ formatX={kHz}
106+ series={[{ x: d.fr, y: d.db, color: method.modal, width: 1.2 }]}
107+ legend={false}
108+ />
109+ <Listen items={[{ label: 'the plucked string', clip: d.clip }]} note="start with the volume low" />
110+ </>
111+ )
112+}
113+
114+function AliasPanels({ d }: { d: AliasOut }) {
115+ return (
116+ <>
117+ <SeriesPlot
118+ title="spectrum of the first half second, up to the Nyquist frequency; faint lines mark the true harmonics"
119+ height={250}
120+ xLabel="Hz"
121+ yLabel="dB"
122+ yDomain={[-100, 3]}
123+ formatX={kHz}
124+ vlines={[{ x: d.harm, color: faded(ink.muted, 0.45) }]}
125+ series={[
126+ { x: d.fr, y: d.dbnaive, color: method.alt, label: "d'Alembert, sampled directly", width: 1.2 },
127+ { x: d.fr, y: d.dbband, color: method.modal, label: 'modal sum below the Nyquist frequency', width: 1.4 },
128+ ]}
129+ />
130+ <Listen
131+ items={[
132+ { label: 'sampled directly', clip: d.clipNaive },
133+ { label: 'band-limited', clip: d.clipBand },
134+ { label: 'their difference', clip: d.clipDiff },
135+ ]}
136+ note="all three at the same scale"
137+ />
138+ </>
139+ )
140+}
141+
142+export const ch01: Chapter = {
143+ id: 'exact',
144+ nav: 'An exact string',
145+ part: 'Strings',
146+ title: 'Sound from an exact solution',
147+ blurb:
148+ "The plucked string solved twice, by d'Alembert and by Fourier, and the first thing that goes wrong: an exact solution that cannot be played as it stands.",
149+ text,
150+ labs: [
151+ {
152+ id: 'pluck',
153+ title: 'A plucked string, from its modes',
154+ caption:
155+ 'Move the pluck towards the middle and listen to the sound go hollow as the even harmonics disappear; switch to the pickup to hear what the 1/n² roll-off does.',
156+ driver,
157+ script,
158+ controls: [noteControl('m', 'note', [45, 52, 57, 64, 69]), BETA, WHERE],
159+ defaults: { m: 45, beta: 0.2, where: 'bridge' },
160+ derive: (p: Params) => ({
161+ mode: 'pluck',
162+ fs: FS,
163+ f0: noteHz(Number(p.m)),
164+ beta: p.beta,
165+ where: p.where,
166+ xp: 0.12,
167+ T: 2.5,
168+ T60: 3,
169+ fmax: 6000,
170+ }),
171+ editorOpen: false,
172+ render: (d: PluckOut) => <PluckPanels d={d} />,
173+ summary: (d: PluckOut) => [
174+ { label: 'partials below Nyquist', value: String(d.nyq) },
175+ { label: 'missing in the first 60', value: String(d.missing) },
176+ { label: "modal sum vs d'Alembert, rms", value: fmtErr(d.reldiff) },
177+ ],
178+ },
179+ {
180+ id: 'alias',
181+ title: 'Sampling a signal that is not band-limited',
182+ caption:
183+ "Raise the note and listen for tones that are not harmonics of it. They are the partials above 22.05 kHz, folded back into the audible band by sampling.",
184+ driver,
185+ script,
186+ controls: [noteControl('m', 'note', [45, 57, 69, 76, 81, 88, 93, 96, 100]), BETA, WHERE],
187+ defaults: { m: 88, beta: 0.2, where: 'bridge' },
188+ derive: (p: Params) => ({
189+ mode: 'alias',
190+ fs: FS,
191+ f0: noteHz(Number(p.m)),
192+ beta: p.beta,
193+ where: p.where,
194+ xp: 0.12,
195+ T: 1.5,
196+ T60: 1.2,
197+ }),
198+ render: (d: AliasOut) => <AliasPanels d={d} />,
199+ summary: (d: AliasOut) => [
200+ { label: 'fs / f0', value: d.ratio.toFixed(3) },
201+ { label: 'partials below Nyquist', value: String(d.nyq) },
202+ { label: 'folded energy, measured', value: fmtDb(d.measured) },
203+ { label: 'expected from the tail', value: fmtDb(d.predicted) },
204+ ],
205+ },
206+ ],
207+}
src/chapters/ch01/driver.madded+153−0View file
@@ -0,0 +1,153 @@
1+% Driver for Chapter 1: an exact string, and the sound of sampling it.
2+%
3+% The editable script supplies
4+%
5+% y = pluck(t, f0, beta, where, xp, fmax, T60)
6+%
7+% the modal sum for a plucked string, restricted to partials below fmax.
8+%
9+% The driver's own ground truth is d'Alembert's solution, which shares no
10+% algebra with the modal sum: the string's shape is the average of two copies
11+% of the initial triangle travelling in opposite directions, so the bridge
12+% force is a square-cornered pulse train and the pickup displacement is
13+% piecewise linear in time. Both are evaluated exactly, in closed form, at
14+% any time, and neither is band-limited.
15+%
16+% mode 'pluck' one note: the first three periods drawn finely, the harmonic
17+% amplitudes, and the sound with a listening loss applied.
18+% mode 'alias' the same note sampled two ways: d'Alembert's signal read off
19+% at the sampling instants (every partial, most of them above
20+% the Nyquist frequency and folded back below it), and the
21+% modal sum stopped at the Nyquist frequency. Their difference
22+% is exactly the folded part, and its energy is also known in
23+% closed form, which the driver prints alongside the measurement.
24+p = wv_params();
25+fs = p.fs;
26+f0 = p.f0;
27+beta = p.beta;
28+out = struct();
29+out.fs = fs;
30+out.f0 = f0;
31+out.nyq = floor(fs / 2 / f0);
32+
33+if strcmp(p.mode, 'pluck')
34+ % three periods, finely, for the waveform panel
35+ tf = linspace(0, 3 / f0, 900)';
36+ ts = (0:floor(3 * fs / f0))' / fs;
37+ out.tf = tf' * 1000;
38+ out.exact = drv_dalembert(tf, f0, beta, p.where, p.xp)';
39+ out.ts = ts' * 1000;
40+ out.synth = pluck(ts, f0, beta, p.where, p.xp, fs / 2, Inf)';
41+
42+ % harmonic amplitudes, from the exact coefficients
43+ nh = min(out.nyq, 60);
44+ n = 1:nh;
45+ c = drv_coef(n, beta, p.where, p.xp);
46+ out.n = n;
47+ out.cdb = drv_db(c, max(abs(c)));
48+ out.missing = sum(abs(sin(n * pi * beta)) < 1e-9);
49+
50+ % the sound: band-limited, with the listening loss
51+ t = (0:round(p.T * fs) - 1)' / fs;
52+ y = pluck(t, f0, beta, p.where, p.xp, fs / 2, p.T60);
53+ [fr, db] = wv_spectrum(y(1:min(end, round(0.5 * fs))), fs, p.fmax, 700);
54+ out.fr = fr;
55+ out.db = db;
56+ out.clip = wv_audio(y, fs);
57+
58+ % how far the band-limited sum is from d'Alembert at the sample instants
59+ yb = pluck(t(1:round(0.25 * fs)), f0, beta, p.where, p.xp, fs / 2, Inf);
60+ ye = drv_dalembert(t(1:round(0.25 * fs)), f0, beta, p.where, p.xp);
61+ out.reldiff = sqrt(sum((yb - ye) .^ 2) / sum(yb .^ 2));
62+else
63+ t = (0:round(p.T * fs) - 1)' / fs;
64+ env = exp(-6.908 * t / p.T60); % the same envelope on both clips
65+ ye = drv_dalembert(t, f0, beta, p.where, p.xp);
66+ yb = pluck(t, f0, beta, p.where, p.xp, fs / 2, Inf);
67+ d = ye - yb;
68+ out.measured = 10 * log10(sum(d .^ 2) / sum(yb .^ 2));
69+ out.predicted = drv_predicted(f0, fs, beta, p.where, p.xp);
70+ nw = round(0.5 * fs);
71+ [fr, dbe] = wv_spectrum(ye(1:nw), fs, fs / 2, 900);
72+ [~, dbb] = wv_spectrum(yb(1:nw), fs, fs / 2, 900);
73+ out.fr = fr;
74+ out.dbnaive = dbe;
75+ out.dbband = dbb;
76+ % where the first few folded partials land
77+ nmax = 3 * out.nyq;
78+ nn = out.nyq + 1:nmax;
79+ fa = mod(nn * f0, fs);
80+ fa(fa > fs / 2) = fs - fa(fa > fs / 2);
81+ cc = drv_coef(nn, beta, p.where, p.xp);
82+ keep = abs(cc) > 1e-9 * max(abs(drv_coef(1:out.nyq, beta, p.where, p.xp)));
83+ out.falias = fa(keep);
84+ out.harm = (1:out.nyq) * f0;
85+ sc = max(abs(ye));
86+ out.clipNaive = wv_audio(ye .* env, fs, sc);
87+ out.clipBand = wv_audio(yb .* env, fs, sc);
88+ out.clipDiff = wv_audio(d .* env, fs, sc);
89+ out.ratio = fs / f0;
90+end
91+
92+wv_write(out);
93+
94+function y = drv_dalembert(t, f0, beta, where, xp)
95+% d'Alembert: u(x, t) = (phi(x - ct) + phi(x + ct)) / 2, with phi the odd,
96+% period-2 extension of the initial triangle and c = 2 f0 on a string of
97+% length 1.
98+s = 2 * f0 * t;
99+if strcmp(where, 'bridge')
100+ % u_x(0, t) = phi'(ct), and phi' is even: 1/beta on (0, beta),
101+ % -1/(1 - beta) on (beta, 2 - beta), 1/beta again up to 2. A sample that
102+ % lands exactly on a jump takes the midpoint, as a Fourier series would.
103+ r = mod(s, 2);
104+ hi = 1 / beta;
105+ lo = -1 / (1 - beta);
106+ y = lo * ones(size(r));
107+ y(r < beta | r > 2 - beta) = hi;
108+ y(abs(r - beta) < 1e-12 | abs(r - (2 - beta)) < 1e-12) = 0.5 * (hi + lo);
109+else
110+ y = 0.5 * (drv_phi(xp - s, beta) + drv_phi(xp + s, beta));
111+end
112+end
113+
114+function v = drv_phi(x, beta)
115+% the initial triangle, extended oddly about 0 and periodically with period 2
116+r = mod(x, 2);
117+sg = ones(size(r));
118+sg(r > 1) = -1;
119+r(r > 1) = 2 - r(r > 1);
120+v = min(r / beta, (1 - r) / (1 - beta));
121+v = sg .* v;
122+end
123+
124+function c = drv_coef(n, beta, where, xp)
125+A = 2 * sin(n * pi * beta) ./ (n .^ 2 * pi ^ 2 * beta * (1 - beta));
126+if strcmp(where, 'bridge')
127+ c = n * pi .* A;
128+else
129+ c = A .* sin(n * pi * xp);
130+end
131+end
132+
133+function d = drv_db(c, ref)
134+d = 20 * log10(max(abs(c) / ref, 1e-8));
135+end
136+
137+function r = drv_predicted(f0, fs, beta, where, xp)
138+% Energy above the Nyquist frequency over energy below it, from the exact
139+% coefficients. For the bridge force the total is known in closed form by
140+% Parseval, mean(F^2) = 1 / (beta (1 - beta)), so the tail is the total less
141+% the head; for the pickup the coefficients fall like 1/n^2 and the tail is
142+% summed directly to n = 2e5, beyond which it is below rounding.
143+K = floor(fs / 2 / f0);
144+head = drv_coef(1:K, beta, where, xp);
145+eh = sum(head .^ 2) / 2;
146+if strcmp(where, 'bridge')
147+ et = 1 / (beta * (1 - beta)) - eh;
148+else
149+ tail = drv_coef(K + 1:200000, beta, where, xp);
150+ et = sum(tail .^ 2) / 2;
151+end
152+r = 10 * log10(et / eh);
153+end
src/chapters/ch01/pluck.madded+49−0View file
@@ -0,0 +1,49 @@
1+% Modal synthesis of a plucked string.
2+%
3+% An ideal string of length 1, fixed at both ends and released from rest in
4+% the shape of a triangle with its apex at x = beta, moves as
5+%
6+% u(x, t) = sum_n A_n sin(n pi x) cos(2 pi n f0 t),
7+% A_n = 2 sin(n pi beta) / (n^2 pi^2 beta (1 - beta)),
8+%
9+% which is exact: it is the Fourier sine series of the initial shape, and each
10+% term is a standing wave that solves the wave equation on its own. What the
11+% listener hears is not u itself but something it drives, and this script
12+% offers two choices:
13+%
14+% 'bridge' the force on the bridge, proportional to the slope u_x(0, t),
15+% whose partials fall off like 1/n. This is what an acoustic
16+% instrument's body is shaken by, and it sounds bright.
17+% 'pickup' the displacement at x = xp, whose partials fall off like 1/n^2
18+% and are notched wherever sin(n pi xp) vanishes. This is what a
19+% magnetic pickup reports, and it sounds darker.
20+%
21+% Only partials below fmax are summed. With fmax at the Nyquist frequency the
22+% result is the band-limited signal, which is the right thing to play; see the
23+% driver for what happens otherwise.
24+%
25+% T60 sets a loss for listening: each partial decays by 60 dB in
26+% T60 / (1 + (f/2000)^2) seconds, so that high partials die first, as they do
27+% on a real string. With T60 = Inf the synthesis is lossless and is compared
28+% exactly against d'Alembert's solution.
29+
30+function y = pluck(t, f0, beta, where, xp, fmax, T60)
31+t = t(:);
32+y = zeros(size(t));
33+nmax = floor(fmax / f0);
34+for n = 1:nmax
35+ A = 2 * sin(n * pi * beta) / (n ^ 2 * pi ^ 2 * beta * (1 - beta));
36+ if strcmp(where, 'bridge')
37+ c = n * pi * A;
38+ else
39+ c = A * sin(n * pi * xp);
40+ end
41+ f = n * f0;
42+ if isinf(T60)
43+ y = y + c * cos(2 * pi * f * t);
44+ else
45+ s = 6.908 * (1 + (f / 2000) ^ 2) / T60;
46+ y = y + c * exp(-s * t) .* cos(2 * pi * f * t);
47+ end
48+end
49+end
src/chapters/ch01/text.mdadded+64−0View file
@@ -0,0 +1,64 @@
1+Before any numerical method, we want a string whose motion we know exactly, and a way of turning that motion into sound. This chapter builds both, and in doing so meets the first error of the tutorial. It is not an error of any method: it is what happens when an exact solution is played as it stands.
2+
3+### Two exact solutions
4+
5+Take a string of length 1, fixed at both ends, pulled aside at $x = \beta$ into a triangle of height 1 and released from rest. Its displacement obeys $u_{tt} = c^2 u_{xx}$ with $u(0,t) = u(1,t) = 0$, and there are two classical ways of writing down the answer.
6+
7+**d'Alembert's.** Every solution of the wave equation on a line is a sum of a shape moving right and a shape moving left. Extend the initial triangle to the whole line as an odd function about 0 and about 1, which makes it periodic with period 2, and call the result $\phi$. Then
8+
9+$$u(x,t) = \tfrac12\big(\phi(x - ct) + \phi(x + ct)\big)$$
10+
11+satisfies the equation, the initial conditions and both boundary conditions: the odd extension is what makes the two halves cancel at the ends. Half the triangle travels each way, reflects inverted from each end, and the two halves recombine into the original shape once per period $2/c$.
12+
13+<!--fig:dalembert-->
14+
15+**Fourier's.** Alternatively, expand the initial shape in the eigenfunctions of the problem, $\sin(n\pi x)$. Each one oscillates on its own at frequency $n f_0$, with $f_0 = c/2$, so
16+
17+$$u(x,t) = \sum_{n=1}^{\infty} A_n \sin(n\pi x)\cos(2\pi n f_0 t), \qquad A_n = \frac{2\sin(n\pi\beta)}{n^2\pi^2\beta(1-\beta)}.$$
18+
19+The partials are exactly harmonic, at whole multiples of the fundamental. Their amplitudes fall like $1/n^2$, and $\sin(n\pi\beta)$ removes every partial with a node at the plucking point: plucked at $\beta = 1/5$, the string has no 5th, 10th or 15th harmonic.
20+
21+The two forms share no algebra, and the lab below computes the modal sum with the editable script and d'Alembert's form with the driver, so that each checks the other.
22+
23+### What is heard
24+
25+A listener does not hear $u$. An acoustic guitar is heard through its body, which is driven by the force the string exerts on the bridge, and that force is proportional to the slope $u_x(0,t)$. Differentiating the modal sum multiplies each amplitude by $n\pi$, so the bridge force has partials that fall like $1/n$, and in d'Alembert's form it is $\phi'(ct)$, a rectangular pulse train: $1/\beta$ for a fraction $\beta$ of each period and $-1/(1-\beta)$ for the rest. An electric guitar is heard through a magnetic pickup, which reports the displacement at one point $x_p$, and its partials fall like $1/n^2$ with a second set of gaps where $\sin(n\pi x_p)$ vanishes. The lab offers both.
26+
27+For listening, the script also applies a loss: each partial decays by 60 dB in $3/(1 + (f/2000)^2)$ seconds, so that high partials die first, roughly as they do on a real string. This is a model of taste rather than of physics, and it is switched off whenever the modal sum is compared with d'Alembert's solution.
28+
29+<!--lab:pluck-->
30+
31+At $f_0 = 110$ Hz there are 200 partials below the Nyquist frequency of 22.05 kHz, and at $\beta = 0.2$ exactly 12 of the first 60 are missing. Moving the pluck towards the middle removes more of them, and at $\beta = 0.5$ every even harmonic is gone, which is the hollow, clarinet-like sound of a string plucked at its centre. The waveform panel shows the band-limited modal sum at the sample instants against d'Alembert's rectangular pulses, and they differ in the way any truncated Fourier series differs from a discontinuous function: by ringing at the jumps. That ringing is the whole of the relative rms difference printed above the figures, $5.0\times10^{-2}$ for the bridge force and $3.5\times10^{-4}$ for the smoother pickup signal.
32+
33+### Sampling an exact solution
34+
35+The rms difference between the two exact solutions raises a question. Both are exact. Which should be played?
36+
37+The answer is the modal sum, and the reason is sampling. A digital signal at 44.1 kHz can represent frequencies up to 22.05 kHz and no higher. A component at frequency $f$ above that limit does not disappear when it is sampled: it produces exactly the same samples as a component at $|f - k f_s|$ for the integer $k$ that brings it below the limit. This is **aliasing**, and the component is said to *fold* back into the audible band. d'Alembert's bridge force has corners, so it has partials at every multiple of $f_0$ however high, and reading it off at the sampling instants folds all of those above 22.05 kHz back down. The modal sum stopped at the Nyquist frequency has none.
38+
39+<!--fig:folding-->
40+
41+A folded partial lands at $|n f_0 - k f_s|$, which is a multiple of $f_0$ only when $f_s/f_0$ happens to be a whole number. Otherwise it lands between the harmonics, where it is heard as a tone that does not belong to the note.
42+
43+<!--lab:alias-->
44+
45+The energy that folds is known in advance. It is the energy of the partials above the Nyquist frequency, which for the bridge force is the total, $1/(\beta(1-\beta))$ by Parseval's theorem, minus the sum over the partials below. The measured energy of the difference between the two sampled signals agrees with this to within 0.03 dB for every note in the table below but one.
46+
47+| note | $f_0$ | bridge force | pickup |
48+|---|---|---|---|
49+| A2 | 110 Hz | −26.0 dB | −69.2 dB |
50+| A3 | 220 Hz | −22.0 dB | −60.0 dB |
51+| A4 | 440 Hz | −18.9 dB | −51.0 dB |
52+| A5 | 880 Hz | −15.9 dB | −42.0 dB |
53+| A6 | 1760 Hz | −12.7 dB | −32.0 dB |
54+| E7 | 2637 Hz | −11.4 dB | −28.6 dB |
55+
56+Two regularities are visible. The folded energy of the bridge force rises by 3 dB per octave, because its partials fall like $1/n$ and the energy above the $K$th partial is then proportional to $1/K$, which doubles when the note rises an octave and $K$ halves. The pickup's rises by 9 dB per octave, because its partials fall like $1/n^2$ and the tail is proportional to $1/K^3$. Neither is small. Even at A2, sampling the bridge force directly puts a four-hundredth of the energy of the note into the wrong place, and at A6 a nineteenth.
57+
58+The one exception is A2 itself, where measurement and prediction differ by 1 dB. There $f_s/f_0 = 4410/11$ exactly, so the sampled signal repeats itself every 4410 samples and the folded partials land on a small set of frequencies, where they add with their phases rather than with their energies; the prediction assumes the latter. For a note whose frequency ratio to the sampling rate is not a ratio of small integers, which is the usual case in equal temperament, the prediction holds.
59+
60+### What this chapter establishes
61+
62+The exact solution of a problem in acoustics is not always the right thing to play. What should be played is the exact solution *band-limited* to the sampling rate, and for a string that is the modal sum stopped at the Nyquist frequency. Everything in the next two chapters is measured against that signal or against d'Alembert's solution at the same instants, and we will see in Chapter 2 that one numerical scheme reproduces d'Alembert's solution, aliasing included, to rounding error.
63+
64+Modal synthesis is also, in its own right, the best method in this tutorial for any problem whose modes are known: it is exact, its cost is one oscillator per partial, and it never disperses. The rest of the tutorial is about problems where the modes are not known, which in practice is almost all of them. We keep modal synthesis as the reference and use the string, the square and the disk as places where the other methods can be checked against it.
src/chapters/ch02/chapter.tsxadded+304−0View file
@@ -0,0 +1,304 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Num, Params } from '../../engine/types.ts'
3+import driver from './driver.m?raw'
4+import script from './leapfrog.m?raw'
5+import text from './text.md?raw'
6+import Listen, { type Clip } from '../../audio/Listen.tsx'
7+import Field from '../../plot/Field.tsx'
8+import { Row, SeriesPlot, fmtCents, fmtErr, fmtNum } from '../common/panels.tsx'
9+import { familyColor, familyShapes, ink, method } from '../../plot/palette.ts'
10+import { FS } from '../shared.ts'
11+
12+interface RunOut {
13+ ms: number
14+ lam: number
15+ N: number
16+ unstable: boolean
17+ blowup: Num
18+ tp: number[]
19+ err: Num[]
20+ relerr: number
21+ tw: number[]
22+ yw: Num[]
23+ yew: number[]
24+ pred1: number
25+ pred10: number
26+ clipExact: Clip
27+ clipFd: Clip
28+ clipDiff: Clip
29+}
30+
31+interface CentsOut {
32+ N: number
33+ f0: number
34+ lams: number[]
35+ sel: number[]
36+ nall: number[]
37+ meas: Num[][]
38+ pred: Num[][]
39+ ms: number[]
40+ agree: number
41+}
42+
43+interface ClickOut {
44+ ms: number
45+ N: number
46+ lam: number
47+ fcut: number
48+ rel: number
49+ tw1: number[]
50+ y1: number[]
51+ ye1: number[]
52+ tw20: number[]
53+ y20: number[]
54+ ye20: number[]
55+ spec: Num[][]
56+ specF: [number, number]
57+ specT: [number, number]
58+ clipExact: Clip
59+ clipFd: Clip
60+}
61+
62+const F0 = FS / 400 // 110.25 Hz: the pitch at which N = 200 is exactly lam = 1
63+
64+function RunPanels({ d }: { d: RunOut }) {
65+ return (
66+ <>
67+ <SeriesPlot
68+ title="three periods of the recorded signal, starting one second after the pluck"
69+ height={190}
70+ xLabel="ms"
71+ series={[
72+ { x: d.tw, y: d.yew, color: ink.reference, label: "exact (d'Alembert)", width: 2.6 },
73+ { x: d.tw, y: d.yw, color: method.fd2, label: `leapfrog, N = ${d.N}, λ = ${fmtNum(d.lam, 3)}`, width: 1.4 },
74+ ]}
75+ />
76+ <SeriesPlot
77+ title="relative rms error of the recorded signal, period by period"
78+ height={170}
79+ yLog
80+ yDomain={[1e-16, 10]}
81+ xLabel="ms"
82+ yLabel="error"
83+ series={[{ x: d.tp, y: d.err.map((v) => (v != null && v > 1e-16 ? v : 1e-16)), color: method.fd2, width: 1.6 }]}
84+ legend={false}
85+ />
86+ <Listen
87+ items={[
88+ { label: 'exact', clip: d.clipExact },
89+ { label: 'leapfrog', clip: d.clipFd },
90+ { label: 'the difference', clip: d.clipDiff },
91+ ]}
92+ note={d.unstable ? 'the unstable run is silenced from the moment it blows up' : 'all three at the same scale'}
93+ />
94+ </>
95+ )
96+}
97+
98+function CentsPanels({ d }: { d: CentsOut }) {
99+ const n = d.lams.length
100+ const col = (k: number) => familyColor(k, n)
101+ const ppwAll = d.nall.map((m) => (2 * d.N) / m)
102+ const ppwSel = d.sel.map((m) => (2 * d.N) / m)
103+ const measured = (k: number, abs: boolean) => ({
104+ y: d.meas[k].map((v) => (v == null ? null : abs ? Math.abs(v) : v)),
105+ color: col(k),
106+ shape: familyShapes[k],
107+ line: false,
108+ label: `λ = ${d.lams[k]}`,
109+ tip: (i: number) => [
110+ `partial ${d.sel[i]}, ${(d.sel[i] * d.f0).toFixed(0)} Hz`,
111+ `${fmtNum(ppwSel[i], 3)} points per wavelength`,
112+ `measured ${fmtCents(d.meas[k][i])}`,
113+ ],
114+ })
115+ return (
116+ <>
117+ <SeriesPlot
118+ title={`pitch error of every partial on a grid of N = ${d.N}: measured (markers) and the dispersion relation (lines)`}
119+ height={260}
120+ xLabel="partial frequency, Hz"
121+ yLabel="cents"
122+ series={d.lams.flatMap((_, k) => [
123+ { x: d.nall.map((m) => m * d.f0), y: d.pred[k], color: col(k), width: 1.2, noLegend: true },
124+ { x: d.sel.map((m) => m * d.f0), ...measured(k, false) },
125+ ])}
126+ />
127+ <SeriesPlot
128+ title="the same errors against points per wavelength: every grid falls on the same curves"
129+ height={240}
130+ xLog
131+ yLog
132+ xDomain={[1.8, 500]}
133+ yDomain={[1e-4, 1500]}
134+ xLabel="points per wavelength"
135+ yLabel="|cents|"
136+ hlines={[{ y: 5, label: 'a commonly quoted discrimination threshold, 5 cents' }]}
137+ refs={[{ p: -2, through: [40, 3], label: 'slope −2', span: 0.4 }]}
138+ series={d.lams.flatMap((l, k) =>
139+ l === 1
140+ ? []
141+ : [
142+ { x: ppwAll, y: d.pred[k].map((v) => (v == null ? null : Math.abs(v))), color: col(k), width: 1.2, noLegend: true },
143+ { x: ppwSel, ...measured(k, true) },
144+ ],
145+ )}
146+ />
147+ </>
148+ )
149+}
150+
151+function ClickPanels({ d }: { d: ClickOut }) {
152+ return (
153+ <>
154+ <Row>
155+ <SeriesPlot
156+ title="the first arrival, after 8.6 m"
157+ height={190}
158+ xLabel="ms after the exact arrival"
159+ yDomain={[-0.6, 1.1]}
160+ series={[
161+ { x: d.tw1, y: d.ye1, color: ink.reference, label: 'exact', width: 2.4 },
162+ { x: d.tw1, y: d.y1, color: method.fd2, label: 'leapfrog', width: 1.3 },
163+ ]}
164+ />
165+ <SeriesPlot
166+ title="the twentieth arrival, after 334 m"
167+ height={190}
168+ xLabel="ms after the exact arrival"
169+ yDomain={[-0.6, 1.1]}
170+ series={[
171+ { x: d.tw20, y: d.ye20, color: ink.reference, label: 'exact', width: 2.4 },
172+ { x: d.tw20, y: d.y20, color: method.fd2, label: 'leapfrog', width: 1.3 },
173+ ]}
174+ />
175+ </Row>
176+ <Field
177+ z={d.spec}
178+ xDomain={d.specT}
179+ yDomain={d.specF}
180+ height={260}
181+ kind="seq"
182+ reverse
183+ title="spectrogram of the leapfrog recording: time in seconds across, frequency in hertz up, level in dB"
184+ />
185+ <Listen
186+ items={[
187+ { label: 'exact', clip: d.clipExact },
188+ { label: 'leapfrog', clip: d.clipFd },
189+ ]}
190+ note="the same scale; the exact clip is a plain click train"
191+ />
192+ </>
193+ )
194+}
195+
196+export const ch02: Chapter = {
197+ id: 'leapfrog',
198+ nav: 'Leapfrog',
199+ part: 'Strings',
200+ title: 'Leapfrog, and the magic time step',
201+ blurb:
202+ 'The standard explicit scheme, its stability limit, the one Courant number at which it is exact, and what it sounds like everywhere else.',
203+ text,
204+ labs: [
205+ {
206+ id: 'run',
207+ title: 'A plucked string at the audio rate',
208+ caption:
209+ 'The time step is one sample at 44.1 kHz, so the grid sets the Courant number: λ = N/200. Try N = 200, then 100 and 50, then 201.',
210+ driver,
211+ script,
212+ controls: [
213+ {
214+ kind: 'slider',
215+ key: 'N',
216+ label: 'grid intervals N',
217+ min: 0,
218+ max: 0,
219+ values: [25, 50, 100, 150, 200, 201],
220+ format: (v: number) => `${v} (λ = ${(v / 200).toFixed(3)})`,
221+ },
222+ {
223+ kind: 'select',
224+ key: 'where',
225+ label: 'listen to',
226+ options: [
227+ { value: 'bridge', label: 'the first node off the bridge' },
228+ { value: 'pickup', label: 'a pickup at x = 0.12' },
229+ ],
230+ },
231+ ],
232+ defaults: { N: 100, where: 'bridge' },
233+ derive: (p: Params) => ({
234+ mode: 'run',
235+ fs: FS,
236+ f0: F0,
237+ N: p.N,
238+ T: 2.5,
239+ where: p.where,
240+ beta: 0.2,
241+ xp: 0.12,
242+ }),
243+ editorOpen: true,
244+ render: (d: RunOut) => <RunPanels d={d} />,
245+ summary: (d: RunOut) =>
246+ d.unstable
247+ ? [
248+ { label: 'Courant number λ', value: fmtNum(d.lam, 4) },
249+ { label: 'blew up after', value: `${fmtNum(d.blowup, 3)} ms` },
250+ ]
251+ : [
252+ { label: 'Courant number λ', value: fmtNum(d.lam, 4) },
253+ { label: 'rms error, last second', value: fmtErr(d.relerr) },
254+ { label: 'partial 1, predicted', value: fmtCents(d.pred1) },
255+ { label: 'partial 10, predicted', value: fmtCents(d.pred10) },
256+ ],
257+ },
258+ {
259+ id: 'cents',
260+ title: 'The pitch of every partial, measured',
261+ caption:
262+ 'Each marker is a frequency read off the simulation; each line is the dispersion relation. Change the grid and watch the lower plot not move.',
263+ driver,
264+ script,
265+ controls: [{ kind: 'slider', key: 'N', label: 'grid intervals N', min: 0, max: 0, values: [25, 50, 100, 200] }],
266+ defaults: { N: 100 },
267+ derive: (p: Params) => ({ mode: 'cents', f0: F0, N: p.N, lams: [0.25, 0.5, 0.75, 0.9, 1] }),
268+ render: (d: CentsOut) => <CentsPanels d={d} />,
269+ summary: (d: CentsOut) => [
270+ { label: 'measured minus predicted, largest', value: fmtCents(d.agree) },
271+ { label: 'error at λ = 1, largest', value: fmtCents(Math.max(...d.meas[d.meas.length - 1].map((v) => Math.abs(v ?? 0)))) },
272+ ],
273+ },
274+ {
275+ id: 'click',
276+ title: 'The sound of numerical dispersion',
277+ caption:
278+ 'A click travels round a 17.15 m loop of tube and passes a microphone twenty times a second. At λ = 1 it stays a click; below it, listen.',
279+ driver,
280+ script,
281+ controls: [
282+ {
283+ kind: 'select',
284+ key: 'lam',
285+ label: 'Courant number λ',
286+ options: [
287+ { value: 1, label: '1 (the magic time step)' },
288+ { value: 0.75, label: '0.75' },
289+ { value: 0.5, label: '0.5' },
290+ { value: 0.25, label: '0.25' },
291+ ],
292+ },
293+ ],
294+ defaults: { lam: 0.5 },
295+ derive: (p: Params) => ({ mode: 'click', fs: FS, c: 343, L: 17.15, lam: p.lam, T: 1.2, sigt: 3e-5 }),
296+ render: (d: ClickOut) => <ClickPanels d={d} />,
297+ summary: (d: ClickOut) => [
298+ { label: 'grid intervals', value: String(d.N) },
299+ { label: 'highest frequency the grid carries', value: `${(d.fcut / 1000).toFixed(2)} kHz` },
300+ { label: 'relative rms error', value: fmtErr(d.rel) },
301+ ],
302+ },
303+ ],
304+}
src/chapters/ch02/driver.madded+216−0View file
@@ -0,0 +1,216 @@
1+% Driver for Chapter 2: the leapfrog scheme on a string and in a tube.
2+%
3+% The editable script supplies
4+%
5+% [y, U] = leapfrog(u, uold, lam, M, bc, jr, K)
6+%
7+% which advances the scheme M steps from two given time levels.
8+%
9+% The driver supplies exact data at both starting levels, so that the only
10+% error left is the scheme's, and it measures that error against solutions it
11+% computes independently:
12+%
13+% mode 'run' a plucked string at the audio rate. The time step is one
14+% sample, 1/fs, and the grid is N intervals, so the Courant number
15+% is lam = 2 f0 N / fs and choosing the grid chooses lam. The
16+% reference is d'Alembert's solution at the receiver.
17+% mode 'cents' every partial's frequency, measured. The field is projected
18+% onto the discrete sine vectors sin(n pi j / N), which are exact
19+% eigenvectors of the three-point difference, so each projection
20+% is a single sinusoid whose frequency wv_peakfreq reads off.
21+% The exact frequency of partial n is n f0.
22+% mode 'click' a short pulse circulating in a periodic tube, recorded at one
23+% point, against the exact pulse train.
24+p = wv_params();
25+out = struct();
26+
27+if strcmp(p.mode, 'run')
28+ fs = p.fs;
29+ f0 = p.f0;
30+ N = p.N;
31+ c = 2 * f0; % a string of length 1
32+ h = 1 / N;
33+ dt = 1 / fs;
34+ lam = c * dt / h;
35+ M = round(p.T * fs);
36+ x = (0:N)' * h;
37+ u0 = drv_phi(x, p.beta);
38+ um = 0.5 * (drv_phi(x + c * dt, p.beta) + drv_phi(x - c * dt, p.beta));
39+ if strcmp(p.where, 'bridge')
40+ jr = 2; % the first node off the bridge
41+ else
42+ jr = round(p.xp * N) + 1;
43+ end
44+ t0 = tic;
45+ [y, ~] = leapfrog(u0, um, lam, M, 'fixed', jr, 0);
46+ out.ms = toc(t0) * 1000;
47+ t = (1:M)' * dt;
48+ ye = 0.5 * (drv_phi(x(jr) - c * t, p.beta) + drv_phi(x(jr) + c * t, p.beta));
49+
50+ sc = max(abs(ye));
51+ bad = find(~isfinite(y) | abs(y) > 50 * sc, 1);
52+ out.unstable = ~isempty(bad);
53+ if out.unstable
54+ out.blowup = bad * dt * 1000;
55+ y(bad:end) = 0;
56+ else
57+ out.blowup = NaN;
58+ end
59+ out.lam = lam;
60+ out.N = N;
61+ e = y - ye;
62+ % relative rms error, period by period
63+ P = fs / f0;
64+ np = floor(M / P);
65+ er = NaN(1, np);
66+ for k = 1:np
67+ i0 = floor((k - 1) * P) + 1;
68+ i1 = floor(k * P);
69+ er(k) = sqrt(sum(e(i0:i1) .^ 2) / sum(ye(i0:i1) .^ 2));
70+ end
71+ out.tp = ((1:np) - 0.5) / f0 * 1000;
72+ out.err = er;
73+ last = t > p.T - 1;
74+ out.relerr = sqrt(sum(e(last) .^ 2) / sum(ye(last) .^ 2));
75+ % three periods starting at one second, finely enough to see the drift
76+ w = find(t >= 1 & t < 1 + 3 / f0);
77+ out.tw = t(w)' * 1000;
78+ out.yw = y(w)';
79+ out.yew = ye(w)';
80+ % predicted pitch of partials 1 and 10, for the status strip
81+ out.pred1 = drv_pred(1, N, lam, dt, f0);
82+ out.pred10 = drv_pred(10, N, lam, dt, f0);
83+ out.clipExact = wv_audio(ye, fs, sc);
84+ out.clipFd = wv_audio(max(min(y, 2 * sc), -2 * sc), fs, sc);
85+ out.clipDiff = wv_audio(max(min(e, 2 * sc), -2 * sc), fs, sc);
86+elseif strcmp(p.mode, 'cents')
87+ f0 = p.f0;
88+ N = p.N;
89+ c = 2 * f0;
90+ h = 1 / N;
91+ x = (0:N)' * h;
92+ lams = p.lams(:)';
93+ nl = numel(lams);
94+ % the partials to measure: every one of the first ten, and twenty more
95+ % spread over the rest of the grid's range
96+ sel = unique([1:min(10, N - 1), round(linspace(1, N - 1, 20))]);
97+ S = sin(pi * sel(:) * (1:N - 1) / N); % discrete sine vectors
98+ nall = 1:N - 1;
99+ MEAS = NaN(nl, numel(sel));
100+ PRED = NaN(nl, N - 1);
101+ MS = NaN(1, nl);
102+ for k = 1:nl
103+ lam = lams(k);
104+ dt = lam * h / c;
105+ K = ceil(12 / (f0 * dt)); % twelve periods of the fundamental
106+ % every partial present, with amplitude 1/n, released from rest
107+ u0 = zeros(N + 1, 1);
108+ um = zeros(N + 1, 1);
109+ for n = nall
110+ u0 = u0 + sin(n * pi * x) / n;
111+ um = um + sin(n * pi * x) * cos(2 * pi * n * f0 * dt) / n;
112+ end
113+ t0 = tic;
114+ [~, U] = leapfrog(u0, um, lam, K, 'fixed', 1, K);
115+ MS(k) = toc(t0) * 1000;
116+ A = S * U(2:N, :);
117+ for i = 1:numel(sel)
118+ fm = wv_peakfreq(A(i, :), dt, 0, 0.5 / dt);
119+ MEAS(k, i) = wv_cents(fm, sel(i) * f0);
120+ end
121+ PRED(k, :) = drv_pred(nall, N, lam, dt, f0);
122+ end
123+ out.N = N;
124+ out.f0 = f0;
125+ out.lams = wv_vec(lams);
126+ out.sel = sel;
127+ out.nall = nall;
128+ out.meas = wv_rows(MEAS);
129+ out.pred = wv_rows(PRED);
130+ out.ms = wv_vec(MS);
131+ % the largest disagreement between measurement and prediction, in cents
132+ D = NaN(nl, numel(sel));
133+ for k = 1:nl
134+ D(k, :) = MEAS(k, :) - PRED(k, sel);
135+ end
136+ out.agree = max(abs(D(:)));
137+else
138+ % the click in a periodic tube
139+ c = p.c;
140+ fs = p.fs;
141+ dt = 1 / fs;
142+ L = p.L;
143+ N = round(L * p.lam / (c * dt));
144+ h = L / N;
145+ lam = c * dt / h;
146+ M = round(p.T * fs);
147+ x = (0:N - 1)' * h;
148+ sx = c * p.sigt;
149+ u0 = drv_train(x, 0, L, sx);
150+ um = drv_train(x + c * dt, 0, L, sx); % moving right: u(x, -dt) = g(x + c dt)
151+ jr = round(N / 2) + 1;
152+ t0 = tic;
153+ [y, ~] = leapfrog(u0, um, lam, M, 'periodic', jr, 0);
154+ out.ms = toc(t0) * 1000;
155+ t = (1:M)' * dt;
156+ ye = drv_train(x(jr) - c * t, 0, L, sx);
157+ out.N = N;
158+ out.lam = lam;
159+ out.fcut = asin(min(lam, 1)) / (pi * dt);
160+ sc = max(abs(ye));
161+ out.clipExact = wv_audio(ye, fs, sc);
162+ out.clipFd = wv_audio(y, fs, sc);
163+ % windows around the first and the twentieth arrival
164+ tp = L / c;
165+ ta = x(jr) / c;
166+ for k = [1, 20]
167+ tk = ta + (k - 1) * tp;
168+ w = find(t >= tk - 0.002 & t < tk + 0.020);
169+ out.(sprintf('tw%d', k)) = (t(w)' - tk) * 1000;
170+ out.(sprintf('y%d', k)) = y(w)' / sc;
171+ out.(sprintf('ye%d', k)) = ye(w)' / sc;
172+ end
173+ out.rel = sqrt(sum((y - ye) .^ 2) / sum(ye .^ 2));
174+ % spectrogram of the simulated recording
175+ nw = 512;
176+ hop = 256;
177+ nfr = floor((M - nw) / hop) + 1;
178+ win = wv_window(nw);
179+ nb = nw / 2 + 1;
180+ G = zeros(nb, nfr);
181+ for k = 1:nfr
182+ seg = y((k - 1) * hop + (1:nw));
183+ F = abs(fft(win .* seg));
184+ G(:, k) = F(1:nb);
185+ end
186+ G = max(20 * log10(max(G / max(G(:)), 1e-6)), -80);
187+ keep = 1:2:nb; % every other bin is plenty to draw
188+ out.spec = wv_rows(G(keep, :));
189+ out.specF = [0, (keep(end) - 1) * fs / nw];
190+ out.specT = [0, ((nfr - 1) * hop + nw / 2) / fs];
191+end
192+
193+wv_write(out);
194+
195+function v = drv_phi(x, beta)
196+% the plucked shape, extended oddly about 0 and periodically with period 2
197+r = mod(x, 2);
198+sg = ones(size(r));
199+sg(r > 1) = -1;
200+r(r > 1) = 2 - r(r > 1);
201+v = min(r / beta, (1 - r) / (1 - beta));
202+v = sg .* v;
203+end
204+
205+function g = drv_train(x, x0, L, sx)
206+% a Gaussian pulse of width sx at x0, repeated with period L
207+d = mod(x - x0 + L / 2, L) - L / 2;
208+g = exp(-0.5 * (d / sx) .^ 2);
209+end
210+
211+function c = drv_pred(n, N, lam, dt, f0)
212+% the leapfrog dispersion relation sin(w dt / 2) = lam sin(n pi / (2 N)),
213+% as a pitch error in cents against the exact n f0
214+w = 2 * asin(lam * sin(n * pi / (2 * N))) / dt;
215+c = wv_cents(w / (2 * pi), n * f0);
216+end
src/chapters/ch02/leapfrog.madded+49−0View file
@@ -0,0 +1,49 @@
1+% The leapfrog scheme for the wave equation u_tt = c^2 u_xx.
2+%
3+% Centred second differences in both time and space:
4+%
5+% u(j, n+1) = 2 u(j, n) - u(j, n-1) + lam^2 (u(j+1, n) - 2 u(j, n) + u(j-1, n)),
6+%
7+% with lam = c dt / h the Courant number. It is second-order accurate, it is
8+% stable exactly when lam <= 1, and it has a property that no other scheme in
9+% this tutorial shares: at lam = 1 it is not an approximation at all.
10+%
11+% u is the displacement at step 0 and uold at step -1, one entry per node.
12+% bc is 'fixed', in which case u has N+1 entries and the first and last are
13+% held at zero, or 'periodic', in which case it has N entries and node N is
14+% followed by node 1. The scheme advances M steps and returns
15+%
16+% y M-by-1 the displacement at node jr after each step
17+% U numel(u)-by-K the whole field after each of the first K steps
18+%
19+% The inner loop is written out in full, one node at a time; numbl compiles it,
20+% and in this form it is about six times faster than the vectorised slice.
21+
22+function [y, U] = leapfrog(u, uold, lam, M, bc, jr, K)
23+u = u(:);
24+uold = uold(:);
25+n1 = numel(u);
26+l2 = lam ^ 2;
27+y = zeros(M, 1);
28+U = zeros(n1, K);
29+unew = zeros(n1, 1);
30+periodic = strcmp(bc, 'periodic');
31+for n = 1:M
32+ for j = 2:n1 - 1
33+ unew(j) = 2 * u(j) - uold(j) + l2 * (u(j + 1) - 2 * u(j) + u(j - 1));
34+ end
35+ if periodic
36+ unew(1) = 2 * u(1) - uold(1) + l2 * (u(2) - 2 * u(1) + u(n1));
37+ unew(n1) = 2 * u(n1) - uold(n1) + l2 * (u(1) - 2 * u(n1) + u(n1 - 1));
38+ else
39+ unew(1) = 0;
40+ unew(n1) = 0;
41+ end
42+ uold = u;
43+ u = unew;
44+ y(n) = u(jr);
45+ if n <= K
46+ U(:, n) = u;
47+ end
48+end
49+end
src/chapters/ch02/text.mdadded+75−0View file
@@ -0,0 +1,75 @@
1+The modal sum of Chapter 1 needs the modes, and for a string of uniform density with fixed ends they are known. As soon as the string is not uniform, or is coupled to something, or the domain has more than one dimension and an awkward shape, they are not, and the equation has to be discretised directly. This chapter introduces the scheme that almost every wave computation in acoustics starts from, and measures what it does to pitch.
2+
3+### The scheme
4+
5+Put nodes at $x_j = jh$, $h = 1/N$, and times $t_n = n\,\Delta t$, and replace both second derivatives by centred second differences:
6+
7+$$u_j^{n+1} = 2u_j^n - u_j^{n-1} + \lambda^2\big(u_{j+1}^n - 2u_j^n + u_{j-1}^n\big), \qquad \lambda = \frac{c\,\Delta t}{h}.$$
8+
9+The dimensionless number $\lambda$ is the **Courant number**: the distance a wave travels in one step, measured in grid spacings. The scheme is explicit, second-order accurate in both $h$ and $\Delta t$, and needs two time levels to start. Here the driver supplies both exactly, from d'Alembert's solution at $t = 0$ and $t = -\Delta t$, so that the only error left is the scheme's.
10+
11+<!--fig:stencil-->
12+
13+Two properties decide everything that follows, and both come from putting a single Fourier mode $u_j^n = g^n e^{ikjh}$ into the scheme. The amplification factor $g$ then satisfies
14+
15+$$g + g^{-1} = 2 - 4\lambda^2 \sin^2(kh/2).$$
16+
17+If the right-hand side lies in $[-2, 2]$, the two roots are complex conjugates on the unit circle, $g = e^{\pm i\omega\Delta t}$, and the mode neither grows nor decays. Otherwise one root is real with magnitude above one and the mode grows exponentially. The right-hand side reaches $-2$ at the shortest wave on the grid, $kh = \pi$, exactly when $\lambda = 1$, so the scheme is **stable if and only if** $\lambda \le 1$. This is the Courant–Friedrichs–Lewy condition, and it has a plain meaning: the numerical domain of dependence, one node per step, must contain the physical one, $c\,\Delta t$ per step.
18+
19+Within the stable range the mode's frequency obeys the **dispersion relation**
20+
21+$$\sin\!\left(\frac{\omega\,\Delta t}{2}\right) = \lambda \sin\!\left(\frac{kh}{2}\right),$$
22+
23+where the exact relation is $\omega = ck$. Expanding both sides for small $kh$ gives
24+
25+$$\frac{\omega}{ck} = 1 - \frac{(1 - \lambda^2)(kh)^2}{24} + O\big((kh)^4\big),$$
26+
27+so every wave on the grid travels slightly too slowly, the shorter waves more so. With $kh = 2\pi/P$, where $P$ is the number of grid points per wavelength, the pitch error in cents is about
28+
29+$$-\frac{1731\,\pi^2(1 - \lambda^2)}{6P^2} \approx -\frac{2847\,(1 - \lambda^2)}{P^2}.$$
30+
31+<!--fig:dispersion-->
32+
33+### The magic time step
34+
35+At $\lambda = 1$ the leading error term vanishes, and so do all the others: the dispersion relation becomes $\omega\Delta t = kh$, which is $\omega = ck$ exactly, for every mode the grid can hold. The reason is visible in the scheme itself. At $\lambda = 1$ it reads $u_j^{n+1} + u_j^{n-1} = u_{j+1}^n + u_{j-1}^n$, and any function of the form $F(j - n) + G(j + n)$ satisfies that identically. Those are d'Alembert's two travelling waves, moving exactly one node per step. The scheme is then not an approximation to the wave equation on the grid; it is its exact solution, sampled.
36+
37+The lab runs a plucked string at the audio rate: the time step is one sample at 44.1 kHz, the grid has $N$ intervals, and the fundamental is 110.25 Hz, chosen so that $N = 200$ gives $\lambda = 1$ exactly. Choosing the grid therefore chooses the Courant number, $\lambda = N/200$.
38+
39+<!--lab:run-->
40+
41+At $N = 200$ the recorded signal agrees with d'Alembert's solution to a relative rms error of $1.3\times10^{-12}$, which is rounding error accumulated over 110,250 steps. It agrees with d'Alembert's solution, not with the band-limited modal sum: the grid reproduces the exact solution at its nodes, corners and all, and so it also reproduces the aliasing of Chapter 1. That aliasing is harmonic here, because the fundamental was chosen so that $f_s = 400 f_0$ exactly, and every folded partial lands on another harmonic.
42+
43+At $N = 100$ the Courant number is $0.5$, and the error is no longer small: the relative rms difference over the last second is $0.72$. The fundamental is only 0.05 cents flat and the tenth partial 5.4 cents flat, but the partials above the twentieth are flat by tens or hundreds of cents, and the grid has no partials above the 99th at all. Listen to the two recordings: the coarse grid's string is duller, because it has lost its upper partials, and its remaining upper partials are no longer in tune with the lower ones, which gives it a faintly clangy quality. At $N = 50$ the error is 1.04 and the tenth partial is 27 cents flat.
44+
45+At $N = 201$, $\lambda = 1.005$, and the scheme blows up after 69 steps, 1.6 ms. The instability starts in the shortest wave on the grid, $kh = \pi$, which is exactly where the stability condition first fails, and it grows by a factor of about 1.2 per step. The clip is silenced from the moment the recording exceeds fifty times the size of the exact signal.
46+
47+### Every partial, measured
48+
49+The run above measures the waveform. The next lab measures what a listener would hear, the pitch of each partial. Because the discrete sine vectors $\sin(n\pi j/N)$ are exact eigenvectors of the three-point difference, projecting the computed field onto one of them gives a single pure sinusoid, and `wv_peakfreq` reads its frequency off the recording. The measured frequencies are then compared with the exact $n f_0$. Unlike the previous lab, this one decouples the time step from the audio rate, so that five Courant numbers can be compared on the same grid.
50+
51+<!--lab:cents-->
52+
53+The largest disagreement between the measured pitch errors and the dispersion relation is below $10^{-7}$ cents, so the dispersion relation is not a model of this scheme's behaviour but an exact description of it, and the measurement procedure is accurate enough to confirm it. At $\lambda = 1$ every measured error is below $10^{-7}$ cents.
54+
55+The lower plot is the useful one. Against points per wavelength, the curves for every grid lie on top of each other, and at high resolution each falls with slope $-2$, as the expansion predicts. Reading off where each crosses the 5-cent line gives the resolution a string needs: about 21 points per wavelength at $\lambda = 0.5$, 16 at $\lambda = 0.75$ and 11 at $\lambda = 0.9$. At $\lambda = 0.25$ it is 23.
56+
57+The plot also shows how bad the scheme is at the other end. Near two points per wavelength the error is several hundred cents, more than a semitone. A grid does not represent its shortest waves inaccurately; it represents them as different waves.
58+
59+### The sound of dispersion
60+
61+For a standing wave, dispersion is a wrong pitch. For a travelling one, it is a wrong arrival time that depends on frequency. The last lab sends a click, a Gaussian pulse about $0.03$ ms wide, round a 17.15 m loop of tube, and records it at one point as it passes, twenty times a second. The tube is periodic, the time step is one sample, and the Courant number is chosen from a list.
62+
63+<!--lab:click-->
64+
65+At $\lambda = 1$ the recording is the exact pulse train, to a relative error of $3\times10^{-11}$. At $\lambda = 0.5$ each click arrives followed by a descending whistle, and by the twentieth pass the whistle has spread over more than the interval between clicks. Two effects combine. The grid cannot carry frequencies above $f_s \arcsin(\lambda)/\pi$ at all, which is 7.35 kHz at $\lambda = 0.5$ and 3.55 kHz at $\lambda = 0.25$, so the click loses its top. And the frequencies it can carry travel at the group velocity
66+
67+$$v_g = c\,\frac{\cos(kh/2)}{\cos(\omega\Delta t/2)},$$
68+
69+which at 5 kHz and $\lambda = 0.5$ is about three quarters of the speed of sound. After 334 m that frequency arrives about 0.3 s late. The spectrogram shows it directly: each click is a falling curve, highest frequencies last, and the curves lean further with each pass.
70+
71+This is the characteristic sound of numerical dispersion in an explicit scheme, and it is worth learning to recognise, because it appears whenever a simulation runs for many wavelengths at too coarse a resolution. It is also the reason that the waveform error of this recording, 1.7 at $\lambda = 0.5$, says so little: the error is not noise but a smearing of each click in time, and it grows with distance.
72+
73+### What this chapter establishes
74+
75+In one dimension the leapfrog scheme has a magic time step, $\lambda = 1$, at which it is exact. Below it, every wave is slow by an amount that depends on its length, the error in pitch is $-2847(1-\lambda^2)/P^2$ cents at $P$ points per wavelength, and between 11 and 23 points per wavelength are needed for errors below 5 cents, depending on how close to 1 the Courant number is. In two and three dimensions there is no magic time step, and Chapters 4 and 5 are about what that costs. First, though, Chapter 3 takes the magic time step seriously and removes the grid altogether.
src/chapters/ch03/chapter.tsxadded+165−0View file
@@ -0,0 +1,165 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Num, Params } from '../../engine/types.ts'
3+import driver from './driver.m?raw'
4+import script from './ks.m?raw'
5+import text from './text.md?raw'
6+import Listen, { type Clip } from '../../audio/Listen.tsx'
7+import { SeriesPlot, fmtCents, fmtErr, fmtMs } from '../common/panels.tsx'
8+import { method } from '../../plot/palette.ts'
9+import { FS, noteName } from '../shared.ts'
10+
11+interface EquivOut {
12+ Ns: number[]
13+ tl: number[]
14+ tw: number[]
15+ diff: number[]
16+ M: number
17+}
18+
19+interface KeysOut {
20+ notes: number[]
21+ f: number[]
22+ tunings: string[]
23+ cents: Num[][]
24+ t60: Num[][]
25+ clipInt: Clip
26+ clipLin: Clip
27+ clipAp: Clip
28+ clipExact: Clip
29+}
30+
31+const TUNE = [
32+ { label: 'whole-sample loop', color: method.fd2, shape: 'circle' as const },
33+ { label: 'linear interpolation', color: method.alt, shape: 'square' as const },
34+ { label: 'allpass', color: method.waveguide, shape: 'diamond' as const },
35+]
36+
37+function EquivPanels({ d }: { d: EquivOut }) {
38+ const tip = (i: number) => [`N = ${d.Ns[i]}`, `leapfrog ${fmtMs(d.tl[i])}`, `delay loop ${fmtMs(d.tw[i])}`]
39+ return (
40+ <>
41+ <SeriesPlot
42+ title="time to compute one second of sound at 44.1 kHz, against the number of grid intervals"
43+ height={240}
44+ xLog
45+ yLog
46+ xLabel="grid intervals N"
47+ yLabel="ms"
48+ refs={[{ p: 1, through: [400, 200], label: 'slope 1', span: 0.5 }]}
49+ series={[
50+ { x: d.Ns, y: d.tl, color: method.fd2, shape: 'circle', label: 'leapfrog at λ = 1', endLabel: 'leapfrog', tip },
51+ { x: d.Ns, y: d.tw, color: method.waveguide, shape: 'diamond', label: 'delay loop', endLabel: 'delay loop', tip },
52+ ]}
53+ />
54+ <SeriesPlot
55+ title="largest difference between the two recordings, relative to the signal"
56+ height={170}
57+ xLog
58+ yLog
59+ yDomain={[1e-16, 1e-8]}
60+ xLabel="grid intervals N"
61+ series={[{ x: d.Ns, y: d.diff, color: method.waveguide, shape: 'diamond', tip: (i) => [`N = ${d.Ns[i]}`, fmtErr(d.diff[i])] }]}
62+ legend={false}
63+ />
64+ </>
65+ )
66+}
67+
68+function KeysPanels({ d }: { d: KeysOut }) {
69+ const tip = (k: number) => (i: number) => [
70+ `${noteName(d.notes[i])}, ${d.f[i].toFixed(1)} Hz`,
71+ `pitch ${fmtCents(d.cents[k][i])}`,
72+ `T60 ${(d.t60[k][i] ?? NaN).toPrecision(3)} s`,
73+ ]
74+ return (
75+ <>
76+ <SeriesPlot
77+ title="pitch error of the fundamental, measured from the sound, against the equal-tempered target"
78+ height={240}
79+ xLog
80+ xLabel="note frequency, Hz"
81+ yLabel="cents"
82+ hlines={[
83+ { y: 5, label: '±5 cents' },
84+ { y: -5 },
85+ ]}
86+ series={TUNE.map((t, k) => ({ x: d.f, y: d.cents[k], color: t.color, shape: t.shape, label: t.label, width: 1.4, tip: tip(k) }))}
87+ />
88+ <SeriesPlot
89+ title="time for the fundamental to decay by 60 dB"
90+ height={220}
91+ xLog
92+ yLog
93+ xLabel="note frequency, Hz"
94+ yLabel="seconds"
95+ refs={[{ p: -3, through: [262, 150], label: 'slope −3', span: 0.5, labelAt: 'end' }]}
96+ series={TUNE.map((t, k) => ({ x: d.f, y: d.t60[k], color: t.color, shape: t.shape, label: t.label, width: 1.4, tip: tip(k) }))}
97+ />
98+ <Listen
99+ items={[
100+ { label: 'exact pitch', clip: d.clipExact },
101+ { label: 'whole-sample loop', clip: d.clipInt },
102+ { label: 'linear interpolation', clip: d.clipLin },
103+ { label: 'allpass', clip: d.clipAp },
104+ ]}
105+ note="a C major scale from C6 to C8"
106+ />
107+ </>
108+ )
109+}
110+
111+export const ch03: Chapter = {
112+ id: 'waveguide',
113+ nav: 'The digital waveguide',
114+ part: 'Strings',
115+ title: 'The digital waveguide',
116+ blurb:
117+ 'Leapfrog at the magic time step without the grid: a delay loop that costs one operation per sample, and the tuning problem that comes with it.',
118+ text,
119+ labs: [
120+ {
121+ id: 'equiv',
122+ title: 'The same sound, without the grid',
123+ caption:
124+ 'The loop is seeded with the first 2N samples of the leapfrog recording and then left to itself. The two agree to rounding error for the rest of the second.',
125+ driver,
126+ script,
127+ controls: [],
128+ defaults: {},
129+ derive: () => ({ mode: 'equiv', fs: FS, T: 1, beta: 0.2, Ns: [25, 50, 100, 200, 400, 800, 1600, 3200] }),
130+ render: (d: EquivOut) => <EquivPanels d={d} />,
131+ summary: (d: EquivOut) => [
132+ { label: 'leapfrog at N = 3200', value: fmtMs(d.tl[d.tl.length - 1]) },
133+ { label: 'delay loop', value: fmtMs(d.tw[d.tw.length - 1]) },
134+ { label: 'largest difference', value: fmtErr(Math.max(...d.diff)) },
135+ ],
136+ },
137+ {
138+ id: 'keys',
139+ title: 'Across the keyboard',
140+ caption:
141+ 'One note on every third key from C2 to C8, with each of three ways of making the loop the right length. Then listen to a scale played high up, where the differences are.',
142+ driver,
143+ script,
144+ controls: [],
145+ defaults: {},
146+ derive: (_p: Params) => ({
147+ mode: 'keys',
148+ fs: FS,
149+ beta: 0.2,
150+ notes: Array.from({ length: 25 }, (_, i) => 36 + 3 * i),
151+ Tn: 0.5,
152+ scale: [84, 86, 88, 89, 91, 93, 95, 96, 98, 100, 101, 103, 105, 107, 108],
153+ step: 0.22,
154+ ring: 0.9,
155+ }),
156+ editorOpen: true,
157+ render: (d: KeysOut) => <KeysPanels d={d} />,
158+ summary: (d: KeysOut) =>
159+ TUNE.map((t, k) => ({
160+ label: `${t.label}, worst`,
161+ value: fmtCents(d.cents[k].reduce<number>((m, v) => (Math.abs(v ?? 0) > Math.abs(m) ? (v as number) : m), 0)),
162+ })),
163+ },
164+ ],
165+}
src/chapters/ch03/driver.madded+177−0View file
@@ -0,0 +1,177 @@
1+% Driver for Chapter 3: the digital waveguide and the Karplus-Strong string.
2+%
3+% The editable script supplies
4+%
5+% y = ks(x0, D, tuning, M)
6+%
7+% a delay loop of length D samples, started from x0.
8+%
9+% mode 'equiv' the lossless loop against the leapfrog scheme of Chapter 2 at
10+% lam = 1, on grids from 25 to 3200 intervals. The leapfrog
11+% scheme is run by the driver's own copy; its first 2N output
12+% samples seed the loop, and the two are then compared for the
13+% rest of the run, sample for sample, and timed.
14+% mode 'keys' one note on each of 25 keys from C2 to C8, with each tuning.
15+% The fundamental's frequency is measured by wv_peakfreq and
16+% compared with the equal-tempered target; its decay time is
17+% measured from the envelope. A two-octave scale is also played
18+% with each tuning, and once by exact modal synthesis at the
19+% target pitches, for the ear to compare.
20+p = wv_params();
21+fs = p.fs;
22+out = struct();
23+
24+if strcmp(p.mode, 'equiv')
25+ Ns = p.Ns(:)';
26+ M = round(p.T * fs);
27+ TL = NaN(1, numel(Ns));
28+ TW = NaN(1, numel(Ns));
29+ DIFF = NaN(1, numel(Ns));
30+ for k = 1:numel(Ns)
31+ N = Ns(k);
32+ x = (0:N)' / N;
33+ u0 = min(x / p.beta, (1 - x) / (1 - p.beta));
34+ um = 0.5 * ([u0(2:end); 0] + [0; u0(1:end-1)]); % exact at lam = 1
35+ um(1) = 0;
36+ um(end) = 0;
37+ drv_lf(u0, um, 50);
38+ t0 = tic;
39+ yl = drv_lf(u0, um, M);
40+ TL(k) = toc(t0) * 1000;
41+ seed = yl(1:2 * N);
42+ ks(seed, 2 * N, 'lossless', 4 * N);
43+ t0 = tic;
44+ yw = ks(seed, 2 * N, 'lossless', M);
45+ TW(k) = toc(t0) * 1000;
46+ DIFF(k) = max(abs(yw - yl)) / max(abs(yl));
47+ end
48+ out.Ns = Ns;
49+ out.tl = TL;
50+ out.tw = TW;
51+ out.diff = DIFF;
52+ out.M = M;
53+else
54+ notes = p.notes(:)';
55+ tunings = {'integer', 'linear', 'allpass'};
56+ nt = numel(tunings);
57+ Mn = round(p.Tn * fs);
58+ CE = NaN(nt, numel(notes));
59+ T60 = NaN(nt, numel(notes));
60+ for i = 1:numel(notes)
61+ f = wv_note(notes(i));
62+ D = fs / f;
63+ x0 = drv_seed(f, fs, p.beta);
64+ for k = 1:nt
65+ y = ks(x0, D, tunings{k}, Mn);
66+ fm = wv_peakfreq(y, 1 / fs, f / 1.1, f * 1.1);
67+ CE(k, i) = wv_cents(fm, f);
68+ T60(k, i) = drv_t60(y, fm, fs);
69+ end
70+ end
71+ out.notes = notes;
72+ out.f = wv_note(notes);
73+ out.tunings = tunings;
74+ out.cents = wv_rows(CE);
75+ out.t60 = wv_rows(T60);
76+
77+ % a scale, played with each tuning and exactly
78+ sc = p.scale(:)';
79+ step = round(p.step * fs);
80+ ring = round(p.ring * fs);
81+ Mt = step * (numel(sc) - 1) + ring;
82+ Y = zeros(Mt, nt + 1);
83+ for i = 1:numel(sc)
84+ f = wv_note(sc(i));
85+ x0 = drv_seed(f, fs, p.beta);
86+ idx = (i - 1) * step + (1:ring);
87+ for k = 1:nt
88+ Y(idx, k) = Y(idx, k) + ks(x0, fs / f, tunings{k}, ring);
89+ end
90+ % the reference: the same pluck, modally, at exactly the target pitch,
91+ % with each partial given the decay the integer loop gives it
92+ t = (0:ring - 1)' / fs;
93+ Y(idx, nt + 1) = Y(idx, nt + 1) + drv_modal(t, f, fs, p.beta);
94+ end
95+ s = max(abs(Y(:)));
96+ out.clipInt = wv_audio(Y(:, 1), fs, s);
97+ out.clipLin = wv_audio(Y(:, 2), fs, s);
98+ out.clipAp = wv_audio(Y(:, 3), fs, s);
99+ out.clipExact = wv_audio(Y(:, 4), fs, s);
100+ out.scale = sc;
101+end
102+
103+wv_write(out);
104+
105+function y = drv_lf(u, uold, M)
106+% leapfrog at lam = 1 on a fixed string, recording the first node off the end
107+n1 = numel(u);
108+y = zeros(M, 1);
109+unew = zeros(n1, 1);
110+for n = 1:M
111+ for j = 2:n1 - 1
112+ unew(j) = u(j + 1) + u(j - 1) - uold(j);
113+ end
114+ uold = u;
115+ u = unew;
116+ y(n) = u(2);
117+end
118+end
119+
120+function x0 = drv_seed(f, fs, beta)
121+% The loop's initial content, the same for every tuning: the band-limited
122+% bridge force of a string plucked at beta, for a little over one period,
123+% because the loop reads up to two samples further back than its length.
124+P = ceil(fs / f) + 2;
125+t = (0:P - 1)' / fs;
126+x0 = zeros(P, 1);
127+for n = 1:floor(fs / 2 / f)
128+ x0 = x0 + 2 * sin(n * pi * beta) / (n * pi * beta * (1 - beta)) * cos(2 * pi * n * f * t);
129+end
130+% A loop with a two-point average in it passes a constant unchanged for
131+% ever, so a seed with a nonzero mean leaves a DC offset that never decays.
132+% It is inaudible, but it would sit under the fundamental's decay and spoil
133+% the measurement of it, so the seed is made to have none.
134+x0 = x0 - mean(x0);
135+end
136+
137+function y = drv_modal(t, f, fs, beta)
138+% the same pluck by modal synthesis, each partial decaying as the two-point
139+% average makes it decay in a loop of exactly the right length
140+y = zeros(size(t));
141+for n = 1:floor(fs / 2 / f)
142+ g = abs(cos(pi * n * f / fs));
143+ s = -log(max(g, 1e-300)) * f; % amplitude lost per second
144+ y = y + 2 * sin(n * pi * beta) / (n * pi * beta * (1 - beta)) * exp(-s * t) .* cos(2 * pi * n * f * t);
145+end
146+end
147+
148+function T = drv_t60(y, f, fs)
149+% decay time of the partial at f: its amplitude in successive tapered frames,
150+% a straight line fitted to the logarithm, extrapolated to -60 dB
151+nw = round(8 * fs / f);
152+nw = max(nw, 256);
153+hop = nw;
154+nfr = floor((numel(y) - nw) / hop);
155+w = wv_window(nw);
156+tt = (0:nw - 1)' / fs;
157+e = exp(-2i * pi * f * tt);
158+a = zeros(nfr, 1);
159+tc = zeros(nfr, 1);
160+for k = 1:nfr
161+ seg = y((k - 1) * hop + (1:nw));
162+ a(k) = abs(sum(w .* seg .* e));
163+ tc(k) = ((k - 1) * hop + nw / 2) / fs;
164+end
165+% only the frames within 100 dB of the loudest, where the partial is still
166+% far above rounding error and the window's leakage from anything else
167+use = find(a > 1e-5 * max(a));
168+use = use(use > 1);
169+if numel(use) < 3
170+ use = 2:min(4, nfr);
171+end
172+c = polyfit(tc(use), log(a(use)), 1);
173+T = -log(1000) / c(1);
174+if c(1) >= 0
175+ T = Inf;
176+end
177+end
src/chapters/ch03/ks.madded+67−0View file
@@ -0,0 +1,67 @@
1+% The Karplus-Strong plucked string: a delay loop with a filter in it.
2+%
3+% Chapter 2 showed that at lam = 1 the leapfrog scheme is exact, and that the
4+% signal at the first node off the bridge repeats itself every 2N steps. A
5+% sequence that repeats itself needs no grid: it needs a buffer of one period
6+% and a rule for what comes next,
7+%
8+% y(n) = y(n - D)
9+%
10+% which is the lossless digital waveguide, at a cost of one sample per sample
11+% instead of N. Karplus and Strong's observation (1983) was that putting a
12+% two-point average inside the loop,
13+%
14+% y(n) = ( y(n - P) + y(n - P - 1) ) / 2,
15+%
16+% makes each partial lose a little on every trip round, the high ones more
17+% than the low ones, which is what a real string does. The average also
18+% delays the loop by half a sample, so the loop length is P + 1/2.
19+%
20+% The pitch is fs / (loop length), and a loop made of whole samples can only
21+% be so long. The `tuning` argument chooses how the missing fraction eta of a
22+% sample is supplied:
23+%
24+% 'lossless' y(n) = y(n - D), D a whole number: the waveguide itself
25+% 'integer' round the loop to the nearest whole length and accept the error
26+% 'linear' interpolate linearly between two taps, a delay of eta that is
27+% also a low-pass filter
28+% 'allpass' a first-order allpass, coefficient (1 - eta)/(1 + eta), whose
29+% delay is eta at low frequency and whose gain is exactly one
30+%
31+% x0 is the loop's initial content, played out first; D is the desired loop
32+% length in samples, fs / f0; M is the number of samples to return.
33+
34+function y = ks(x0, D, tuning, M)
35+y = zeros(M, 1);
36+n0 = numel(x0);
37+y(1:n0) = x0(:);
38+if strcmp(tuning, 'lossless')
39+ P = round(D);
40+ for n = n0 + 1:M
41+ y(n) = y(n - P);
42+ end
43+elseif strcmp(tuning, 'integer')
44+ P = round(D - 0.5);
45+ for n = n0 + 1:M
46+ y(n) = 0.5 * (y(n - P) + y(n - P - 1));
47+ end
48+elseif strcmp(tuning, 'linear')
49+ P = floor(D - 0.5);
50+ eta = D - 0.5 - P;
51+ for n = n0 + 1:M
52+ y(n) = 0.5 * ((1 - eta) * y(n - P) + y(n - P - 1) + eta * y(n - P - 2));
53+ end
54+else
55+ % keep eta in [0.1, 1.1): an allpass with eta near zero has its pole near
56+ % the unit circle and takes a long time to settle
57+ P = floor(D - 0.6);
58+ eta = D - 0.5 - P;
59+ C = (1 - eta) / (1 + eta);
60+ wprev = 0;
61+ for n = n0 + 1:M
62+ w = 0.5 * (y(n - P) + y(n - P - 1));
63+ y(n) = C * w + wprev - C * y(n - 1);
64+ wprev = w;
65+ end
66+end
67+end
src/chapters/ch03/text.mdadded+55−0View file
@@ -0,0 +1,55 @@
1+Chapter 2 found one setting in which a numerical scheme for the wave equation is exact: the leapfrog scheme in one dimension at Courant number 1. This chapter takes that result seriously. If the scheme is exact, its grid is doing nothing but moving numbers one node along per step, and a computation that only moves numbers along can be done without the grid.
2+
3+### The grid is a delay line
4+
5+At $\lambda = 1$ the solution on the grid is $u_j^n = F(j-n) + G(j+n)$: a right-moving sequence and a left-moving one, each shifted by one node per step. At a fixed end each is reflected into the other with a change of sign. So the whole state of the string is two sequences of length $N$ travelling in opposite directions, which is two **delay lines** joined at their ends by inverting reflections; this is the **digital waveguide** of Smith (1992). Following one sample round the loop, it travels $2N$ nodes and is inverted twice, so it returns unchanged after $2N$ steps, and the signal at any node repeats itself with period $2N$ samples. The pitch is $f_s/(2N)$.
6+
7+A signal that repeats itself with period $D$ needs no grid at all. It needs a buffer holding one period and the rule
8+
9+$$y_n = y_{n-D},$$
10+
11+which costs one operation per sample instead of $N$.
12+
13+<!--fig:loop-->
14+
15+<!--lab:equiv-->
16+
17+The lab runs Chapter 2's scheme at $\lambda = 1$ for one second of sound on grids from 25 to 3200 intervals, seeds the loop with the first $2N$ samples of the result, and lets the loop run on its own for the rest of the second. The two recordings agree to a relative difference of about $10^{-11}$, the rounding error of the leapfrog run. The cost is the point. On this machine the leapfrog scheme takes time proportional to $N$, about a second at $N = 3200$, while the loop takes about a millisecond whatever the length, which at $N = 3200$ is a factor of several hundred; in the browser it is larger still. The comparison is not quite fair to the grid, which also computes the displacement everywhere along the string, but a listener only hears one point.
18+
19+### A string that decays
20+
21+A lossless loop rings for ever. Karplus and Strong (1983) observed that putting a two-point average inside it,
22+
23+$$y_n = \tfrac12\big(y_{n-P} + y_{n-P-1}\big),$$
24+
25+makes it sound like a plucked string. The average is a filter with gain $\cos(\omega/2f_s)$ at angular frequency $\omega$, which is one at zero frequency and falls towards the Nyquist frequency, so each partial loses a little on every trip round the loop and the high partials lose more. It also delays every frequency by exactly half a sample, so the loop's length is $P + \tfrac12$ samples and its pitch is $f_s/(P + \tfrac12)$.
26+
27+The decay this gives is fixed by the filter and the pitch together. The fundamental loses a factor $\cos(\pi f_0/f_s)$ per period, so it takes
28+
29+$$T_{60} = \frac{-3}{f_0 \log_{10}\cos(\pi f_0/f_s)} \approx \frac{1.4\, f_s^2}{f_0^3}$$
30+
31+seconds to fall by 60 dB. Every octave up, the decay time falls by a factor of eight.
32+
33+### The tuning problem
34+
35+The loop's length is a whole number of samples plus one half, so the pitches it can play are $f_s/(P + \tfrac12)$ and nothing in between. For a note of frequency $f$ the desired length is $D = f_s/f$, and rounding it to the nearest available length leaves an error of up to half a sample in $D$, which is a pitch error of up to $866/D$ cents. At A2, $D = 401$ and the worst case is about two cents. At C7, $D = 21$ and it is about forty. At the top of the piano, $D = 10.5$ and it is about eighty.
36+
37+The remedy is a delay of a fraction $\eta$ of a sample, and the script offers two. **Linear interpolation** between two taps, $(1-\eta)y_{n-P} + \eta\,y_{n-P-1}$, has exactly the right delay at low frequency but is itself a low-pass filter, so it adds to the loop's loss by an amount that depends on $\eta$. A first-order **allpass** filter,
38+
39+$$H(z) = \frac{C + z^{-1}}{1 + Cz^{-1}}, \qquad C = \frac{1-\eta}{1+\eta},$$
40+
41+has gain exactly one at every frequency, so it adds no loss, and a delay that is $\eta$ at low frequency and drifts away from it towards the Nyquist frequency (Jaffe and Smith 1983).
42+
43+<!--lab:keys-->
44+
45+The measurements, one note on every third key from C2 to C8, confirm all three statements. With the loop rounded to a whole length, the pitch error is within 2 cents up to about 260 Hz, grows roughly in proportion to the note's frequency above that, and reaches 47 cents at F♯7, nearly a quarter-tone. Both fractional delays fix it: the worst error with linear interpolation is 0.14 cents and with the allpass 0.63 cents, both at the top of the keyboard, where a partial at a sizeable fraction of the sampling rate sees the filters' frequency-dependent delay.
46+
47+The decay plot shows the price of each. The whole-sample loop and the allpass decay identically, along a line of slope $-3$: a C2 would ring for about 9700 seconds, nearly three hours, an A4 for 32 seconds, a C6 for 2.4 seconds and a C8 for 36 milliseconds. None of these is what a real string does, and the enormous spread is the best-known weakness of the plain Karplus–Strong string. Linear interpolation cuts the decay time by up to a half, erratically from note to note, because its loss depends on $\eta$, which jumps as the note changes.
48+
49+The scale is played high up, from C6 to C8, where the differences are. Against the exact-pitch reference, played by modal synthesis with the same decay per partial, the whole-sample loop is audibly out of tune on several notes. The two tuned versions are close to the reference in pitch, and differ from each other mainly in how long the notes ring.
50+
51+### What this chapter establishes
52+
53+In one dimension an exact scheme can be run without its grid, and the result costs one operation per sample. This is why digital waveguides and Karplus–Strong strings, rather than finite-difference grids, are what synthesisers actually use for strings and tubes. The price is that the loop's length is quantised, and a fractional-delay filter is needed to play in tune; the allpass is the usual choice because it adds no loss.
54+
55+The limitations are as clear as the strengths. The waveguide needs a uniform medium, so that waves travel at one speed, and a geometry in which they travel along lines. A stiff string, whose partials are sharp by an amount that grows with frequency, needs further filters in the loop, and a membrane or a room cannot be reduced to a loop at all: in two and three dimensions waves spread in every direction and there is no Courant number at which the leapfrog scheme is exact. That is where Part II begins.
src/chapters/ch04/chapter.tsxadded+61−0View file
@@ -0,0 +1,61 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Params } from '../../engine/types.ts'
3+import driver from '../membrane/driver.m?raw'
4+import script from './drum.m?raw'
5+import text from './text.md?raw'
6+import {
7+ CentsPanels,
8+ STRIKE_CONTROL,
9+ StrikePanels,
10+ strikeParams,
11+ strikeSummary,
12+ type CentsOut,
13+ type StrikeOut,
14+} from '../membrane/panels.tsx'
15+import { fmtCents } from '../common/panels.tsx'
16+
17+export const ch04: Chapter = {
18+ id: 'square',
19+ nav: 'A square drum',
20+ part: 'Drums',
21+ title: 'A square drum',
22+ blurb:
23+ 'The same scheme in two dimensions: an inharmonic set of partials, a stability limit of 1/√2, and a pitch error that depends on the direction a wave travels.',
24+ text,
25+ labs: [
26+ {
27+ id: 'strike',
28+ title: 'Striking the drum',
29+ caption:
30+ 'The fundamental is 150 Hz. Listen to the exact drum first, then to the grid; move the strike to the centre and hear three quarters of the modes disappear.',
31+ driver,
32+ script,
33+ controls: [
34+ { kind: 'slider', key: 'N', label: 'grid intervals N', min: 0, max: 0, values: [24, 32, 48, 64] },
35+ { kind: 'slider', key: 'lam', label: 'Courant number λ', min: 0, max: 0, values: [0.3, 0.5, 0.7] },
36+ STRIKE_CONTROL,
37+ ],
38+ defaults: { N: 48, lam: 0.7, at: 'off' },
39+ derive: (p: Params) => strikeParams(p, 'fd2'),
40+ editorOpen: true,
41+ render: (d: StrikeOut) => <StrikePanels d={d} />,
42+ summary: strikeSummary,
43+ },
44+ {
45+ id: 'cents',
46+ title: 'Pitch error in every direction',
47+ caption:
48+ 'Modes at three wavelengths, at directions from nearly along an edge to nearly along the other. Markers are measured; lines are the dispersion relation at the same modes.',
49+ driver,
50+ script,
51+ controls: [{ kind: 'slider', key: 'N', label: 'grid intervals N', min: 0, max: 0, values: [32, 64] }],
52+ defaults: { N: 64 },
53+ derive: (p: Params) => {
54+ const N = Number(p.N)
55+ return { mode: 'cents', N, lams: [0.3, 0.5, Math.SQRT1_2], rs: [N / 8, N / 4, N / 2], nang: 9 }
56+ },
57+ render: (d: CentsOut) => <CentsPanels d={d} />,
58+ summary: (d: CentsOut) => [{ label: 'measured minus predicted, largest', value: fmtCents(d.agree) }],
59+ },
60+ ],
61+}
src/chapters/ch04/drum.madded+38−0View file
@@ -0,0 +1,38 @@
1+% The leapfrog scheme on a square membrane.
2+%
3+% The two-dimensional version of Chapter 2's scheme, with the five-point
4+% Laplacian in space:
5+%
6+% u(i,j,n+1) = 2 u(i,j,n) - u(i,j,n-1)
7+% + lam^2 (u(i+1,j) + u(i-1,j) + u(i,j+1) + u(i,j-1) - 4 u(i,j)),
8+%
9+% on the unit square with u = 0 on the rim. It is stable exactly when
10+% lam <= 1/sqrt(2), and there is no Courant number at which it is exact.
11+%
12+% u and uold are (N+1)-by-(N+1), rim included, at steps 0 and -1. The scheme
13+% advances M steps and returns the displacement at node (ir, jr) after each
14+% one, and the whole field, as columns u(:), after each of the first K. The
15+% `scheme` argument is ignored here; Chapter 5's version of this function
16+% offers three.
17+
18+function [y, U] = drum2d(u, uold, lam, M, scheme, ir, jr, K)
19+n1 = size(u, 1);
20+l2 = lam ^ 2;
21+y = zeros(M, 1);
22+U = zeros(n1 * n1, K);
23+unew = zeros(n1, n1);
24+for n = 1:M
25+ for j = 2:n1 - 1
26+ for i = 2:n1 - 1
27+ unew(i, j) = 2 * u(i, j) - uold(i, j) ...
28+ + l2 * (u(i + 1, j) + u(i - 1, j) + u(i, j + 1) + u(i, j - 1) - 4 * u(i, j));
29+ end
30+ end
31+ uold = u;
32+ u = unew;
33+ y(n) = u(ir, jr);
34+ if n <= K
35+ U(:, n) = u(:);
36+ end
37+end
38+end
src/chapters/ch04/text.mdadded+47−0View file
@@ -0,0 +1,47 @@
1+In two dimensions a wave can travel in any direction, and a square grid does not look the same in every direction. This chapter moves the leapfrog scheme to a square membrane, where the exact solution is still a sum of known modes, and measures the two things that change: there is no longer a Courant number at which the scheme is exact, and its error depends on which way a wave is going.
2+
3+### The square membrane
4+
5+A unit square membrane clamped at its rim has the modes
6+
7+$$u_{mn}(x,y,t) = \sin(m\pi x)\sin(n\pi y)\cos(2\pi f_{mn} t), \qquad f_{mn} = \frac{c}{2}\sqrt{m^2 + n^2},$$
8+
9+for all $m, n \ge 1$. Unlike a string's, these frequencies are not multiples of the lowest. Relative to $f_{11}$ they are $1$, $1.581$ (twice, for $(1,2)$ and $(2,1)$), $2$, $2.236$ (twice), $2.550$ (twice), $2.915$ (twice), $3$ and so on, growing more densely packed as the frequency rises. A spectrum of that kind has no single pitch, which is why a drum sounds like a drum.
10+
11+<!--fig:modes-->
12+
13+The first lab strikes the drum: the membrane starts at rest and receives a Gaussian blow of velocity, 0.03 of its side wide, at one point, and it is heard at another. The fundamental is 150 Hz. The exact solution is the modal sum with every mode's coefficient known in closed form, and the driver evaluates it at the receiver, over about 2600 modes, for comparison. Striking at the centre, where every mode with an even index has a node, silences three quarters of the modes.
14+
15+### The scheme in two dimensions
16+
17+The scheme is Chapter 2's with the five-point Laplacian:
18+
19+$$u_{ij}^{n+1} = 2u_{ij}^n - u_{ij}^{n-1} + \lambda^2\big(u_{i+1,j}^n + u_{i-1,j}^n + u_{i,j+1}^n + u_{i,j-1}^n - 4u_{ij}^n\big).$$
20+
21+A Fourier mode with wave vector $(k_x, k_y)$ now satisfies
22+
23+$$\sin^2\!\left(\frac{\omega\Delta t}{2}\right) = \lambda^2\left[\sin^2\!\left(\frac{k_x h}{2}\right) + \sin^2\!\left(\frac{k_y h}{2}\right)\right].$$
24+
25+The right-hand side is largest for the checkerboard mode, $k_x h = k_y h = \pi$, where it is $2\lambda^2$, so the scheme is stable if and only if $\lambda \le 1/\sqrt{2}$. At that limit the dispersion relation is exact for waves travelling along a diagonal, but for no others, and below it for none at all. There is no magic time step.
26+
27+<!--lab:strike-->
28+
29+At $N = 48$ and $\lambda = 0.7$ the scheme runs at 14.5 kHz and the relative rms error of the recording is 0.59; at $N = 64$ it is 0.54, and at $N = 24$ it is 0.80. These are large numbers, and the spectrum shows why. The blow excites modes up to a few kilohertz, and on these grids those modes have only three or four points per wavelength, so they come out flat by hundreds of cents and the recording drifts out of phase with the exact one within a few periods. The lowest modes, which carry most of the energy and set the character of the sound, are close to right. Listening confirms both halves of this: the coarse drum is recognisably the same drum, with a duller and slightly different ring.
30+
31+### The error depends on direction
32+
33+Expanding the dispersion relation for long waves, as in Chapter 2, gives for a wave at angle $\theta$ to the $x$ axis
34+
35+$$\frac{\omega}{ck} = 1 - \frac{(kh)^2}{24}\left(\cos^4\theta + \sin^4\theta - \lambda^2\right) + O\big((kh)^4\big),$$
36+
37+so the pitch error at $P$ points per wavelength is about $-2847\,(\cos^4\theta + \sin^4\theta - \lambda^2)/P^2$ cents. Along an axis, $\cos^4\theta + \sin^4\theta = 1$ and the error is the one-dimensional one. Along a diagonal it is $1/2$, the error is smaller, and at $\lambda = 1/\sqrt{2}$ it vanishes. The grid is **anisotropic**: waves along its axes travel slowest.
38+
39+<!--fig:anisotropy-->
40+
41+For the modes of the square this is directly measurable, because the sine vectors $\sin(m\pi i/N)\sin(n\pi j/N)$ are exact eigenvectors of the five-point Laplacian: a single mode on the grid stays a single mode, and only its frequency is wrong. The next lab starts the grid in a sum of modes at three wavelengths and at directions from $4°$ to $86°$, projects the field onto each, and measures each frequency from its own recording.
42+
43+<!--lab:cents-->
44+
45+The measured errors agree with the dispersion relation to better than $10^{-6}$ cents. At about 16 points per wavelength the error along the axes is $-5.4$ cents at $\lambda = 1/\sqrt{2}$ and $-10.0$ at $\lambda = 0.3$, and along the diagonal it is $0$ and $-5.1$. At about 8 points per wavelength the axis error is $-23$ cents at the stability limit, and at about 4 it is $-101$. Running at the largest stable time step is again the most accurate choice as well as the cheapest. What it cannot change is the spread between directions, which for waves of the same length is, to leading order, $2847/(2P^2)$ cents whatever the Courant number.
46+
47+The conclusion for a drum is the same as for a string, with a worse constant. To hold every mode within 5 cents, the grid needs about 17 points per wavelength at the stability limit for waves along its axes, which in two dimensions means about $17^2 \approx 290$ nodes per square wavelength, and every halving of the error costs a factor $\sqrt{2}$ in each direction and in time, so $2^{3/2} \approx 2.8$ in work. Chapter 5 asks whether a more accurate stencil does better.
src/chapters/ch05/chapter.tsxadded+78−0View file
@@ -0,0 +1,78 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Params } from '../../engine/types.ts'
3+import driver from '../membrane/driver.m?raw'
4+import script from './drum.m?raw'
5+import text from './text.md?raw'
6+import {
7+ OrdersPanels,
8+ SCHEME,
9+ STRIKE_CONTROL,
10+ StrikePanels,
11+ strikeParams,
12+ strikeSummary,
13+ type OrdersOut,
14+ type StrikeOut,
15+} from '../membrane/panels.tsx'
16+import { fmtCents } from '../common/panels.tsx'
17+
18+/** each scheme at a Courant number just inside its own stability limit */
19+const LAM: Record<string, number> = { fd2: 0.7, fd4: 0.6, fd44: 0.7 }
20+
21+export const ch05: Chapter = {
22+ id: 'order',
23+ nav: 'Fewer points per wavelength',
24+ part: 'Drums',
25+ title: 'Fewer points per wavelength',
26+ blurb:
27+ 'Fourth-order stencils in space, in time, and in both, measured by pitch error against cost, and why raising the order in space alone buys little.',
28+ text,
29+ labs: [
30+ {
31+ id: 'orders',
32+ title: 'Three schemes, three test modes',
33+ caption:
34+ 'Each scheme runs at a Courant number just inside its own stability limit. This lab takes several seconds, so it waits for the run button.',
35+ driver,
36+ script,
37+ controls: [],
38+ defaults: {},
39+ derive: () => ({
40+ mode: 'orders',
41+ Ns: [16, 24, 32, 48, 64, 96],
42+ schemes: ['fd2', 'fd4', 'fd44'],
43+ lams: [LAM.fd2, LAM.fd4, LAM.fd44],
44+ modes: [4, 3, 9, 2, 14, 1],
45+ }),
46+ manual: true,
47+ editorOpen: true,
48+ render: (d: OrdersOut) => <OrdersPanels d={d} />,
49+ summary: (d: OrdersOut) =>
50+ d.schemes.map((s, k) => ({
51+ label: `${s}, N = ${d.Ns[d.Ns.length - 1]}`,
52+ value: fmtCents(d.worst[k][d.Ns.length - 1]),
53+ })),
54+ },
55+ {
56+ id: 'listen',
57+ title: 'The drum again, by each scheme',
58+ caption:
59+ 'Chapter 4’s drum on the same grid with each scheme. On a coarse grid the fourth-order scheme is much closer to the exact sound.',
60+ driver,
61+ script,
62+ controls: [
63+ {
64+ kind: 'select',
65+ key: 'scheme',
66+ label: 'scheme',
67+ options: Object.entries(SCHEME).map(([k, v]) => ({ value: k, label: v.label })),
68+ },
69+ { kind: 'slider', key: 'N', label: 'grid intervals N', min: 0, max: 0, values: [24, 32, 48] },
70+ STRIKE_CONTROL,
71+ ],
72+ defaults: { scheme: 'fd44', N: 32, at: 'off' },
73+ derive: (p: Params) => strikeParams({ ...p, lam: LAM[String(p.scheme)] }, String(p.scheme)),
74+ render: (d: StrikeOut, p: Params) => <StrikePanels d={d} scheme={String(p.scheme)} />,
75+ summary: strikeSummary,
76+ },
77+ ],
78+}
src/chapters/ch05/drum.madded+104−0View file
@@ -0,0 +1,104 @@
1+% Three explicit schemes for the square membrane.
2+%
3+% 'fd2' leapfrog in time, the five-point Laplacian in space: second order
4+% in both, stable for lam <= 1/sqrt(2). Chapter 4's scheme.
5+% 'fd4' leapfrog in time, the fourth-order Laplacian in space, whose 1-D
6+% stencil is (-1, 16, -30, 16, -1)/12: fourth order in space but
7+% still second order in time, and stable only for lam <= 0.612.
8+% 'fd44' fourth order in both, by the modified-equation trick (Dablain
9+% 1986): the leapfrog update's leading time error is
10+% (dt^2/12) u_tttt = (dt^2/12) c^4 Lap^2 u, so add it back, using
11+% the five-point Laplacian applied twice for Lap^2, since the term
12+% is already multiplied by dt^2. One more stencil pass per step,
13+% and the time step is 1/sqrt(2) again.
14+%
15+% The wide stencils need values one node beyond the rim. With u = 0 on the
16+% rim, the wave equation makes every even derivative of u vanish there too,
17+% so the solution continues across the rim as an odd reflection, and that is
18+% exactly what the ghost nodes are filled with. For the sine modes of the
19+% square the reflection is not an approximation at all.
20+%
21+% The contract is Chapter 4's: u and uold are (N+1)-by-(N+1), rim included;
22+% the result is the displacement at node (ir, jr) after each of M steps, and
23+% the whole field after each of the first K.
24+
25+function [y, U] = drum2d(u, uold, lam, M, scheme, ir, jr, K)
26+n1 = size(u, 1);
27+g = n1 + 4; % two ghost layers on every side
28+P = zeros(g, g);
29+Q = zeros(g, g);
30+R = zeros(g, g);
31+W = zeros(g, g);
32+P(3:n1 + 2, 3:n1 + 2) = u;
33+Q(3:n1 + 2, 3:n1 + 2) = uold;
34+y = zeros(M, 1);
35+U = zeros(n1 * n1, K);
36+if strcmp(scheme, 'fd2')
37+ s = 0;
38+elseif strcmp(scheme, 'fd4')
39+ s = 1;
40+else
41+ s = 2;
42+end
43+l2 = lam ^ 2;
44+l4 = lam ^ 4 / 12;
45+a = n1 + 2; % the index of the far rim
46+for n = 1:M
47+ % odd reflection across the rim, two layers deep
48+ for j = 1:g
49+ P(2, j) = -P(4, j);
50+ P(1, j) = -P(5, j);
51+ P(a + 1, j) = -P(a - 1, j);
52+ P(a + 2, j) = -P(a - 2, j);
53+ end
54+ for i = 1:g
55+ P(i, 2) = -P(i, 4);
56+ P(i, 1) = -P(i, 5);
57+ P(i, a + 1) = -P(i, a - 1);
58+ P(i, a + 2) = -P(i, a - 2);
59+ end
60+ if s == 0
61+ for j = 4:a - 1
62+ for i = 4:a - 1
63+ R(i, j) = 2 * P(i, j) - Q(i, j) ...
64+ + l2 * (P(i + 1, j) + P(i - 1, j) + P(i, j + 1) + P(i, j - 1) - 4 * P(i, j));
65+ end
66+ end
67+ else
68+ if s == 2
69+ % W = five-point Laplacian of P (times h^2), zero on the rim,
70+ % reflected oddly one layer out
71+ for j = 4:a - 1
72+ for i = 4:a - 1
73+ W(i, j) = P(i + 1, j) + P(i - 1, j) + P(i, j + 1) + P(i, j - 1) - 4 * P(i, j);
74+ end
75+ end
76+ for j = 1:g
77+ W(2, j) = -W(4, j);
78+ W(a + 1, j) = -W(a - 1, j);
79+ end
80+ for i = 1:g
81+ W(i, 2) = -W(i, 4);
82+ W(i, a + 1) = -W(i, a - 1);
83+ end
84+ end
85+ for j = 4:a - 1
86+ for i = 4:a - 1
87+ L4 = (-P(i + 2, j) + 16 * P(i + 1, j) - 30 * P(i, j) + 16 * P(i - 1, j) - P(i - 2, j) ...
88+ - P(i, j + 2) + 16 * P(i, j + 1) - 30 * P(i, j) + 16 * P(i, j - 1) - P(i, j - 2)) / 12;
89+ v = 2 * P(i, j) - Q(i, j) + l2 * L4;
90+ if s == 2
91+ v = v + l4 * (W(i + 1, j) + W(i - 1, j) + W(i, j + 1) + W(i, j - 1) - 4 * W(i, j));
92+ end
93+ R(i, j) = v;
94+ end
95+ end
96+ end
97+ Q = P;
98+ P = R;
99+ y(n) = P(ir + 2, jr + 2);
100+ if n <= K
101+ U(:, n) = reshape(P(3:a, 3:a), n1 * n1, 1);
102+ end
103+end
104+end
src/chapters/ch05/text.mdadded+47−0View file
@@ -0,0 +1,47 @@
1+Chapter 4 ended with a cost: about 17 points per wavelength to hold a drum's modes within 5 cents, and a factor of 2.8 in work for every halving of the error beyond that. The standard response is a more accurate stencil, and this chapter measures three versions of that response against each other. The results are less obvious than the orders of accuracy suggest, and one of the three is a trap.
2+
3+### Three schemes
4+
5+All three are explicit, advance two time levels, and use the rim condition $u = 0$.
6+
7+**Second order**, the scheme of Chapter 4: the five-point Laplacian and leapfrog in time. Stable for $\lambda \le 1/\sqrt{2}$.
8+
9+**Fourth order in space only.** Replace the three-point second difference in each direction by the five-point one,
10+
11+$$u_{xx} \approx \frac{-u_{j+2} + 16u_{j+1} - 30u_j + 16u_{j-1} - u_{j-2}}{12h^2},$$
12+
13+which is fourth-order accurate, and keep leapfrog in time. The wider stencil has a larger largest eigenvalue, $32/(3h^2)$ against $8/h^2$, so the stable time step shrinks, to $\lambda \le \sqrt{3/8} \approx 0.612$. This is the scheme one arrives at by improving the part of the discretisation that is easiest to improve.
14+
15+**Fourth order in space and time.** Leapfrog's leading time error is $(\Delta t^2/12)\,u_{tttt}$, and by the wave equation $u_{tttt} = c^4\nabla^4 u$, so it can be cancelled by adding $(\Delta t^4 c^4/12)\,\nabla^4 u$ to the update. Because that term already carries a factor $\Delta t^4$, a second-order approximation of $\nabla^4$ is enough, and the five-point Laplacian applied twice serves. The result is fourth order in both $h$ and $\Delta t$ for one extra stencil pass per step (Dablain 1986; it is often called the modified-equation or Lax–Wendroff approach). A short calculation with the symbols shows that its stability limit is $\lambda \le 1/\sqrt{2}$, the same as the second-order scheme's: the extra term gives back the time step that the wider stencil took away.
16+
17+<!--fig:stencils-->
18+
19+The wide stencils need values beyond the rim. With $u = 0$ on the rim, the wave equation forces $u_{tt} = 0$ there too, hence $\nabla^2 u = 0$, and repeating the argument shows that every even derivative vanishes on the rim, so the solution continues across it as an odd reflection. The script fills its ghost nodes that way. For the modes of the square this is exact, and the sine vectors remain exact eigenvectors of all three schemes, so pitch errors can be measured one mode at a time as before.
20+
21+### The measurement
22+
23+Three test modes, $(4,3)$, $(9,2)$ and $(14,1)$, cover the range from well resolved to barely resolved, and the last is within $4°$ of the $x$ axis, which Chapter 4 showed is the worst direction. Each is simulated alone for twelve of its periods, on grids from 16 to 96 intervals, with each scheme at a Courant number just inside its own stability limit: 0.7, 0.6 and 0.7. The error plotted is the worst of the three; the time is that of the run of the lowest mode, the longest of the three.
24+
25+<!--lab:orders-->
26+
27+The second-order scheme behaves as it did in Chapter 4: its worst error falls from 409 cents at $N = 16$ to 7.7 at $N = 96$, with an observed order of 2.03 at the end.
28+
29+The fully fourth-order scheme converges at order 4.0 over the whole ladder, from 245 cents to 0.19. At $N = 48$ its worst error is 3.0 cents, which the second-order scheme does not reach on any grid here. It is also more expensive per step, by a factor of about four and a half in this implementation, which is more than the extra stencil pass alone would suggest; part of that is the ghost-node handling that the wider stencils need, and a more careful implementation would narrow it. Even so, it wins at every accuracy that matters. Interpolating along the fourth-order curve, and extrapolating the second-order one beyond the grids measured at its observed order, 5 cents costs about 0.1 s with the fourth-order scheme and about 0.5 s with the second-order one, and 1 cent about 0.4 s against about 6 s. The advantage grows as the tolerance tightens, which is what the orders predict: halving the error costs a factor $2^{3/4} \approx 1.7$ in work at fourth order and $2^{3/2} \approx 2.8$ at second.
30+
31+The scheme that is fourth order in space only is the trap. At $N = 24$ its worst error is 23 cents, much better than the second-order scheme's 146, and on a coarse grid it looks like a good idea. But leapfrog's time error is still there, now uncancelled by anything, and it has the opposite sign to the spatial error: the spatial error makes waves slow and the time error makes them fast. On the coarsest grids the two partly cancel, which produces the erratic observed orders in the table, and on fine grids the time error is left on its own, so the scheme converges at second order after all. By $N = 96$ its worst error, 5.1 cents, is barely better than the plain scheme's 7.7, at two and a half times the cost, because of the smaller time step and the wider stencil. The hover read-outs on the plots give the per-mode errors: at $N = 96$ the lowest test mode is 0.7 cents sharp under this scheme and 0.1 cents flat under the plain one.
32+
33+This is a general lesson rather than a peculiarity of the wave equation. A method is as accurate as its least accurate part, and improving the spatial discretisation of a time-dependent problem without the temporal one improves only the constant in front of the error, and not always that.
34+
35+### Listening
36+
37+The same struck drum as in Chapter 4, on the same grid, with each scheme.
38+
39+<!--lab:listen-->
40+
41+On a grid of $N = 32$ the relative rms error of the recording is 0.69 with the second-order scheme, 0.81 with fourth order in space only, and 0.37 with the fully fourth-order scheme; on $N = 24$ the fourth-order scheme's 0.46 is better than the second-order scheme's 0.59 on $N = 48$, for less computation. The waveform error is dominated, as before, by the highest modes the blow excites, which are poorly resolved on every grid here, so these numbers flatter none of the schemes. Listen for the upper partials of the ring rather than for its fundamental.
42+
43+### What this chapter establishes
44+
45+For the wave equation, where accuracy is lost to phase error accumulated over many periods, raising the order pays, and it pays more the tighter the tolerance. It pays only if the time discretisation is raised with the spatial one. The modified-equation scheme does this for the price of one more stencil pass, keeps the second-order scheme's time step, and here is roughly five times cheaper at 5 cents and fifteen times cheaper at 1 cent.
46+
47+We emphasise that, on the square, every one of these schemes loses to the modal sum of Chapter 1, which is exact and costs one oscillator per mode. The square is here because it lets errors be measured exactly. Chapter 6 moves to a domain where the grid no longer fits the boundary, which is where the rest of the error comes from in practice.
src/chapters/ch06/chapter.tsxadded+228−0View file
@@ -0,0 +1,228 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Num, Params } from '../../engine/types.ts'
3+import driver from './driver.m?raw'
4+import script from './disk.m?raw'
5+import text from './text.md?raw'
6+import Listen, { type Clip } from '../../audio/Listen.tsx'
7+import { NumTable, Row, SeriesPlot, fmtCents, fmtMs, fmtNum } from '../common/panels.tsx'
8+import { faded, ink, method } from '../../plot/palette.ts'
9+
10+interface HearOut {
11+ f: number[]
12+ adb: number[]
13+ m: number[]
14+ k: number[]
15+ ratio: number[]
16+ fr: number[]
17+ db: number[]
18+ clipRound: Clip
19+ clipSquare: Clip
20+ tm: number[]
21+ tk: number[]
22+ tratio: number[]
23+}
24+
25+interface GridOut {
26+ Ns: number[]
27+ methods: string[]
28+ modes: number[][]
29+ cents: Num[][]
30+ worst: Num[][]
31+ lam: Num[][]
32+ ms: Num[][]
33+ orders: Num[][]
34+}
35+
36+const RIM: Record<string, { label: string; color: string; shape: 'circle' | 'cross' }> = {
37+ staircase: { label: 'staircase rim', color: method.fd2, shape: 'circle' },
38+ sw: { label: 'Shortley–Weller rim', color: method.boundary, shape: 'cross' },
39+}
40+
41+function HearPanels({ d }: { d: HearOut }) {
42+ const harm = Array.from({ length: 27 }, (_, i) => i + 1)
43+ return (
44+ <>
45+ <SeriesPlot
46+ title="every partial the blow excites, placed by its frequency as a multiple of the fundamental; faint lines are the whole-number multiples a string would have"
47+ height={220}
48+ xDomain={[0, 27]}
49+ yDomain={[-60, 3]}
50+ xLabel="frequency / fundamental"
51+ yLabel="dB"
52+ vlines={[{ x: harm, color: faded(ink.muted, 0.5) }]}
53+ series={[
54+ {
55+ x: d.ratio,
56+ y: d.adb,
57+ color: method.modal,
58+ stems: true,
59+ base: -60,
60+ shape: 'circle',
61+ line: false,
62+ tip: (i) => [`mode (m, k) = (${d.m[i]}, ${d.k[i]})`, `${d.f[i].toFixed(1)} Hz, ratio ${d.ratio[i].toFixed(3)}`, `${d.adb[i].toFixed(1)} dB`],
63+ },
64+ ]}
65+ legend={false}
66+ />
67+ <Row>
68+ <SeriesPlot
69+ title="spectrum of the sound"
70+ height={200}
71+ xLabel="Hz"
72+ yLabel="dB"
73+ yDomain={[-80, 3]}
74+ series={[{ x: d.fr, y: d.db, color: method.modal, width: 1.2 }]}
75+ legend={false}
76+ />
77+ <NumTable
78+ caption="the lowest twelve modes of any round membrane"
79+ head={['m', 'k', 'f / f₀₁']}
80+ rows={d.tm.map((m, i) => [m, d.tk[i], d.tratio[i].toFixed(4)])}
81+ />
82+ </Row>
83+ <Listen
84+ items={[
85+ { label: 'round drum', clip: d.clipRound },
86+ { label: 'square drum, same fundamental', clip: d.clipSquare },
87+ ]}
88+ note="both exact, each normalised to its own peak"
89+ />
90+ </>
91+ )
92+}
93+
94+function GridPanels({ d }: { d: GridOut }) {
95+ const nq = d.modes.length
96+ const rim = d.methods.map((m) => RIM[m])
97+ const tip = (s: number) => (j: number) => [
98+ `N = ${d.Ns[j]}, λ = ${fmtNum(d.lam[s][j], 3)}, ${fmtMs(d.ms[s][j])}`,
99+ ...d.modes.map((m, q) => `mode (${m[0]},${m[1]}): ${fmtCents(d.cents[s * nq + q][j])}`),
100+ ]
101+ return (
102+ <>
103+ <Row>
104+ <SeriesPlot
105+ title="worst pitch error of the two lowest modes"
106+ height={240}
107+ xLog
108+ yLog
109+ yDomain={[0.05, 200]}
110+ xLabel="grid intervals N"
111+ xTicks={d.Ns}
112+ yLabel="|cents|"
113+ hlines={[{ y: 5, label: '5 cents' }]}
114+ refs={[
115+ { p: -1, through: [30, 60], label: 'slope −1', span: 0.4, labelAt: 'end' },
116+ { p: -2, through: [30, 1.2], label: 'slope −2', span: 0.4 },
117+ ]}
118+ series={rim.map((r, s) => ({ x: d.Ns, y: d.worst[s], color: r.color, shape: r.shape, label: r.label, tip: tip(s) }))}
119+ />
120+ <SeriesPlot
121+ title="the Courant number each rim allows"
122+ height={240}
123+ xLog
124+ yDomain={[0, 0.75]}
125+ xLabel="grid intervals N"
126+ xTicks={d.Ns}
127+ yLabel="λ"
128+ series={rim.map((r, s) => ({ x: d.Ns, y: d.lam[s], color: r.color, shape: r.shape, label: r.label, tip: tip(s) }))}
129+ legend={false}
130+ />
131+ </Row>
132+ <SeriesPlot
133+ title="the same errors against the time the runs took"
134+ height={220}
135+ xLog
136+ yLog
137+ yDomain={[0.05, 200]}
138+ xLabel="ms"
139+ yLabel="|cents|"
140+ hlines={[{ y: 5, label: '5 cents' }]}
141+ series={rim.map((r, s) => ({ x: d.ms[s], y: d.worst[s], color: r.color, shape: r.shape, label: r.label, tip: tip(s) }))}
142+ legend={false}
143+ />
144+ <NumTable
145+ caption="worst pitch error in cents, observed order, and the Courant number used"
146+ head={['N', ...d.methods.flatMap((m) => [`${m} error`, `${m} order`, `${m} λ`])]}
147+ rows={d.Ns.map((N, j) => [
148+ N,
149+ ...d.methods.flatMap((_, s) => [fmtNum(d.worst[s][j], 3), fmtNum(d.orders[s][j], 2), fmtNum(d.lam[s][j], 3)]),
150+ ])}
151+ />
152+ </>
153+ )
154+}
155+
156+export const ch06: Chapter = {
157+ id: 'round',
158+ nav: 'A round drum',
159+ part: 'Drums',
160+ title: 'A round drum on a square grid',
161+ blurb:
162+ 'Bessel modes and the sound of an inharmonic spectrum, and what a curved rim costs a grid that cannot follow it.',
163+ text,
164+ labs: [
165+ {
166+ id: 'hear',
167+ title: 'The partials of a round membrane',
168+ caption:
169+ 'Strike at the centre and only the circularly symmetric modes sound; move outwards and the others join in. Neither set lines up with the harmonic series.',
170+ driver,
171+ script,
172+ controls: [
173+ {
174+ kind: 'select',
175+ key: 'r0',
176+ label: 'strike at radius',
177+ options: [
178+ { value: 0, label: 'the centre' },
179+ { value: 0.4, label: '0.4 of the way out' },
180+ { value: 0.8, label: '0.8, near the rim' },
181+ ],
182+ },
183+ ],
184+ defaults: { r0: 0.4 },
185+ derive: (p: Params) => ({
186+ mode: 'hear',
187+ fs: 22050,
188+ f01: 150,
189+ T: 1.5,
190+ width: 0.03,
191+ mmax: 40,
192+ kmax: 14,
193+ fmax: 4000,
194+ r0: p.r0,
195+ rr: 0.55,
196+ thr: 2.1,
197+ tau: 0.5,
198+ }),
199+ render: (d: HearOut) => <HearPanels d={d} />,
200+ summary: (d: HearOut) => [
201+ { label: 'partials below 4 kHz', value: String(d.f.length) },
202+ { label: 'second partial', value: `${d.ratio[1]?.toFixed(3)} × f₀₁` },
203+ ],
204+ },
205+ {
206+ id: 'grid',
207+ title: 'Two rims, measured',
208+ caption:
209+ 'Modes (0,1) and (1,1), started exactly and measured from the simulation. This lab runs for several seconds, so it waits for the run button.',
210+ driver,
211+ script,
212+ controls: [],
213+ defaults: {},
214+ derive: () => ({
215+ mode: 'grid',
216+ Ns: [16, 24, 32, 40, 48, 64],
217+ methods: ['staircase', 'sw'],
218+ modes: [0, 1, 1, 1],
219+ periods: 10,
220+ }),
221+ manual: true,
222+ editorOpen: true,
223+ render: (d: GridOut) => <GridPanels d={d} />,
224+ summary: (d: GridOut) =>
225+ d.methods.map((m, s) => ({ label: `${RIM[m].label}, N = ${d.Ns[d.Ns.length - 1]}`, value: fmtCents(d.worst[s][d.Ns.length - 1]) })),
226+ },
227+ ],
228+}
src/chapters/ch06/disk.madded+72−0View file
@@ -0,0 +1,72 @@
1+% Two ways to put the edge of a round drum on a square grid.
2+%
3+% The grid covers the square [-1, 1]^2 with spacing h = 2/N, and the drum is
4+% the unit disk. Every node strictly inside the disk is an unknown; every
5+% other node is held at zero. What differs is what the five-point Laplacian
6+% does at an unknown whose neighbour lies outside.
7+%
8+% 'staircase' nothing: the outside neighbour is simply zero, as if the rim
9+% ran through it. The drum the grid sees is the union of the
10+% cells around the inside nodes, a disk with a jagged edge, and
11+% its radius is wrong by up to h.
12+% 'sw' Shortley and Weller (1938): if the rim crosses the grid line
13+% at a fraction theta of the way to the outside neighbour, use
14+% the three-point second difference for unequal spacing,
15+%
16+% u_xx ~ 2/h^2 [ u_W / (1 + theta) - u_P / theta ]
17+% (with u = 0 at the crossing),
18+%
19+% which is consistent to first order at the rim and gives
20+% second-order frequencies. The price is the 1/theta: a node
21+% very close to the rim has a very large coefficient, and an
22+% explicit time step has to respect it.
23+%
24+% The result is the stencil, times h^2, one number per node and direction:
25+% C at the node itself and E, W, Nn, S for its neighbours at (i, j+1), (i, j-1),
26+% (i+1, j), (i-1, j), where i counts y and j counts x. `in` marks the
27+% unknowns. The driver runs leapfrog with it.
28+
29+function [C, E, W, Nn, S, in] = diskstencil(N, method)
30+h = 2 / N;
31+x = -1 + (0:N) * h;
32+[X, Y] = meshgrid(x, x); % X(i, j) = x(j), Y(i, j) = x(i)
33+in = X .^ 2 + Y .^ 2 < 1 - 1e-12;
34+n1 = N + 1;
35+C = zeros(n1, n1);
36+E = zeros(n1, n1);
37+W = zeros(n1, n1);
38+Nn = zeros(n1, n1);
39+S = zeros(n1, n1);
40+sw = strcmp(method, 'sw');
41+for i = 2:n1 - 1
42+ for j = 2:n1 - 1
43+ if ~in(i, j)
44+ continue;
45+ end
46+ % x direction: distances to the east and west, as fractions of h
47+ te = 1;
48+ tw = 1;
49+ if ~in(i, j + 1) && sw
50+ te = (sqrt(1 - Y(i, j) ^ 2) - X(i, j)) / h;
51+ end
52+ if ~in(i, j - 1) && sw
53+ tw = (X(i, j) + sqrt(1 - Y(i, j) ^ 2)) / h;
54+ end
55+ tn = 1;
56+ ts = 1;
57+ if ~in(i + 1, j) && sw
58+ tn = (sqrt(1 - X(i, j) ^ 2) - Y(i, j)) / h;
59+ end
60+ if ~in(i - 1, j) && sw
61+ ts = (Y(i, j) + sqrt(1 - X(i, j) ^ 2)) / h;
62+ end
63+ % the unequal-spacing second difference in each direction; with every
64+ % theta equal to 1 this is the ordinary five-point stencil
65+ E(i, j) = 2 / (te * (te + tw)) * in(i, j + 1);
66+ W(i, j) = 2 / (tw * (te + tw)) * in(i, j - 1);
67+ Nn(i, j) = 2 / (tn * (tn + ts)) * in(i + 1, j);
68+ S(i, j) = 2 / (ts * (tn + ts)) * in(i - 1, j);
69+ C(i, j) = -2 / (te * tw) - 2 / (tn * ts);
70+ end
71+end
72+end
src/chapters/ch06/driver.madded+211−0View file
@@ -0,0 +1,211 @@
1+% Driver for Chapter 6: the round drum.
2+%
3+% The editable script supplies
4+%
5+% [C, E, W, Nn, S, in] = diskstencil(N, method)
6+%
7+% the five-point Laplacian (times h^2) on the unknowns of an N-by-N grid over
8+% [-1, 1]^2 that lie inside the unit disk, with some treatment of the rim.
9+%
10+% The exact modes of the unit disk are J_m(j_mk r) cos(m theta), with
11+% frequency j_mk / (2 pi) when c = 1, where j_mk is the k-th zero of the
12+% Bessel function J_m; wv_bjzeros computes them to rounding error.
13+%
14+% mode 'hear' the exact round drum, struck at radius r0, heard at a fixed
15+% point, by modal synthesis; and a square drum with the same
16+% fundamental, also exact, for comparison.
17+% mode 'grid' the pitch error of the lowest modes on grids of increasing
18+% size, for each rim treatment. The driver runs leapfrog itself,
19+% at a Courant number of 0.95 times the largest the stencil
20+% allows (found by power iteration on the stencil, and never
21+% more than 1/sqrt(2)); the grid starts in the exact mode, and
22+% the frequency is read off the projection of the field onto it.
23+p = wv_params();
24+out = struct();
25+
26+if strcmp(p.mode, 'hear')
27+ fs = p.fs;
28+ f01 = p.f01;
29+ T = p.T;
30+ t = (0:round(T * fs) - 1)' / fs;
31+ c = 2 * pi * f01 / 2.404825557695773; % so that the lowest mode is f01, R = 1
32+ sw = p.width;
33+ y = zeros(size(t));
34+ F = [];
35+ Amp = [];
36+ Lab = zeros(0, 2);
37+ for m = 0:p.mmax
38+ z = wv_bjzeros(m, p.kmax);
39+ for k = 1:numel(z)
40+ j = z(k);
41+ f = c * j / (2 * pi);
42+ if f > p.fmax
43+ continue;
44+ end
45+ % mode shape normalised: int J_m(j r)^2 r dr = J_{m+1}(j)^2 / 2,
46+ % times pi (m > 0) or 2 pi (m = 0) for the angle
47+ nrm = besselj(m + 1, j) ^ 2 / 2 * pi * (1 + (m == 0));
48+ ps = besselj(m, j * p.r0); % strike at angle 0
49+ pr = besselj(m, j * p.rr) * cos(m * p.thr); % receiver
50+ a = ps * pr / nrm * exp(-0.5 * (j * sw) ^ 2) / (2 * pi * f);
51+ if abs(a) < 1e-12
52+ continue;
53+ end
54+ y = y + a * sin(2 * pi * f * t);
55+ F(end + 1) = f;
56+ Amp(end + 1) = abs(a);
57+ Lab(end + 1, :) = [m, k];
58+ end
59+ end
60+ % the square drum with the same fundamental: unit side, c chosen so that
61+ % f11 = f01, struck and heard at comparable points. Partials more than
62+ % 80 dB below the loudest are left out; they cannot be heard over it.
63+ cs = 2 * f01 / sqrt(2);
64+ ysq = zeros(size(t));
65+ nq = floor(2 * p.fmax / cs);
66+ [nn, mm] = meshgrid(1:nq, 1:nq);
67+ fq = cs / 2 * sqrt(mm .^ 2 + nn .^ 2);
68+ aq = sin(mm * pi * 0.5 * (1 + p.r0 / 1.2)) .* sin(nn * pi * 0.5) ...
69+ .* sin(mm * pi * 0.77) .* sin(nn * pi * 0.63) ...
70+ .* exp(-0.5 * (pi * sqrt(mm .^ 2 + nn .^ 2) * sw / 2) .^ 2) ./ (2 * pi * fq);
71+ aq(fq > p.fmax) = 0;
72+ for q = find(abs(aq(:)) > 1e-4 * max(abs(aq(:))))'
73+ ysq = ysq + aq(q) * sin(2 * pi * fq(q) * t);
74+ end
75+ % one decay for every partial of both drums, so that neither rings forever
76+ env = exp(-t / p.tau);
77+ y = y .* env;
78+ ysq = ysq .* env;
79+ [F, ord] = sort(F);
80+ Amp = Amp(ord);
81+ Lab = Lab(ord, :);
82+ out.f = F;
83+ out.adb = 20 * log10(Amp / max(Amp));
84+ out.m = Lab(:, 1)';
85+ out.k = Lab(:, 2)';
86+ out.ratio = F / f01;
87+ [fr, db] = wv_spectrum(y, fs, p.fmax, 800);
88+ out.fr = fr;
89+ out.db = db;
90+ out.clipRound = wv_audio(y, fs);
91+ out.clipSquare = wv_audio(ysq, fs);
92+ % the first dozen partials, whatever the strike, for the table
93+ tbl = zeros(0, 3);
94+ for m = 0:5
95+ z = wv_bjzeros(m, 3);
96+ for k = 1:3
97+ tbl(end + 1, :) = [m, k, z(k)];
98+ end
99+ end
100+ [~, o] = sort(tbl(:, 3));
101+ tbl = tbl(o(1:12), :);
102+ out.tm = tbl(:, 1)';
103+ out.tk = tbl(:, 2)';
104+ out.tratio = (tbl(:, 3) / tbl(1, 3))';
105+else
106+ Ns = p.Ns(:)';
107+ methods = p.methods;
108+ modes = reshape(p.modes, 2, [])';
109+ nm = numel(methods);
110+ nq = size(modes, 1);
111+ CE = NaN(nm * nq, numel(Ns));
112+ LAM = NaN(nm, numel(Ns));
113+ TM = NaN(nm, numel(Ns));
114+ WORST = NaN(nm, numel(Ns));
115+ for s = 1:nm
116+ for j = 1:numel(Ns)
117+ N = Ns(j);
118+ h = 2 / N;
119+ [C, E, W, Nn, S, in] = diskstencil(N, methods{s});
120+ rho = drv_radius(C, E, W, Nn, S, in);
121+ lam = 0.95 * min(2 / sqrt(rho), 1 / sqrt(2));
122+ LAM(s, j) = lam;
123+ dt = lam * h;
124+ x = -1 + (0:N) * h;
125+ [X, Y] = meshgrid(x, x);
126+ R = sqrt(X .^ 2 + Y .^ 2);
127+ TH = atan2(Y, X);
128+ wst = 0;
129+ el = 0;
130+ for q = 1:nq
131+ m = modes(q, 1);
132+ z = wv_bjzeros(m, modes(q, 2));
133+ jm = z(end);
134+ phi = besselj(m, jm * R) .* cos(m * TH) .* in;
135+ w = jm; % c = 1
136+ M = ceil(p.periods * 2 * pi / (w * dt));
137+ t0 = tic;
138+ a = drv_leapfrog(phi, phi * cos(w * dt), C, E, W, Nn, S, lam, M, phi);
139+ el = el + toc(t0);
140+ fm = wv_peakfreq(a, dt, 0, 0.5 / dt);
141+ cq = wv_cents(fm, w / (2 * pi));
142+ CE((s - 1) * nq + q, j) = cq;
143+ wst = max(wst, abs(cq));
144+ end
145+ TM(s, j) = el * 1000;
146+ WORST(s, j) = wst;
147+ end
148+ end
149+ out.Ns = Ns;
150+ out.methods = methods;
151+ out.modes = wv_rows(modes);
152+ out.cents = wv_rows(CE);
153+ out.worst = wv_rows(WORST);
154+ out.lam = wv_rows(LAM);
155+ out.ms = wv_rows(TM);
156+ Q = NaN(nm, numel(Ns));
157+ for s = 1:nm
158+ Q(s, :) = wv_orders(WORST(s, :), Ns);
159+ end
160+ out.orders = wv_rows(Q);
161+end
162+
163+wv_write(out);
164+
165+function a = drv_leapfrog(u, uold, C, E, W, Nn, S, lam, M, phi)
166+% leapfrog with the script's stencil; returns the projection of the field
167+% onto phi after each step
168+n1 = size(u, 1);
169+l2 = lam ^ 2;
170+unew = zeros(n1, n1);
171+a = zeros(M, 1);
172+for n = 1:M
173+ s = 0;
174+ for j = 2:n1 - 1
175+ for i = 2:n1 - 1
176+ v = 2 * u(i, j) - uold(i, j) + l2 * (C(i, j) * u(i, j) + E(i, j) * u(i, j + 1) ...
177+ + W(i, j) * u(i, j - 1) + Nn(i, j) * u(i + 1, j) + S(i, j) * u(i - 1, j));
178+ unew(i, j) = v;
179+ s = s + phi(i, j) * v;
180+ end
181+ end
182+ uold = u;
183+ u = unew;
184+ a(n) = s;
185+end
186+end
187+
188+function r = drv_radius(C, E, W, Nn, S, in)
189+% the spectral radius of the stencil, by power iteration: leapfrog with this
190+% stencil is stable when lam^2 r <= 4
191+n1 = size(C, 1);
192+v = double(in) .* (mod((1:n1)' + (1:n1), 2) * 2 - 1); % a checkerboard: the roughest vector
193+r = 0;
194+w = zeros(n1, n1);
195+for it = 1:300
196+ for j = 2:n1 - 1
197+ for i = 2:n1 - 1
198+ w(i, j) = C(i, j) * v(i, j) + E(i, j) * v(i, j + 1) + W(i, j) * v(i, j - 1) ...
199+ + Nn(i, j) * v(i + 1, j) + S(i, j) * v(i - 1, j);
200+ end
201+ end
202+ nv = sqrt(sum(w(:) .^ 2));
203+ rn = nv / sqrt(sum(v(:) .^ 2));
204+ v = w / nv;
205+ if abs(rn - r) < 1e-6 * rn
206+ r = rn;
207+ break;
208+ end
209+ r = rn;
210+end
211+end
src/chapters/ch06/text.mdadded+41−0View file
@@ -0,0 +1,41 @@
1+A real drum is round, and a square grid cannot follow a circle. This chapter first listens to what a round membrane sounds like, with its exact modes, and then measures what happens to its pitch when it is put on a grid whose nodes do not lie on its rim. In the previous two chapters the error came from the interior stencil alone. Here the boundary contributes an error of its own, and on a coarse grid it is the larger of the two.
2+
3+### The modes of a disk
4+
5+The modes of a unit disk clamped at its rim are
6+
7+$$u_{mk}(r,\theta,t) = J_m(j_{mk}\, r)\cos(m\theta)\cos(2\pi f_{mk} t), \qquad f_{mk} = \frac{c\, j_{mk}}{2\pi},$$
8+
9+where $J_m$ is the Bessel function of order $m$ and $j_{mk}$ its $k$th positive zero, and for $m \ge 1$ there is a second mode with $\sin(m\theta)$ at the same frequency. The index $m$ counts nodal diameters and $k$ counts nodal circles, the rim included. The zeros are not in any simple ratio to one another: relative to the fundamental $j_{01} = 2.4048$, the next modes lie at $1.593$, $2.136$, $2.295$, $2.653$, $2.917$ and so on. The shared library's `wv_bjzeros` finds them by bracketing sign changes of `besselj` and polishing with Newton's method, to within a unit or two in the last place.
10+
11+<!--lab:hear-->
12+
13+Struck at the centre, the drum sounds only its circularly symmetric modes, $m = 0$, since $J_m(0) = 0$ for every other $m$; their ratios $1$, $2.295$, $3.598$, $4.903, \dots$ are far from any harmonic series, and the result is a dull thud. Struck away from the centre, all the modes join in and the sound becomes brighter and more clangorous. Neither is a musical note. Real timpani do sound a pitch, because the air in the kettle loads the membrane and moves the modes with one nodal circle, $(1,1)$, $(2,1)$, $(3,1)$ and $(4,1)$, close to the ratios $2 : 3 : 4 : 5$, so that the ear hears a missing fundamental below them (Fletcher and Rossing 1998). The ideal membrane here has no kettle.
14+
15+### A disk on a square grid
16+
17+Cover the square $[-1,1]^2$ with a grid of spacing $h = 2/N$, make every node strictly inside the unit circle an unknown and every other node zero, and use the five-point Laplacian at every unknown. This is the **staircase** approximation: at an unknown whose neighbour lies outside, the stencil acts as if the rim passed through that neighbour. The drum the grid sees has a jagged edge, which is displaced from the true rim by up to one grid spacing, and by an amount that varies irregularly around the circle and from one grid to the next.
18+
19+A better treatment keeps the true rim. Where the rim crosses a grid line at a fraction $\theta$ of the way to an outside neighbour, the second difference is taken over the two unequal intervals $h$ and $\theta h$, with $u = 0$ at the crossing:
20+
21+$$u_{xx} \approx \frac{2}{h^2}\left[\frac{u_W}{1 + \theta} - \frac{u_P}{\theta}\right].$$
22+
23+This is the **Shortley–Weller** stencil (Shortley and Weller 1938). It is only first-order consistent at the boundary nodes, but for Dirichlet problems it gives second-order accurate solutions, and in practice second-order accurate frequencies. It has a cost that the staircase does not: a node very close to the rim has a coefficient $2/\theta$ that can be arbitrarily large, and an explicit time step must be small enough for the largest eigenvalue of the whole stencil.
24+
25+<!--fig:rim-->
26+
27+The editable script builds the stencil for either treatment; the driver finds its largest eigenvalue by power iteration, runs leapfrog at 0.95 times the largest stable Courant number (never more than the interior's $1/\sqrt{2}$), starts the grid in the exact mode, and measures the frequency of the projection of the field onto that mode. The test modes are $(0,1)$ and $(1,1)$.
28+
29+<!--lab:grid-->
30+
31+The staircase is poor. Its worst pitch error falls from 54 cents at $N = 16$ to 17 at $N = 64$, which is a fitted order of about 0.8, and it does not fall steadily: from $N = 24$ to $N = 32$ it rises. The errors are all negative, because the staircase drum includes every node inside the circle and puts its effective rim beyond the last of them, so it is slightly too large and sounds slightly flat. How much too large depends on exactly how the circle cuts the grid, which is why the convergence is erratic. Extrapolating at first order, 5 cents would need a grid of about 220 intervals.
32+
33+The Shortley–Weller rim is better by a factor of between 7 and 25 on every grid. Its worst error is 7.4 cents at $N = 16$ and 0.69 at $N = 64$, a fitted order of 1.7, and it falls below 5 cents by $N = 24$. The irregularity has not gone, though, and the second plot shows where it went: into the time step. The largest stable Courant number now depends on how close the nearest node comes to the rim, and varies from 0.26 to 0.62 over these grids, against the staircase's 0.67 on every grid. At $N = 64$ it costs a factor of 2.6 in steps.
34+
35+The work-precision plot makes the comparison that matters. The Shortley–Weller rim on the coarsest grid, $N = 16$, is more accurate than the staircase on the finest, $N = 64$, for about a fortieth of the time. Over the range measured, a small time step is a far cheaper price than a jagged rim.
36+
37+We note two limitations. The time-step penalty is unpredictable: a grid on which one node lands at a thousandth of a spacing from the rim would need a time step thirty times smaller. The usual remedies are to merge such nodes with their neighbours, or to use one of the embedded-boundary schemes designed to avoid the restriction (Kreiss, Petersson and Yström 2002 is one); neither is implemented here. And the measured order of 1.7 is an average over irregular behaviour, not a guarantee; on a different family of grids the numbers would differ in detail.
38+
39+### What this chapter establishes
40+
41+On a domain the grid does not fit, the boundary treatment can matter more than the interior stencil. A staircase rim costs an order of accuracy and makes the error unpredictable from one grid to the next; a cut-cell stencil such as Shortley–Weller's recovers second order, at a price paid in time step rather than in accuracy. Chapter 5's fourth-order interior stencils would gain nothing on this drum without a boundary treatment of matching order, which is a harder problem and is not attempted here.
src/chapters/ch07/chapter.tsxadded+223−0View file
@@ -0,0 +1,223 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Num, Params } from '../../engine/types.ts'
3+import driver from './driver.m?raw'
4+import script from './openbox.m?raw'
5+import text from './text.md?raw'
6+import Listen, { type Clip } from '../../audio/Listen.tsx'
7+import Field from '../../plot/Field.tsx'
8+import { Row, SeriesPlot, fmtDb, fmtMs } from '../common/panels.tsx'
9+import { method, type MethodKey } from '../../plot/palette.ts'
10+
11+interface SnapOut {
12+ ms: number
13+ refl: number
14+ tsnap: number
15+ ref: Num[][]
16+ num: Num[][]
17+ err: Num[][]
18+ emax: number
19+ umax: number
20+ urefmax: number
21+ t: number[]
22+ eng: number[]
23+ L: number
24+}
25+
26+interface SweepOut {
27+ methods: string[]
28+ Ws: number[]
29+ refl: Num[][]
30+ ms: Num[][]
31+}
32+
33+interface HearOut {
34+ methods: string[]
35+ fs: number
36+ tt: number[]
37+ traces: number[][]
38+ clips: Clip[]
39+}
40+
41+const EDGE: Record<string, { label: string; key: MethodKey; shape: 'circle' | 'square' | 'diamond' | 'cross' }> = {
42+ rigid: { label: 'rigid wall', key: 'fd2', shape: 'circle' },
43+ mur: { label: 'Mur’s condition', key: 'waveguide', shape: 'diamond' },
44+ sponge: { label: 'damping layer', key: 'fd4', shape: 'square' },
45+ pml: { label: 'perfectly matched layer', key: 'boundary', shape: 'cross' },
46+}
47+
48+const METHOD_CONTROL = {
49+ kind: 'select' as const,
50+ key: 'method',
51+ label: 'edge',
52+ options: Object.entries(EDGE).map(([k, v]) => ({ value: k, label: v.label })),
53+}
54+
55+const REGION = { N: 100, L: 2, lam: 0.7, sigma: 0.06, xs: 0.7, ys: 1.1, T: 0.0085 }
56+
57+function SnapPanels({ d }: { d: SnapOut }) {
58+ const dom: [number, number] = [0, d.L]
59+ return (
60+ <>
61+ <Row>
62+ <Field z={d.ref} xDomain={dom} yDomain={dom} kind="div" zmax={d.umax} height={250} title={`reference, t = ${d.tsnap.toFixed(1)} ms`} />
63+ <Field z={d.num} xDomain={dom} yDomain={dom} kind="div" zmax={d.umax} height={250} title="with this edge, same colour scale" />
64+ <Field z={d.err} xDomain={dom} yDomain={dom} kind="div" height={250} title="the difference, on its own scale" />
65+ </Row>
66+ <SeriesPlot
67+ title="energy in the region that should not be there, relative to the most the region ever held"
68+ height={190}
69+ xLabel="ms"
70+ yLabel="dB"
71+ yDomain={[-160, 10]}
72+ series={[{ x: d.t, y: d.eng.map((v) => Math.max(v, -160)), color: method.boundary, width: 1.6 }]}
73+ legend={false}
74+ />
75+ </>
76+ )
77+}
78+
79+function SweepPanels({ d }: { d: SweepOut }) {
80+ const tip = (k: number) => (j: number) => [`layer ${d.Ws[j]} nodes`, `reflected ${fmtDb(d.refl[k][j])}`, `run ${fmtMs(d.ms[k][j])}`]
81+ const layered = d.methods.map((m, k) => ({ m, k })).filter(({ m }) => m === 'sponge' || m === 'pml')
82+ const flat = d.methods.map((m, k) => ({ m, k })).filter(({ m }) => m === 'rigid' || m === 'mur')
83+ return (
84+ <Row>
85+ <SeriesPlot
86+ title="reflected energy against the width of the layer; the two edges without a layer are drawn as lines"
87+ height={250}
88+ xLog
89+ xTicks={d.Ws}
90+ xLabel="layer width, nodes"
91+ yLabel="dB"
92+ yDomain={[-120, 10]}
93+ hlines={flat.map(({ m, k }) => ({ y: d.refl[k][0] as number, color: method[EDGE[m].key], label: EDGE[m].label }))}
94+ series={layered.map(({ m, k }) => ({
95+ x: d.Ws,
96+ y: d.refl[k],
97+ color: method[EDGE[m].key],
98+ shape: EDGE[m].shape,
99+ label: EDGE[m].label,
100+ tip: tip(k),
101+ }))}
102+ />
103+ <SeriesPlot
104+ title="the same against the time each run took"
105+ height={250}
106+ xLog
107+ xLabel="ms"
108+ yLabel="dB"
109+ yDomain={[-120, 10]}
110+ series={d.methods.map((m, k) => ({
111+ x: d.ms[k],
112+ y: d.refl[k],
113+ color: method[EDGE[m].key],
114+ shape: EDGE[m].shape,
115+ label: EDGE[m].label,
116+ tip: tip(k),
117+ }))}
118+ />
119+ </Row>
120+ )
121+}
122+
123+function HearPanels({ d }: { d: HearOut }) {
124+ return (
125+ <>
126+ <SeriesPlot
127+ title="the first 80 ms at the microphone"
128+ height={230}
129+ xLabel="ms"
130+ series={d.methods.map((m, k) => ({
131+ x: d.tt,
132+ y: d.traces[k].map((v) => v + 1.2 * (d.methods.length - 1 - k)),
133+ color: method[EDGE[m].key],
134+ label: EDGE[m].label,
135+ width: 1.2,
136+ }))}
137+ yTicks={[]}
138+ />
139+ <Listen
140+ items={d.methods.map((m, k) => ({ label: EDGE[m].label, clip: d.clips[k] }))}
141+ note={`the same scale, at the simulation's own rate of ${(d.fs / 1000).toFixed(1)} kHz`}
142+ />
143+ </>
144+ )
145+}
146+
147+export const ch07: Chapter = {
148+ id: 'open',
149+ nav: 'Letting sound out',
150+ part: 'Rooms',
151+ title: 'Letting sound out',
152+ blurb:
153+ 'A grid has to end somewhere. Four ways of ending it, measured against a reference that is exact by causality, and heard.',
154+ text,
155+ labs: [
156+ {
157+ id: 'snap',
158+ title: 'One pulse, four edges',
159+ caption:
160+ 'A pulse released 0.7 m from the left of a 2 m square. The snapshot is taken once it has reached every edge; whatever is in the difference panel came back from one.',
161+ driver,
162+ script,
163+ controls: [
164+ METHOD_CONTROL,
165+ { kind: 'slider', key: 'W', label: 'layer width, nodes', min: 0, max: 0, values: [5, 10, 20, 40] },
166+ ],
167+ defaults: { method: 'mur', W: 10 },
168+ derive: (p: Params) => ({ mode: 'snap', ...REGION, tsnap: 0.0042, method: p.method, W: p.W }),
169+ editorOpen: true,
170+ render: (d: SnapOut) => <SnapPanels d={d} />,
171+ summary: (d: SnapOut) => [
172+ { label: 'reflected energy', value: fmtDb(d.refl) },
173+ { label: 'largest reflection / largest wave', value: (d.emax / d.urefmax).toExponential(1) },
174+ ],
175+ },
176+ {
177+ id: 'sweep',
178+ title: 'Reflection against cost',
179+ caption: 'Every edge, and the two layers at four widths. This lab takes several seconds, so it waits for the run button.',
180+ driver,
181+ script,
182+ controls: [],
183+ defaults: {},
184+ derive: () => ({ mode: 'sweep', ...REGION, methods: ['rigid', 'mur', 'sponge', 'pml'], Ws: [5, 10, 20, 40] }),
185+ manual: true,
186+ render: (d: SweepOut) => <SweepPanels d={d} />,
187+ summary: (d: SweepOut) => {
188+ const k = d.methods.indexOf('pml')
189+ return [
190+ { label: 'Mur', value: fmtDb(d.refl[d.methods.indexOf('mur')][0]) },
191+ { label: 'PML, 5 nodes', value: fmtDb(d.refl[k][0]) },
192+ { label: 'PML, 40 nodes', value: fmtDb(d.refl[k][d.Ws.length - 1]) },
193+ ]
194+ },
195+ },
196+ {
197+ id: 'hear',
198+ title: 'A box, and a box that is not there',
199+ caption:
200+ 'A 3 m square, a thump at one point, a microphone at another, for 0.3 s. This lab waits for the run button.',
201+ driver,
202+ script,
203+ controls: [],
204+ defaults: {},
205+ derive: () => ({
206+ mode: 'hear',
207+ N: 60,
208+ L: 3,
209+ lam: 0.7,
210+ sigma: 0.1,
211+ xs: 1.0,
212+ ys: 1.3,
213+ xr: 2.1,
214+ yr: 1.7,
215+ T: 0.3,
216+ W: 10,
217+ methods: ['rigid', 'mur', 'pml'],
218+ }),
219+ manual: true,
220+ render: (d: HearOut) => <HearPanels d={d} />,
221+ },
222+ ],
223+}
src/chapters/ch07/driver.madded+137−0View file
@@ -0,0 +1,137 @@
1+% Driver for Chapter 7: letting sound out of a grid.
2+%
3+% The editable script supplies
4+%
5+% U = openbox(u0, uold, lam, M, method, W, keep)
6+%
7+% which runs leapfrog on a square region with some treatment of its edges,
8+% and returns the region's field at the nodes `keep` after every step.
9+%
10+% The reference is exact without being a formula. An explicit scheme moves
11+% information by at most one node per step, so the same leapfrog scheme on a
12+% grid that extends M nodes further in every direction cannot feel its own
13+% edges anywhere in the region during M steps: inside the region, it is the
14+% scheme's free-space solution, to rounding error. The driver runs that
15+% reference with its own code. Whatever differs from it in the region is
16+% what the edge treatment sent back, and nothing else.
17+%
18+% mode 'snap' one method: snapshots, and the reflection it produced.
19+% mode 'sweep' every method at several layer widths: reflection against
20+% cost.
21+% mode 'hear' a longer run with a click, heard at one point, for a rigid
22+% box and for two absorbing edges.
23+p = wv_params();
24+out = struct();
25+N = p.N;
26+n0 = N + 1;
27+lam = p.lam;
28+h = p.L / N;
29+c = 343;
30+dt = lam * h / c;
31+x = (0:N)' * h;
32+[X, Y] = meshgrid(x, x);
33+sg = p.sigma;
34+u0 = exp(-((X - p.xs) .^ 2 + (Y - p.ys) .^ 2) / (2 * sg ^ 2));
35+% released from rest: uold = u0 + (lam^2/2) Lap_h u0, the same for every run
36+lap = zeros(n0, n0);
37+lap(2:N, 2:N) = u0(3:n0, 2:N) + u0(1:N - 1, 2:N) + u0(2:N, 3:n0) + u0(2:N, 1:N - 1) - 4 * u0(2:N, 2:N);
38+um = u0 + 0.5 * lam ^ 2 * lap;
39+
40+if strcmp(p.mode, 'hear')
41+ M = round(p.T / dt);
42+ ir = round(p.xr / h) + 1;
43+ jr = round(p.yr / h) + 1;
44+ meths = p.methods;
45+ Yh = zeros(M, numel(meths));
46+ for k = 1:numel(meths)
47+ U = openbox(u0, um, lam, M, meths{k}, p.W, (jr - 1) * n0 + ir);
48+ Yh(:, k) = U(1, :)';
49+ end
50+ s = max(abs(Yh(:)));
51+ fs = 1 / dt;
52+ clips = cell(1, numel(meths));
53+ for k = 1:numel(meths)
54+ clips{k} = wv_audio(Yh(:, k), fs, s);
55+ end
56+ out.clips = clips;
57+ out.methods = meths;
58+ out.fs = fs;
59+ out.t = (1:M) * dt * 1000;
60+ nkeep = min(M, round(0.08 / dt));
61+ out.tt = out.t(1:nkeep);
62+ out.traces = wv_rows(Yh(1:nkeep, :)' / s);
63+else
64+ M = round(p.T / dt);
65+ Uref = drv_reference(u0, um, lam, M);
66+ rn = sum(Uref(:) .^ 2);
67+ if strcmp(p.mode, 'snap')
68+ t0 = tic;
69+ U = openbox(u0, um, lam, M, p.method, p.W, (1:n0 * n0)');
70+ out.ms = toc(t0) * 1000;
71+ E = U - Uref;
72+ out.refl = 10 * log10(sum(E(:) .^ 2) / rn);
73+ ks = min(M, round(p.tsnap / dt));
74+ out.tsnap = ks * dt * 1000;
75+ out.ref = wv_rows(reshape(Uref(:, ks), n0, n0));
76+ out.num = wv_rows(reshape(U(:, ks), n0, n0));
77+ out.err = wv_rows(reshape(E(:, ks), n0, n0));
78+ out.emax = max(abs(E(:)));
79+ out.umax = max(abs(Uref(:, ks)));
80+ out.urefmax = max(abs(Uref(:)));
81+ % the reflected energy in the region, step by step
82+ out.t = (1:M) * dt * 1000;
83+ out.eng = 10 * log10(max(sum(E .^ 2, 1) / max(sum(Uref .^ 2, 1)), 1e-30));
84+ out.L = p.L;
85+ else
86+ meths = p.methods;
87+ Ws = p.Ws(:)';
88+ R = NaN(numel(meths), numel(Ws));
89+ TM = NaN(numel(meths), numel(Ws));
90+ for k = 1:numel(meths)
91+ for j = 1:numel(Ws)
92+ if (strcmp(meths{k}, 'rigid') || strcmp(meths{k}, 'mur')) && j > 1
93+ continue;
94+ end
95+ t0 = tic;
96+ U = openbox(u0, um, lam, M, meths{k}, Ws(j), (1:n0 * n0)');
97+ TM(k, j) = toc(t0) * 1000;
98+ E = U - Uref;
99+ R(k, j) = 10 * log10(sum(E(:) .^ 2) / rn);
100+ end
101+ end
102+ out.methods = meths;
103+ out.Ws = Ws;
104+ out.refl = wv_rows(R);
105+ out.ms = wv_rows(TM);
106+ end
107+end
108+
109+wv_write(out);
110+
111+function U = drv_reference(u0, uold, lam, M)
112+% The same scheme on a grid M + 2 nodes larger on every side, with its far
113+% edges held at zero: nothing from those edges reaches the region in M steps.
114+n0 = size(u0, 1);
115+e = M + 2;
116+n1 = n0 + 2 * e;
117+u = zeros(n1, n1);
118+uo = zeros(n1, n1);
119+u(e + 1:e + n0, e + 1:e + n0) = u0;
120+uo(e + 1:e + n0, e + 1:e + n0) = uold;
121+un = zeros(n1, n1);
122+l2 = lam ^ 2;
123+U = zeros(n0 * n0, M);
124+for n = 1:M
125+ % only the nodes that can have become nonzero need updating
126+ lo = max(2, e + 1 - n);
127+ hi = min(n1 - 1, e + n0 + n);
128+ for j = lo:hi
129+ for i = lo:hi
130+ un(i, j) = 2 * u(i, j) - uo(i, j) + l2 * (u(i + 1, j) + u(i - 1, j) + u(i, j + 1) + u(i, j - 1) - 4 * u(i, j));
131+ end
132+ end
133+ uo = u;
134+ u = un;
135+ U(:, n) = reshape(u(e + 1:e + n0, e + 1:e + n0), n0 * n0, 1);
136+end
137+end
src/chapters/ch07/openbox.madded+186−0View file
@@ -0,0 +1,186 @@
1+% Four ways to end a grid, for waves that ought to leave it.
2+%
3+% The region of interest is the square of (N+1)-by-(N+1) nodes that u0
4+% covers. Around it, a method may add a layer of W extra nodes on every
5+% side, which costs work but is never looked at. The interior is Chapter 4's
6+% leapfrog scheme; the methods differ only at the edge:
7+%
8+% 'rigid' a hard wall, du/dn = 0, by mirroring the first interior node
9+% into a ghost. Everything comes back, with the same sign.
10+% 'mur' Mur's (1981) first-order absorbing condition, a discrete
11+% u_t + c u_n = 0 on each edge. It is exact for a wave
12+% arriving square-on and reflects a fraction
13+% (1 - cos a) / (1 + cos a) of one arriving at angle a.
14+% 'sponge' a damping layer: the equation u_tt + 2 s(x) u_t = c^2 Lap u,
15+% with s rising from zero to smax across W nodes as the square
16+% of the depth, and a zero-displacement edge behind it. A wave
17+% is damped on the way in and again on the way back out, and
18+% part of it is reflected by the change of s itself.
19+% 'pml' a perfectly matched layer (Berenger 1994). The scheme is
20+% rewritten in pressure and velocity on a staggered grid,
21+% which in the region is the same leapfrog scheme exactly;
22+% in the layer the pressure is split into the parts driven by
23+% the x and the y velocity, and each is damped only in its own
24+% direction. In the continuum this absorbs a wave at any
25+% angle and any frequency without reflecting any of it; on
26+% the grid, the profile's steps reflect a little.
27+%
28+% u0 and uold are the region's field at steps 0 and -1. The result U holds
29+% the region's field at the nodes `keep` (linear indices into the region)
30+% after each of the M steps, one column per step.
31+
32+function U = openbox(u0, uold, lam, M, method, W, keep)
33+n0 = size(u0, 1);
34+if strcmp(method, 'rigid') || strcmp(method, 'mur')
35+ W = 0;
36+end
37+if strcmp(method, 'pml')
38+ U = drv_pml(u0, uold, lam, M, W, keep);
39+ return;
40+end
41+n1 = n0 + 2 * W;
42+u = zeros(n1, n1);
43+uo = zeros(n1, n1);
44+u(W + 1:W + n0, W + 1:W + n0) = u0;
45+uo(W + 1:W + n0, W + 1:W + n0) = uold;
46+un = zeros(n1, n1);
47+l2 = lam ^ 2;
48+% the damping, per step: s dt at each node, zero in the region itself
49+sd = zeros(n1, n1);
50+if W > 0
51+ % s dt at the outer edge. A stronger sponge damps more but reflects more
52+ % from its own gradient; this strength did best of the few tried.
53+ smax = 3 * lam / (2 * W) * log(100);
54+ for j = 1:n1
55+ for i = 1:n1
56+ di = max([W + 1 - i, i - (W + n0), 0]);
57+ dj = max([W + 1 - j, j - (W + n0), 0]);
58+ d = max(di, dj) / W;
59+ sd(i, j) = smax * d ^ 2;
60+ end
61+ end
62+end
63+rigid = strcmp(method, 'rigid');
64+mur = strcmp(method, 'mur');
65+km = (lam - 1) / (lam + 1);
66+U = zeros(numel(keep), M);
67+for n = 1:M
68+ for j = 2:n1 - 1
69+ for i = 2:n1 - 1
70+ a = sd(i, j);
71+ un(i, j) = (2 * u(i, j) - (1 - a) * uo(i, j) ...
72+ + l2 * (u(i + 1, j) + u(i - 1, j) + u(i, j + 1) + u(i, j - 1) - 4 * u(i, j))) / (1 + a);
73+ end
74+ end
75+ if rigid
76+ % the edge node sees its inner neighbour twice
77+ for k = 2:n1 - 1
78+ un(1, k) = 2 * u(1, k) - uo(1, k) + l2 * (2 * u(2, k) + u(1, k + 1) + u(1, k - 1) - 4 * u(1, k));
79+ un(n1, k) = 2 * u(n1, k) - uo(n1, k) + l2 * (2 * u(n1 - 1, k) + u(n1, k + 1) + u(n1, k - 1) - 4 * u(n1, k));
80+ un(k, 1) = 2 * u(k, 1) - uo(k, 1) + l2 * (2 * u(k, 2) + u(k + 1, 1) + u(k - 1, 1) - 4 * u(k, 1));
81+ un(k, n1) = 2 * u(k, n1) - uo(k, n1) + l2 * (2 * u(k, n1 - 1) + u(k + 1, n1) + u(k - 1, n1) - 4 * u(k, n1));
82+ end
83+ un(1, 1) = 2 * u(1, 1) - uo(1, 1) + l2 * (2 * u(2, 1) + 2 * u(1, 2) - 4 * u(1, 1));
84+ un(1, n1) = 2 * u(1, n1) - uo(1, n1) + l2 * (2 * u(2, n1) + 2 * u(1, n1 - 1) - 4 * u(1, n1));
85+ un(n1, 1) = 2 * u(n1, 1) - uo(n1, 1) + l2 * (2 * u(n1 - 1, 1) + 2 * u(n1, 2) - 4 * u(n1, 1));
86+ un(n1, n1) = 2 * u(n1, n1) - uo(n1, n1) + l2 * (2 * u(n1 - 1, n1) + 2 * u(n1, n1 - 1) - 4 * u(n1, n1));
87+ elseif mur
88+ for k = 2:n1 - 1
89+ un(1, k) = u(2, k) + km * (un(2, k) - u(1, k));
90+ un(n1, k) = u(n1 - 1, k) + km * (un(n1 - 1, k) - u(n1, k));
91+ un(k, 1) = u(k, 2) + km * (un(k, 2) - u(k, 1));
92+ un(k, n1) = u(k, n1 - 1) + km * (un(k, n1 - 1) - u(k, n1));
93+ end
94+ un(1, 1) = 0.5 * (un(2, 1) + un(1, 2));
95+ un(1, n1) = 0.5 * (un(2, n1) + un(1, n1 - 1));
96+ un(n1, 1) = 0.5 * (un(n1 - 1, 1) + un(n1, 2));
97+ un(n1, n1) = 0.5 * (un(n1 - 1, n1) + un(n1, n1 - 1));
98+ end
99+ uo = u;
100+ u = un;
101+ R = u(W + 1:W + n0, W + 1:W + n0);
102+ U(:, n) = R(keep);
103+end
104+end
105+
106+function U = drv_pml(u0, uold, lam, M, W, keep)
107+% Leapfrog in pressure p and scaled velocity w = rho c v, staggered: w_x sits
108+% between p(i, j) and p(i, j+1), w_y between p(i, j) and p(i+1, j).
109+%
110+% w <- w - lam grad p, p <- p - lam div w
111+%
112+% Eliminating w gives Chapter 4's scheme for p, so in the region this is the
113+% same computation. In the layer, p = px + py and every field is damped by
114+% the profile of its own direction, s(d) = smax (d/W)^2 at depth d, with
115+% smax chosen for a nominal reflection of 1e-5 at normal incidence.
116+n0 = size(u0, 1);
117+n1 = n0 + 2 * W;
118+smax = 3 * lam / (2 * W) * log(1e5);
119+% damping per step at whole and half nodes, along one axis
120+dw = zeros(1, n1);
121+dh = zeros(1, n1 - 1);
122+for k = 1:n1
123+ d = max([W + 1 - k, k - (W + n0), 0]) / W;
124+ dw(k) = smax * d ^ 2;
125+end
126+for k = 1:n1 - 1
127+ kk = k + 0.5;
128+ d = max([W + 1 - kk, kk - (W + n0), 0]) / W;
129+ dh(k) = smax * d ^ 2;
130+end
131+p0 = zeros(n1, n1);
132+p0(W + 1:W + n0, W + 1:W + n0) = u0;
133+pm = zeros(n1, n1);
134+pm(W + 1:W + n0, W + 1:W + n0) = uold;
135+% start at rest: w at step -1/2 is lam/2 times the gradient of p at step 0,
136+% which makes p at step 1 equal to what leapfrog would give from p0 and pm
137+wx = zeros(n1, n1 - 1);
138+wy = zeros(n1 - 1, n1);
139+for j = 1:n1 - 1
140+ for i = 1:n1
141+ wx(i, j) = 0.5 * lam * (p0(i, j + 1) - p0(i, j));
142+ end
143+end
144+for j = 1:n1
145+ for i = 1:n1 - 1
146+ wy(i, j) = 0.5 * lam * (p0(i + 1, j) - p0(i, j));
147+ end
148+end
149+% the split: all of p0 starts in px, which is harmless because the split
150+% only matters inside the layer, where p0 is zero
151+px = p0;
152+py = zeros(n1, n1);
153+pp = p0;
154+% the damped update x <- (1 - a) x / (1 + a) - lam (...) / (1 + a), with its
155+% two coefficients worked out once per row or column
156+fw = (1 - dw / 2) ./ (1 + dw / 2);
157+gw = lam ./ (1 + dw / 2);
158+fh = (1 - dh / 2) ./ (1 + dh / 2);
159+gh = lam ./ (1 + dh / 2);
160+U = zeros(numel(keep), M);
161+for n = 1:M
162+ for j = 1:n1 - 1
163+ f = fh(j);
164+ g = gh(j);
165+ for i = 1:n1
166+ wx(i, j) = f * wx(i, j) - g * (pp(i, j + 1) - pp(i, j));
167+ end
168+ end
169+ for j = 1:n1
170+ for i = 1:n1 - 1
171+ wy(i, j) = fh(i) * wy(i, j) - gh(i) * (pp(i + 1, j) - pp(i, j));
172+ end
173+ end
174+ for j = 2:n1 - 1
175+ f = fw(j);
176+ g = gw(j);
177+ for i = 2:n1 - 1
178+ px(i, j) = f * px(i, j) - g * (wx(i, j) - wx(i, j - 1));
179+ py(i, j) = fw(i) * py(i, j) - gw(i) * (wy(i, j) - wy(i - 1, j));
180+ pp(i, j) = px(i, j) + py(i, j);
181+ end
182+ end
183+ R = pp(W + 1:W + n0, W + 1:W + n0);
184+ U(:, n) = R(keep);
185+end
186+end
src/chapters/ch07/text.mdadded+54−0View file
@@ -0,0 +1,54 @@
1+Every grid ends somewhere. For a string, a drum or a room with hard walls, the end of the grid is a physical boundary and Chapters 2 to 6 imposed it. For sound radiating into open space it is not: the grid has to end, but the sound should behave as though it had not. This chapter compares four ways of ending a grid, and it needs a reference that is exact for a problem without any closed form.
2+
3+### A reference from causality
4+
5+Take the leapfrog scheme of Chapter 4 on a square region, with a pulse of sound released inside it. The exact solution of the two-dimensional wave equation in free space for this initial condition exists as an integral, but it is not what the grid computes: the grid computes its own free-space solution, dispersion and all, and a boundary treatment should be judged by how well it reproduces that, not by the scheme's interior error, which is the subject of other chapters.
6+
7+The grid's free-space solution can be computed exactly. An explicit scheme moves information by at most one node per step, so a grid that extends $M + 2$ nodes beyond the region on every side, with any condition at all on its far edges, cannot be influenced by those edges anywhere in the region during $M$ steps. Inside the region, for those $M$ steps, it *is* the free-space solution of the scheme, to rounding error. The driver runs this larger grid with its own code, and whatever the method's region contains that the reference's does not came back from the method's edge.
8+
9+<!--fig:causality-->
10+
11+### Four edges
12+
13+The script offers four treatments, all around the same interior scheme.
14+
15+A **rigid wall**, $\partial u/\partial n = 0$, imposed by a mirrored ghost node. It reflects everything, with the same sign, and it is here as a baseline.
16+
17+**Mur's condition** (Mur 1981) discretises the one-way wave equation $u_t + c\,u_n = 0$ on each edge, which lets a wave arriving square-on leave without reflection. A wave arriving at angle $a$ from the normal is reflected with amplitude $(1 - \cos a)/(1 + \cos a)$: 0.17 at $45°$ and a third at $60°$. The corners, where two edges meet, get a cruder average. It costs nothing beyond the region itself.
18+
19+A **damping layer** of $W$ extra nodes on every side, in which the equation becomes $u_{tt} + 2s\,u_t = c^2\nabla^2 u$, with $s$ rising from zero as the square of the depth into the layer. A wave is damped on its way in, reflected by the zero-displacement edge behind the layer, and damped again on its way out. Its difficulty is that the change of $s$ is itself a change of medium and reflects part of the wave before it is damped. A gentle profile reflects less but needs a wide layer to damp enough.
20+
21+A **perfectly matched layer** (PML) of $W$ nodes (Berenger 1994). The scheme is rewritten in the pressure-velocity form of the introduction, on a staggered grid, which in the region is exactly the same leapfrog scheme; in the layer the pressure is split into the parts driven by the $x$ and $y$ velocities, and each is damped only in its own direction. In the continuum this layer absorbs waves at every angle and every frequency without reflecting any of them, because it is equivalent to continuing the equation into complex coordinates. On the grid, the steps in its damping profile reflect a little.
22+
23+<!--lab:snap-->
24+
25+The region is a 2 m square with 100 intervals, the pulse is released 0.7 m from its left edge, and the run lasts 8.5 ms, long enough for the pulse to reach every edge and for what comes back to cross most of the region. The number reported is the energy of the difference from the reference over the region and the whole run, relative to the reference's own energy.
26+
27+The rigid wall returns more energy than the reference keeps, $+2.4$ dB, as it should. Mur's condition returns $-22.7$ dB, about a two-hundredth of the energy, mostly from the waves that meet the edges obliquely and from the corners; the difference panel shows the reflected fronts, strongest where the pulse arrived at a glancing angle. The damping layer at $W = 10$ returns $-8.8$ dB, worse than Mur's condition with no layer at all.
28+
29+### Reflection against cost
30+
31+<!--lab:sweep-->
32+
33+| layer width $W$ | 5 | 10 | 20 | 40 |
34+|---|---|---|---|---|
35+| damping layer | −4.1 dB | −8.8 dB | −14.8 dB | −21.3 dB |
36+| PML | −62.1 dB | −80.2 dB | −95.2 dB | −107.3 dB |
37+
38+With no layer, Mur's condition gives $-22.7$ dB and the rigid wall $+2.4$ dB.
39+
40+The damping layer improves by about 6 dB for every doubling of its width, and even at 40 nodes, which is 0.8 m and more than doubles the number of nodes in the computation, it does no better than Mur's condition. The reflection from its own gradient is the limit, and a stronger damping profile makes that worse rather than better; the profile used here was the best of the few we tried. The PML at five nodes returns $-62$ dB, forty decibels better than anything else, and it gains between 12 and 18 dB per doubling after that. At every cost measured it is the best of the four by a wide margin.
41+
42+The PML is not free. Its layer carries four fields instead of one, and in this implementation it runs at a little under half the speed per node of the plain scheme, so a five-node PML costs about twice as much as Mur's condition on this region, or a little more. On a larger region the layer's share of the work falls, and on a three-dimensional one it is the standard choice for exactly this reason. Mur's condition remains worth knowing: it is simple, it costs nothing, and $-23$ dB is enough for some purposes.
43+
44+### Hearing the difference
45+
46+A longer run makes the reflections audible. The region is now a 3 m square with 60 intervals, the pulse is a low thump, and the microphone is 1.2 m from the source; the run lasts 0.3 s, which is about 34 crossings of the region.
47+
48+<!--lab:hear-->
49+
50+The rigid box rings: the pulse reflects back and forth between parallel walls, and the recording is a dense, pitched flutter that continues for the whole run. Mur's condition removes most of it, leaving a single thump and a faint tail of reflections. The PML leaves the thump alone. Even that is not a single click, and the reason is physical rather than numerical: in two dimensions, a pulse from a point leaves a slowly decaying wake behind its front, because a point in a plane is really an infinite line source in space, and sound from the distant parts of the line keeps arriving after the nearest part has passed. Sound in three dimensions does not do this, which is one reason the two-dimensional computations of this chapter are a model of the method, not of a room.
51+
52+### What this chapter establishes
53+
54+A grid's edge can be made to behave like open space, and how well is a matter of method more than of effort. Mur's condition is free and returns about a two-hundredth of the energy; a damping layer, however wide, does little better; a perfectly matched layer of a few nodes returns less than a millionth. The comparison was only possible because the reference was exact by causality, which is a construction worth remembering: whenever a scheme's own free-space solution is what is needed, a larger grid that runs for fewer steps than it is wide provides it exactly.
src/chapters/ch08/chapter.tsxadded+206−0View file
@@ -0,0 +1,206 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import type { Params } from '../../engine/types.ts'
3+import driver from './driver.m?raw'
4+import script from './images.m?raw'
5+import text from './text.md?raw'
6+import Listen, { type Clip } from '../../audio/Listen.tsx'
7+import { Row, SeriesPlot, fmtMs, fmtNum } from '../common/panels.tsx'
8+import { faded, method } from '../../plot/palette.ts'
9+
10+interface RoomOut {
11+ ms: number
12+ rt: number
13+ rtbroad: number
14+ sabine: number
15+ eyring: number
16+ lattice: number
17+ ts: number[]
18+ sch: number[]
19+ schB: number[]
20+ tp: number[]
21+ sp: number[]
22+ tmax: number
23+ te: number[]
24+ he: number[]
25+ direct: number
26+ clipDry: Clip
27+ clipWet: Clip
28+ clipIr: Clip
29+ alpha: number
30+ V: number
31+ S: number
32+}
33+
34+interface DecayOut {
35+ alphas: number[]
36+ rt: number[]
37+ lattice: number[]
38+ sabine: number[]
39+ eyring: number[]
40+ ms: number[]
41+}
42+
43+const ROOM = { L: [6, 4.5, 3], xs: [1.5, 1.2, 1.4], xr: [4.3, 3.1, 1.6] }
44+const FORMULA = { sabine: method.fd2, eyring: method.waveguide, lattice: method.exact }
45+const s = (v: number) => `${fmtNum(v, 3)} s`
46+
47+function RoomPanels({ d }: { d: RoomOut }) {
48+ const line = (rt: number) => ({ x: [0, d.tmax * 1000], y: [0, (-60 * d.tmax) / rt] })
49+ return (
50+ <>
51+ <SeriesPlot
52+ title={`the first 60 ms of the impulse response: the direct sound at ${d.direct.toFixed(1)} ms, then the early reflections one by one`}
53+ height={170}
54+ xLabel="ms"
55+ series={[{ x: d.te, y: d.he, color: method.boundary, width: 1 }]}
56+ legend={false}
57+ />
58+ <SeriesPlot
59+ title="Schroeder decay curves: the backward-integrated energy of the response, against three predictions"
60+ height={260}
61+ xLabel="ms"
62+ yLabel="dB"
63+ yDomain={[-60, 2]}
64+ series={[
65+ { ...line(d.sabine), color: FORMULA.sabine, dash: '6 4', width: 1.4, label: `Sabine, ${s(d.sabine)}` },
66+ { ...line(d.eyring), color: FORMULA.eyring, dash: '6 4', width: 1.4, label: `Eyring, ${s(d.eyring)}` },
67+ { x: d.tp, y: d.sp, color: FORMULA.lattice, width: 2.4, label: `the lattice, ${s(d.lattice)}` },
68+ { x: d.ts, y: d.sch, color: faded(method.boundary, 0.4), width: 1.4, label: `measured, broadband, ${s(d.rtbroad)}` },
69+ { x: d.ts, y: d.schB, color: method.boundary, width: 1.8, label: `measured, 0.7–5.7 kHz, ${s(d.rt)}` },
70+ ]}
71+ />
72+ <Listen
73+ items={[
74+ { label: 'dry', clip: d.clipDry },
75+ { label: 'in the room', clip: d.clipWet },
76+ { label: 'the impulse response itself', clip: d.clipIr },
77+ ]}
78+ note="each normalised to its own peak"
79+ />
80+ </>
81+ )
82+}
83+
84+function DecayPanels({ d }: { d: DecayOut }) {
85+ const tip = (i: number) => [
86+ `α = ${d.alphas[i]}`,
87+ `measured ${s(d.rt[i])}`,
88+ `lattice ${s(d.lattice[i])}`,
89+ `Sabine ${s(d.sabine[i])}, Eyring ${s(d.eyring[i])}`,
90+ `images ${fmtMs(d.ms[i])}`,
91+ ]
92+ const ratio = (v: number[]) => v.map((x, i) => x / d.lattice[i])
93+ return (
94+ <Row>
95+ <SeriesPlot
96+ title="reverberation time against the absorption coefficient"
97+ height={250}
98+ xLog
99+ yLog
100+ xTicks={d.alphas}
101+ xLabel="absorption α"
102+ yLabel="seconds"
103+ series={[
104+ { x: d.alphas, y: d.sabine, color: FORMULA.sabine, shape: 'circle', dash: '6 4', label: 'Sabine', tip },
105+ { x: d.alphas, y: d.eyring, color: FORMULA.eyring, shape: 'diamond', dash: '6 4', label: 'Eyring', tip },
106+ { x: d.alphas, y: d.lattice, color: FORMULA.lattice, shape: 'plus', label: 'the lattice', tip },
107+ { x: d.alphas, y: d.rt, color: method.boundary, shape: 'cross', label: 'measured', tip },
108+ ]}
109+ />
110+ <SeriesPlot
111+ title="each as a fraction of the lattice's prediction"
112+ height={250}
113+ xLog
114+ xTicks={d.alphas}
115+ yDomain={[0.4, 1.6]}
116+ xLabel="absorption α"
117+ series={[
118+ { x: d.alphas, y: ratio(d.sabine), color: FORMULA.sabine, shape: 'circle', dash: '6 4', label: 'Sabine', tip },
119+ { x: d.alphas, y: ratio(d.eyring), color: FORMULA.eyring, shape: 'diamond', dash: '6 4', label: 'Eyring', tip },
120+ { x: d.alphas, y: ratio(d.rt), color: method.boundary, shape: 'cross', label: 'measured', tip },
121+ ]}
122+ hlines={[{ y: 1, color: FORMULA.lattice, dash: '1 0' }]}
123+ legend={false}
124+ />
125+ </Row>
126+ )
127+}
128+
129+export const ch08: Chapter = {
130+ id: 'images',
131+ nav: 'A room, by images',
132+ part: 'Rooms',
133+ title: 'A room, by image sources',
134+ blurb:
135+ 'The walls replaced by mirrors, the room by a lattice of images, and the two reverberation formulas every acoustician knows, measured against both.',
136+ text,
137+ labs: [
138+ {
139+ id: 'room',
140+ title: 'A 6 × 4.5 × 3 m room',
141+ caption:
142+ 'Every wall absorbs the same fraction of the energy that reaches it. Change it, and listen to the same phrase played in each room.',
143+ driver,
144+ script,
145+ controls: [
146+ {
147+ kind: 'select',
148+ key: 'alpha',
149+ label: 'absorption α',
150+ options: [
151+ { value: 0.1, label: '0.1, hard walls' },
152+ { value: 0.2, label: '0.2' },
153+ { value: 0.35, label: '0.35' },
154+ { value: 0.6, label: '0.6, heavily damped' },
155+ ],
156+ },
157+ {
158+ kind: 'select',
159+ key: 'taps',
160+ label: 'each arrival placed',
161+ options: [
162+ { value: 0, label: 'on the nearest sample' },
163+ { value: 8, label: 'as an 8-tap windowed sinc' },
164+ { value: 32, label: 'as a 32-tap windowed sinc' },
165+ ],
166+ },
167+ ],
168+ defaults: { alpha: 0.2, taps: 8 },
169+ derive: (p: Params) => ({
170+ mode: 'room',
171+ ...ROOM,
172+ fs: 22050,
173+ alpha: p.alpha,
174+ taps: p.taps,
175+ tcap: 1.2,
176+ notes: [60, 64, 67, 72, 76, 72, 67, 64, 60],
177+ step: 0.24,
178+ }),
179+ editorOpen: true,
180+ render: (d: RoomOut) => <RoomPanels d={d} />,
181+ summary: (d: RoomOut) => [
182+ { label: 'images', value: fmtMs(d.ms) },
183+ { label: 'RT measured', value: s(d.rt) },
184+ { label: 'lattice', value: s(d.lattice) },
185+ { label: 'Sabine', value: s(d.sabine) },
186+ { label: 'Eyring', value: s(d.eyring) },
187+ ],
188+ },
189+ {
190+ id: 'decay',
191+ title: 'Reverberation time against absorption',
192+ caption: 'Six rooms, from nearly bare to heavily damped. This lab takes a few seconds, so it waits for the run button.',
193+ driver,
194+ script,
195+ controls: [],
196+ defaults: {},
197+ derive: () => ({ mode: 'decay', ...ROOM, fs: 16000, alphas: [0.05, 0.1, 0.2, 0.3, 0.5, 0.7], tcap: 1.5, taps: 8 }),
198+ manual: true,
199+ render: (d: DecayOut) => <DecayPanels d={d} />,
200+ summary: (d: DecayOut) => {
201+ const dev = Math.max(...d.rt.slice(0, 4).map((v, i) => Math.abs(v / d.lattice[i] - 1)))
202+ return [{ label: 'measured vs lattice, α ≤ 0.3', value: `within ${(100 * dev).toFixed(0)} %` }]
203+ },
204+ },
205+ ],
206+}
src/chapters/ch08/driver.madded+194−0View file
@@ -0,0 +1,194 @@
1+% Driver for Chapter 8: a room, by image sources.
2+%
3+% The editable script supplies
4+%
5+% h = images(L, xs, xr, beta, fs, tmax, taps)
6+%
7+% the impulse response of a rectangular room whose walls all reflect a
8+% fraction beta of the pressure, from source xs to receiver xr.
9+%
10+% What the driver measures is the decay of the response, and what it compares
11+% it with are three predictions that share no algebra with the script:
12+%
13+% Sabine (1900) RT = 24 ln(10) V / (c S alpha)
14+% Eyring (1930) RT = 24 ln(10) V / (c S (-ln(1 - alpha)))
15+% the lattice the decay that the image lattice itself implies once
16+% the images are too many to count: an image at distance
17+% ct in direction u has been reflected about
18+% ct (|ux|/Lx + |uy|/Ly + |uz|/Lz) times, so the energy
19+% arriving at time t is the average over directions of
20+% beta^(2 ct g(u)), computed here by quadrature on the
21+% sphere.
22+%
23+% with alpha = 1 - beta^2 the absorption coefficient. The reverberation time
24+% is read off the Schroeder curve, the backward integral of h^2, by a straight
25+% line through its -5 and -25 dB points (T20, scaled to 60 dB), in the band
26+% from 0.7 to 5.7 kHz: the three octaves around 1, 2 and 4 kHz together,
27+% which averages away much of what one octave at one receiver fluctuates by.
28+%
29+% The band matters. Every image has the same sign, so arrivals that land
30+% within a sample or two of each other add coherently at low frequency, and
31+% the broadband response carries a slowly decaying low-frequency build-up
32+% (for rigid walls, a pressure offset that never decays at all: a point
33+% source has pushed air into a closed box). The lattice prediction is the
34+% incoherent, high-frequency limit, which is what an octave band well above
35+% the room's lowest modes measures, and it is also what the two formulas are
36+% for. The broadband curve is returned too, so that the difference can be
37+% seen.
38+%
39+% mode 'room' one room: the response, its decay, and what it does to a sound.
40+% mode 'decay' reverberation time against absorption, by all four routes.
41+p = wv_params();
42+out = struct();
43+L = p.L(:)';
44+V = prod(L);
45+S = 2 * (L(1) * L(2) + L(2) * L(3) + L(1) * L(3));
46+c = 343;
47+fs = p.fs;
48+K = 24 * log(10) / c;
49+
50+if strcmp(p.mode, 'room')
51+ alpha = p.alpha;
52+ beta = sqrt(1 - alpha);
53+ te = K * V / (S * (-log(1 - alpha)));
54+ tmax = min(p.tcap, 0.75 * te + 0.1);
55+ t0 = tic;
56+ h = images(L, p.xs, p.xr, beta, fs, tmax, p.taps);
57+ out.ms = toc(t0) * 1000;
58+ t = (0:numel(h) - 1)' / fs;
59+ [sch, rtb] = drv_schroeder(h, fs);
60+ [schB, rt] = drv_schroeder(drv_band(h, fs, 700, 5600), fs);
61+ out.rt = rt;
62+ out.rtbroad = rtb;
63+ out.sabine = K * V / (S * alpha);
64+ out.eyring = te;
65+ [tp, sp, rtp] = drv_lattice(L, beta, tmax, 300);
66+ out.lattice = rtp;
67+ step = max(1, floor(numel(t) / 1200));
68+ out.ts = t(1:step:end)' * 1000;
69+ out.sch = sch(1:step:end)';
70+ out.schB = schB(1:step:end)';
71+ out.tp = tp * 1000;
72+ out.sp = sp;
73+ out.tmax = tmax;
74+ % the first 60 ms, finely
75+ ne = min(numel(h), round(0.06 * fs));
76+ out.te = t(1:ne)' * 1000;
77+ out.he = (h(1:ne) / max(abs(h)))';
78+ out.direct = norm(p.xs - p.xr) / c * 1000;
79+ % the sound: a phrase of plucked notes, dry and in the room
80+ dry = drv_phrase(p.notes, p.step, fs);
81+ wet = wv_fftconv(dry, h);
82+ out.clipDry = wv_audio(dry, fs);
83+ out.clipWet = wv_audio(wet, fs);
84+ out.clipIr = wv_audio(h, fs);
85+ out.alpha = alpha;
86+else
87+ alphas = p.alphas(:)';
88+ na = numel(alphas);
89+ RT = NaN(1, na);
90+ LAT = NaN(1, na);
91+ MS = NaN(1, na);
92+ for k = 1:na
93+ beta = sqrt(1 - alphas(k));
94+ te = K * V / (S * (-log(1 - alphas(k))));
95+ tmax = min(p.tcap, 0.75 * te + 0.1);
96+ t0 = tic;
97+ h = images(L, p.xs, p.xr, beta, fs, tmax, p.taps);
98+ MS(k) = toc(t0) * 1000;
99+ [~, RT(k)] = drv_schroeder(drv_band(h, fs, 700, 5600), fs);
100+ [~, ~, LAT(k)] = drv_lattice(L, beta, tmax, 300);
101+ end
102+ out.alphas = alphas;
103+ out.rt = RT;
104+ out.lattice = LAT;
105+ out.sabine = K * V ./ (S * alphas);
106+ out.eyring = K * V ./ (S * (-log(1 - alphas)));
107+ out.ms = MS;
108+end
109+out.V = V;
110+out.S = S;
111+wv_write(out);
112+
113+function [db, rt] = drv_schroeder(h, fs)
114+% Schroeder's backward integral of the squared response, in dB, and the
115+% reverberation time from a line through its -5 and -25 dB crossings
116+e = flipud(cumsum(flipud(h .^ 2)));
117+db = 10 * log10(max(e / e(1), 1e-30));
118+t = (0:numel(h) - 1)' / fs;
119+i1 = find(db <= -5, 1);
120+i2 = find(db <= -25, 1);
121+rt = NaN;
122+if ~isempty(i1) && ~isempty(i2) && i2 > i1 + 2
123+ cf = polyfit(t(i1:i2), db(i1:i2), 1);
124+ rt = -60 / cf(1);
125+end
126+end
127+
128+function y = drv_band(h, fs, flo, fhi)
129+% the band from flo to fhi, by FFT, with a raised-cosine edge a sixth of an
130+% octave wide at each end so that the filter itself rings only briefly
131+n = numel(h);
132+nf = 2 ^ nextpow2(2 * n);
133+H = fft(h, nf);
134+f = (0:nf - 1)' * fs / nf;
135+f(f > fs / 2) = fs - f(f > fs / 2);
136+hw = 0.5 * log2(fhi / flo); % half the width, in octaves
137+o = abs(log2(max(f, 1e-9) / sqrt(flo * fhi))); % octaves from the centre
138+B = zeros(nf, 1);
139+B(o <= hw - 1 / 12) = 1;
140+edge = o > hw - 1 / 12 & o < hw + 1 / 12;
141+B(edge) = 0.5 + 0.5 * cos(pi * (o(edge) - (hw - 1 / 12)) / (1 / 6));
142+y = real(ifft(H .* B));
143+y = y(1:n);
144+end
145+
146+function [t, db, rt] = drv_lattice(L, beta, tmax, nt)
147+% The decay the image lattice implies, averaged over directions: the energy
148+% arriving at time t is proportional to mean_u exp(-a g(u) t), with
149+% a = -2 ln(beta) c and g(u) = sum |u_i| / L_i, so its backward integral is
150+% mean_u exp(-a g t) / (a g). Midpoint quadrature in (cos theta, phi) over
151+% one octant, which is enough by symmetry.
152+c = 343;
153+a = -2 * log(beta) * c;
154+nq = 120;
155+mu = ((1:nq) - 0.5) / nq; % cos(theta), uniform in solid angle
156+ph = ((1:nq) - 0.5) / nq * pi / 2;
157+[M, P] = meshgrid(mu, ph);
158+st = real(sqrt(1 - M .^ 2));
159+g = abs(st .* cos(P)) / L(1) + abs(st .* sin(P)) / L(2) + abs(M) / L(3);
160+g = g(:);
161+t = linspace(0, tmax, nt);
162+e = zeros(1, nt);
163+for k = 1:nt
164+ e(k) = mean(exp(-a * g * t(k)) ./ (a * g));
165+end
166+db = 10 * log10(e / e(1));
167+i1 = find(db <= -5, 1);
168+i2 = find(db <= -25, 1);
169+rt = NaN;
170+if ~isempty(i1) && ~isempty(i2)
171+ cf = polyfit(t(i1:i2), db(i1:i2), 1);
172+ rt = -60 / cf(1);
173+end
174+end
175+
176+function y = drv_phrase(notes, step, fs)
177+% plucked notes by modal synthesis, as in Chapter 1, one every `step` seconds
178+ns = numel(notes);
179+ring = 1.2;
180+n = round((step * (ns - 1) + ring) * fs);
181+y = zeros(n, 1);
182+t = (0:round(ring * fs) - 1)' / fs;
183+for i = 1:ns
184+ f0 = wv_note(notes(i));
185+ v = zeros(size(t));
186+ for m = 1:floor(0.45 * fs / f0)
187+ f = m * f0;
188+ s = 6.908 * (1 + (f / 2000) ^ 2) / 1.5;
189+ v = v + sin(m * pi * 0.2) / m * exp(-s * t) .* cos(2 * pi * f * t);
190+ end
191+ i0 = round((i - 1) * step * fs);
192+ y(i0 + (1:numel(t))) = y(i0 + (1:numel(t))) + v;
193+end
194+end
src/chapters/ch08/images.madded+117−0View file
@@ -0,0 +1,117 @@
1+% The image-source method for a rectangular room (Allen and Berkley 1979).
2+%
3+% A wall that reflects sound specularly behaves, for the listener, like a
4+% window onto a mirror-image room with a mirror-image source in it. Mirror
5+% the mirror rooms in their turn and the whole of space fills with a lattice
6+% of image sources, eight to each cell of size 2 Lx by 2 Ly by 2 Lz:
7+%
8+% x = (1 - 2p) xs + 2 l Lx, p in {0, 1}, l any integer,
9+%
10+% and the same in y and z. The path from image to listener crosses the
11+% walls x = 0 and x = Lx |l - p| and |l| times, so the image's pressure is
12+%
13+% beta^(number of reflections) / (4 pi d), arriving d / c after the source,
14+%
15+% with beta the pressure reflection factor of the walls. For rigid walls,
16+% beta = 1, this is not a model: it is the exact solution of the wave
17+% equation in the room, by the method of images. For beta < 1 it is a model,
18+% one in which every wall reflects the same fraction at every angle and every
19+% frequency, which no real wall does.
20+%
21+% Each arrival is a delta at a time that falls between samples. With taps = 0
22+% it is put on the nearest sample, as Allen and Berkley did; otherwise it is
23+% spread over `taps` samples as a Hann-windowed sinc, which is the delta
24+% band-limited to the Nyquist frequency, cut off at the window.
25+%
26+% L = [Lx Ly Lz], xs and xr the source and the receiver; the result is the
27+% impulse response, sampled at fs, up to tmax seconds.
28+
29+function h = images(L, xs, xr, beta, fs, tmax, taps)
30+% the two ways of placing an arrival are two separate loops, because numbl
31+% will not compile a loop in which a variable is assigned on only one branch
32+% of an `if`, and a loop nest with the choice inside it is such a loop
33+if taps == 0
34+ h = img_nearest(L, xs, xr, beta, fs, tmax);
35+else
36+ h = img_sinc(L, xs, xr, beta, fs, tmax, taps);
37+end
38+end
39+
40+function h = img_nearest(L, xs, xr, beta, fs, tmax)
41+c = 343;
42+rmax = c * tmax;
43+h = zeros(ceil(tmax * fs) + 2, 1);
44+nl = ceil(rmax / (2 * L(1))) + 1;
45+nm = ceil(rmax / (2 * L(2))) + 1;
46+nk = ceil(rmax / (2 * L(3))) + 1;
47+% beta^n from a table: numbl compiles an indexed read, but not a power whose
48+% exponent it cannot prove is a whole number
49+bp = beta .^ (0:2 * (nl + nm + nk) + 6);
50+for p = 0:1
51+ for q = 0:1
52+ for r = 0:1
53+ for l = -nl:nl
54+ dx = (1 - 2 * p) * xs(1) + 2 * l * L(1) - xr(1);
55+ ax = abs(l - p) + abs(l);
56+ for m = -nm:nm
57+ dy = (1 - 2 * q) * xs(2) + 2 * m * L(2) - xr(2);
58+ ay = abs(m - q) + abs(m);
59+ for k = -nk:nk
60+ dz = (1 - 2 * r) * xs(3) + 2 * k * L(3) - xr(3);
61+ d = real(sqrt(dx * dx + dy * dy + dz * dz));
62+ if d <= rmax
63+ a = bp(ax + ay + abs(k - r) + abs(k) + 1) / (4 * pi * d);
64+ jn = floor(d / c * fs + 0.5) + 1;
65+ h(jn) = h(jn) + a;
66+ end
67+ end
68+ end
69+ end
70+ end
71+ end
72+end
73+end
74+
75+function h = img_sinc(L, xs, xr, beta, fs, tmax, taps)
76+c = 343;
77+rmax = c * tmax;
78+h = zeros(ceil(tmax * fs) + taps + 2, 1);
79+nl = ceil(rmax / (2 * L(1))) + 1;
80+nm = ceil(rmax / (2 * L(2))) + 1;
81+nk = ceil(rmax / (2 * L(3))) + 1;
82+bp = beta .^ (0:2 * (nl + nm + nk) + 6);
83+half = taps / 2;
84+for p = 0:1
85+ for q = 0:1
86+ for r = 0:1
87+ for l = -nl:nl
88+ dx = (1 - 2 * p) * xs(1) + 2 * l * L(1) - xr(1);
89+ ax = abs(l - p) + abs(l);
90+ for m = -nm:nm
91+ dy = (1 - 2 * q) * xs(2) + 2 * m * L(2) - xr(2);
92+ ay = abs(m - q) + abs(m);
93+ for k = -nk:nk
94+ dz = (1 - 2 * r) * xs(3) + 2 * k * L(3) - xr(3);
95+ d = real(sqrt(dx * dx + dy * dy + dz * dz));
96+ if d > rmax
97+ continue;
98+ end
99+ a = bp(ax + ay + abs(k - r) + abs(k) + 1) / (4 * pi * d);
100+ tau = d / c * fs;
101+ j0 = floor(tau);
102+ for j = max(0, j0 - half + 1):j0 + half
103+ x = j - tau;
104+ if abs(x) < 1e-12
105+ s = 1;
106+ else
107+ s = sin(pi * x) / (pi * x);
108+ end
109+ h(j + 1) = h(j + 1) + a * s * (0.5 + 0.5 * cos(2 * pi * x / taps));
110+ end
111+ end
112+ end
113+ end
114+ end
115+ end
116+end
117+end
src/chapters/ch08/text.mdadded+70−0View file
@@ -0,0 +1,70 @@
1+Rooms are where acoustics is most often computed, and a room is three-dimensional, many wavelengths across at most audible frequencies, and heard for a second or more. A grid for that is expensive, as Chapter 9 will measure. This chapter uses a method with no grid at all, which is exact for one idealised room and a useful model for many real ones, and uses it to test the two formulas for reverberation time that every acoustician learns first.
2+
3+### Images
4+
5+A wall that reflects sound specularly behaves, for a listener, like a window onto a mirror-image room containing a mirror-image source. The sound reflected once from the wall is exactly the sound the image source would send through the window. The images reflect in the other walls in their turn, and for a rectangular room the process fills space with a regular lattice of image sources, eight in each cell of size $2L_x \times 2L_y \times 2L_z$:
6+
7+$$x = (1 - 2p)\,x_s + 2\ell L_x, \qquad p \in \{0, 1\},\ \ell \in \mathbb{Z},$$
8+
9+and similarly in $y$ and $z$. The path from an image to the listener crosses the walls $x = 0$ and $x = L_x$ a total of $|\ell - p| + |\ell|$ times, so if every wall reflects a fraction $\beta$ of the pressure, an impulse from the source reaches the listener as a sum of delayed impulses,
10+
11+$$h(t) = \sum_{\text{images}} \frac{\beta^{\,n}}{4\pi d}\,\delta\!\left(t - \frac{d}{c}\right),$$
12+
13+with $d$ the image's distance and $n$ its number of reflections (Allen and Berkley 1979).
14+
15+<!--fig:images-->
16+
17+For rigid walls, $\beta = 1$, this is not a model. It is the exact solution of the wave equation in the room with Neumann conditions on its walls, by the method of images, and Chapter 9 uses it as such. For $\beta < 1$ it is a model, one in which every wall reflects the same fraction of the pressure at every angle and every frequency. No real wall does that: a wall's reflection depends on both, and a real room also scatters sound diffusely from furniture and people. What the model keeps is the geometry of the specular reflections, exactly.
18+
19+Each arrival falls between two samples. The script can put it on the nearer one, as Allen and Berkley did, or spread it over several samples as a windowed sinc, which is the impulse band-limited to the Nyquist frequency and cut off by the window. We use 8 taps by default. The choice changes the response at high frequencies, where rounding to the nearest sample misplaces each arrival by up to half a sample, but it changes the decay rate by about one per cent.
20+
21+### Sabine, Eyring, and the lattice
22+
23+The reverberation time $T_{60}$ is the time the sound in a room takes to decay by 60 dB after the source stops. Sabine (1900) derived the first formula for it by assuming a **diffuse** field, in which sound energy is spread uniformly through the room and travels equally in all directions. Energy then reaches the walls at a rate proportional to their area $S$, a fraction $\alpha$ of it is absorbed, and the energy decays exponentially:
24+
25+$$T_{60}^{\text{Sabine}} = \frac{24\ln 10}{c}\,\frac{V}{S\alpha} \approx 0.161\,\frac{V}{S\alpha},$$
26+
27+with $V$ the volume and $\alpha = 1 - \beta^2$ the absorption coefficient. Eyring (1930) observed that the loss happens in steps, one per reflection, rather than continuously. A ray in a diffuse field travels on average $4V/S$ between reflections and loses a factor $1 - \alpha$ at each, which gives
28+
29+$$T_{60}^{\text{Eyring}} = \frac{24\ln 10}{c}\,\frac{V}{-S\ln(1-\alpha)}.$$
30+
31+The two agree for small $\alpha$, and Eyring's is always the shorter.
32+
33+The image model allows a third prediction, which we call the **lattice** prediction and which assumes nothing about diffusion. An image at distance $ct$ in direction $\mathbf{u}$ has been reflected about $ct\,g(\mathbf{u})$ times, with $g(\mathbf{u}) = |u_x|/L_x + |u_y|/L_y + |u_z|/L_z$, and the images at distance between $r$ and $r + dr$ number about $4\pi r^2 dr/V$, each contributing energy proportional to $\beta^{2n}/r^2$. So the energy arriving at time $t$ is proportional to
34+
35+$$E(t) \propto \Big\langle (1 - \alpha)^{\,c t\, g(\mathbf{u})} \Big\rangle_{\mathbf{u}},$$
36+
37+the average over directions of a decaying exponential whose rate depends on the direction. The average of $g$ over directions is $S/4V$, so at early times $E(t)$ decays at exactly Eyring's rate. Later it decays more slowly, because it becomes dominated by the directions with the fewest reflections per metre, along the room's longest dimension. The driver evaluates this average by quadrature on the sphere and reads a $T_{60}$ off it in the same way as off the measured response.
38+
39+### Measuring the decay
40+
41+The reverberation time is read off the **Schroeder curve**, the backward integral $\int_t^\infty h^2\,dt'$, in decibels, by a straight line through its $-5$ and $-25$ dB points, scaled to 60 dB; this is the standard $T_{20}$. We measure it in the band from 0.7 to 5.7 kHz, the three octaves around 1, 2 and 4 kHz together, and the reason for the band is itself instructive. Every image has the same sign, so arrivals that land within a sample or two of each other add coherently at low frequencies, and the broadband response carries a slowly decaying low-frequency component. For rigid walls it would never decay at all: a point source that injects air into a closed box leaves its pressure raised. The lattice prediction is the high-frequency limit, in which arrivals add incoherently, and that is also what Sabine's and Eyring's formulas describe.
42+
43+<!--lab:room-->
44+
45+In the default room, $6 \times 4.5 \times 3$ m with $\alpha = 0.2$, the measured $T_{60}$ in the band is 0.59 s. The lattice predicts 0.59 s, Sabine 0.56 s and Eyring 0.50 s. The broadband curve, drawn faintly, decays more slowly, with a $T_{60}$ of 0.78 s, which is the low-frequency build-up and not the room's reverberation in the usual sense.
46+
47+The phrase is a C major arpeggio of plucked notes, synthesised as in Chapter 1 and convolved with the impulse response by FFT. At $\alpha = 0.1$ it is heard in a hard, live room with a reverberation time of about 1.3 s; at $\alpha = 0.6$ it is nearly dry, at about 0.15 s. The impulse response itself sounds something like a hand clap in the same room, which is how reverberation times are often measured in practice.
48+
49+### Reverberation time against absorption
50+
51+<!--lab:decay-->
52+
53+| $\alpha$ | 0.05 | 0.1 | 0.2 | 0.3 | 0.5 | 0.7 |
54+|---|---|---|---|---|---|---|
55+| measured | 2.47 s | 1.28 s | 0.59 s | 0.37 s | 0.21 s | 0.12 s |
56+| lattice | 2.55 s | 1.24 s | 0.59 s | 0.37 s | 0.19 s | 0.11 s |
57+| Sabine | 2.23 s | 1.12 s | 0.56 s | 0.37 s | 0.22 s | 0.16 s |
58+| Eyring | 2.17 s | 1.06 s | 0.50 s | 0.31 s | 0.16 s | 0.09 s |
59+
60+Three things are visible in the table and in the ratio plot.
61+
62+The measured values follow the lattice prediction to within about 3 per cent up to $\alpha = 0.3$. Above that they drift long, by 10 per cent at $\alpha = 0.5$ and 14 per cent at $0.7$. At high absorption the sound has died after a few dozen reflections, and a lattice average over infinitely many images is a poor description of the few that matter; the early reflections of this particular source and receiver dominate.
63+
64+The lattice prediction is a fixed multiple of Eyring's, 1.17 for this room, at every absorption. That is because it depends on $\alpha$ only through $\ln(1 - \alpha)$, exactly as Eyring's formula does; the factor depends only on the room's shape, and measures how far a shoebox with specular walls is from being diffuse. A cube would presumably have a smaller factor and a long corridor a larger one.
65+
66+Sabine's formula is the closest of the two to the measurements over much of the range, which is a coincidence of two errors in opposite directions. Its assumption of continuous absorption overestimates the decay time, by a factor $-\ln(1-\alpha)/\alpha$, and the non-diffuse geometry of a specular shoebox lengthens the true decay time by a factor of 1.17. The two cancel near $\alpha = 0.3$. Below that, Sabine's formula is short of the lattice prediction by up to 13 per cent; above it, long by up to 46 per cent at $\alpha = 0.7$, where it is the worst of the three.
67+
68+### What this chapter establishes
69+
70+The image method computes a rectangular room's response exactly for rigid walls, and as a well-defined model for uniformly absorbing ones, at a cost set by the number of images, which grows as the cube of the length of the response. For decay times, it confirms Eyring's formula as the correct early-time limit for specular reflection, and it shows that the shape of the room matters by a factor that neither formula contains. It cannot model what it leaves out: frequency-dependent absorption, diffuse scattering, and anything that is not a rectangular box. The last of these is what finite-difference methods are for, and Chapter 9 measures what they cost.
src/chapters/ch09/chapter.tsxadded+148−0View file
@@ -0,0 +1,148 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import driver from './driver.m?raw'
3+import script from './room3d.m?raw'
4+import text from './text.md?raw'
5+import Listen, { type Clip } from '../../audio/Listen.tsx'
6+import { Row, SeriesPlot, fmtErr, fmtMs, fmtNum } from '../common/panels.tsx'
7+import { faded, ink, method } from '../../plot/palette.ts'
8+
9+interface ConvergeOut {
10+ Ns: number[]
11+ err: number[]
12+ ms: number[]
13+ cells: number[]
14+ ns: number[]
15+ orders: (number | null)[]
16+ fhi: number
17+ ppw: number[]
18+ fullband: number
19+ rate: number
20+ fullcells: number
21+ lowband: number
22+ t: number[]
23+ y: number[]
24+ ye: number[]
25+ t1: number[]
26+ y1: number[]
27+}
28+
29+interface HearOut {
30+ Ns: number[]
31+ fsk: number[]
32+ clips: Clip[]
33+ clipExact: Clip
34+}
35+
36+const BOX = { L: [3.2, 2.4, 2.0], lam: 0.55, sigma: 0.0005, xs: [0.8, 0.6, 0.8], xr: [2.4, 1.6, 1.2] }
37+
38+function duration(sec: number): string {
39+ if (sec < 120) return `${sec.toFixed(0)} s`
40+ if (sec < 7200) return `${(sec / 60).toFixed(0)} minutes`
41+ if (sec < 2 * 86400) return `${(sec / 3600).toFixed(1)} hours`
42+ if (sec < 2 * 365 * 86400) return `${(sec / 86400).toFixed(0)} days`
43+ return `${(sec / (365.25 * 86400)).toFixed(1)} years`
44+}
45+
46+function ConvergePanels({ d }: { d: ConvergeOut }) {
47+ const last = d.Ns.length - 1
48+ const tip = (i: number) => [
49+ `N = ${d.Ns[i]}: ${d.cells[i].toLocaleString()} nodes`,
50+ `${fmtNum(d.ppw[i], 3)} points per wavelength at ${d.fhi} Hz`,
51+ `error ${fmtErr(d.err[i])}, run ${fmtMs(d.ms[i])}`,
52+ ]
53+ return (
54+ <>
55+ <SeriesPlot
56+ title="pressure at the receiver: the exact image sum, and the grid at its coarsest and its finest"
57+ height={220}
58+ xLabel="ms"
59+ series={[
60+ { x: d.t, y: d.ye, color: ink.reference, width: 2.6, label: 'exact' },
61+ { x: d.t1, y: d.y1, color: faded(method.fd2, 0.35), width: 1.1, label: `N = ${d.Ns[0]}` },
62+ { x: d.t, y: d.y, color: method.fd2, width: 1.3, label: `N = ${d.Ns[last]}` },
63+ ]}
64+ yTicks={[]}
65+ />
66+ <Row>
67+ <SeriesPlot
68+ title="relative rms error at the receiver over the first 30 ms"
69+ height={230}
70+ xLog
71+ yLog
72+ xTicks={d.Ns}
73+ yDomain={[0.03, 3]}
74+ xLabel="nodes along the 3.2 m side"
75+ yLabel="error"
76+ refs={[{ p: -2, through: [36, 0.7], label: 'slope −2', span: 0.4, labelAt: 'end' }]}
77+ series={[{ x: d.Ns, y: d.err, color: method.fd2, shape: 'circle', label: 'leapfrog', tip }]}
78+ legend={false}
79+ />
80+ <SeriesPlot
81+ title="time per node per step"
82+ height={230}
83+ xLog
84+ xTicks={d.Ns}
85+ yDomain={[0, Math.max(...d.ns) * 1.15]}
86+ xLabel="nodes along the 3.2 m side"
87+ yLabel="ns"
88+ series={[{ x: d.Ns, y: d.ns, color: method.fd2, shape: 'circle', tip }]}
89+ legend={false}
90+ />
91+ </Row>
92+ </>
93+ )
94+}
95+
96+export const ch09: Chapter = {
97+ id: 'fdtd',
98+ nav: 'A room, by grid',
99+ part: 'Rooms',
100+ title: 'A room, by finite differences',
101+ blurb:
102+ 'The leapfrog scheme in three dimensions, checked against the exact image sum for rigid walls, and the arithmetic that confines it to low frequencies.',
103+ text,
104+ labs: [
105+ {
106+ id: 'converge',
107+ title: 'A pulse in a rigid 3.2 × 2.4 × 2 m room',
108+ caption:
109+ 'A pulse with most of its energy below about 1.1 kHz, heard 1.8 m away for 30 ms, which is long enough for several dozen reflections. This lab waits for the run button.',
110+ driver,
111+ script,
112+ controls: [],
113+ defaults: {},
114+ derive: () => ({ mode: 'converge', ...BOX, T: 0.03, Ns: [16, 24, 32, 48, 64] }),
115+ manual: true,
116+ editorOpen: true,
117+ render: (d: ConvergeOut) => <ConvergePanels d={d} />,
118+ summary: (d: ConvergeOut) => [
119+ { label: `error at N = ${d.Ns[d.Ns.length - 1]}`, value: fmtErr(d.err[d.err.length - 1]) },
120+ { label: 'observed order', value: fmtNum(d.orders[d.orders.length - 1], 3) },
121+ { label: 'ns per node per step', value: fmtNum(d.rate, 3) },
122+ { label: 'Chapter 8’s room, 1 s, to 20 kHz', value: duration(d.fullband) },
123+ { label: 'the same, to 2 kHz', value: duration(d.lowband) },
124+ ],
125+ },
126+ {
127+ id: 'hear',
128+ title: 'Three tenths of a second in the room',
129+ caption:
130+ 'The same pulse and the same rigid room, for long enough to hear it ring, exactly and on two grids. This lab waits for the run button.',
131+ driver,
132+ script,
133+ controls: [],
134+ defaults: {},
135+ derive: () => ({ mode: 'hear', ...BOX, T: 0.3, fs: 22050, Ns: [16, 32] }),
136+ manual: true,
137+ render: (d: HearOut) => (
138+ <Listen
139+ items={[
140+ { label: 'exact', clip: d.clipExact },
141+ ...d.clips.map((c, k) => ({ label: `N = ${d.Ns[k]}`, clip: c })),
142+ ]}
143+ note={`the same scale; the grids run at ${d.fsk.map((f) => `${(f / 1000).toFixed(1)}`).join(' and ')} kHz`}
144+ />
145+ ),
146+ },
147+ ],
148+}
src/chapters/ch09/driver.madded+171−0View file
@@ -0,0 +1,171 @@
1+% Driver for Chapter 9: a room, by finite differences.
2+%
3+% The editable script supplies
4+%
5+% y = room3d(n, lam, M, is, ir, src)
6+%
7+% leapfrog in a rigid box on a grid of n(1) by n(2) by n(3) nodes, driven at
8+% node is and heard at node ir.
9+%
10+% The source is a point source of strength f(t), the derivative of a
11+% Gaussian, which puts no net volume of air into the box. For rigid walls
12+% the exact pressure is the image sum
13+%
14+% u(t) = sum over images f(t - d/c) / (4 pi c^2 d),
15+%
16+% which the driver evaluates directly; Chapter 8's image method with beta = 1
17+% is the same formula, and here it is not a model but the solution.
18+%
19+% mode 'converge' the error at the receiver against the grid, and the time
20+% per node per step, from which the driver extrapolates what
21+% a full-band simulation would cost.
22+% mode 'hear' a longer run on two grids, against the exact response.
23+p = wv_params();
24+out = struct();
25+c = 343;
26+L = p.L(:)';
27+lam = p.lam;
28+sg = p.sigma;
29+t0p = 5 * sg;
30+
31+if strcmp(p.mode, 'converge')
32+ Ns = p.Ns(:)';
33+ E = NaN(1, numel(Ns));
34+ TM = NaN(1, numel(Ns));
35+ NS = NaN(1, numel(Ns));
36+ cells = NaN(1, numel(Ns));
37+ for k = 1:numel(Ns)
38+ [y, ye, dt, ms, n] = drv_run(Ns(k), L, lam, p.T, p.xs, p.xr, sg, t0p, c);
39+ E(k) = sqrt(sum((y - ye) .^ 2) / sum(ye .^ 2));
40+ TM(k) = ms;
41+ cells(k) = prod(n);
42+ NS(k) = ms * 1e6 / (prod(n) * numel(y)); % nanoseconds per node per step
43+ if k == numel(Ns)
44+ out.t = (1:numel(y)) * dt * 1000;
45+ out.y = y';
46+ out.ye = ye';
47+ end
48+ if k == 1
49+ out.t1 = (1:numel(y)) * dt * 1000;
50+ out.y1 = y';
51+ end
52+ end
53+ out.Ns = Ns;
54+ out.err = E;
55+ out.ms = TM;
56+ out.cells = cells;
57+ out.ns = NS;
58+ out.orders = wv_orders(E, Ns);
59+ % points per wavelength at the pulse's upper frequency, taken as the
60+ % point where its spectrum is 40 dB below its peak
61+ f = linspace(1, 20000, 20000);
62+ S = f .* exp(-0.5 * (2 * pi * f * sg) .^ 2);
63+ S = S / max(S);
64+ fhi = f(find(S > 0.01, 1, 'last'));
65+ out.fhi = fhi;
66+ out.ppw = c / fhi ./ (L(1) ./ Ns);
67+ % what a full-band run of Chapter 8's room would cost at the measured
68+ % rate: 10 points per shortest wavelength at 20 kHz, one second of sound.
69+ % The rate is the best of the grids from N = 32 up, where the fixed costs
70+ % no longer matter and a single slow run (a garbage collection, another
71+ % tab) does not decide the answer.
72+ rate = min(NS(3:end));
73+ out.rate = rate;
74+ V8 = 81;
75+ h8 = c / 20000 / 10;
76+ dt8 = lam * h8 / c;
77+ upd = V8 / h8 ^ 3 / dt8;
78+ out.fullband = upd * rate * 1e-9; % seconds
79+ out.fullcells = V8 / h8 ^ 3;
80+ h2 = c / 2000 / 10; % the same up to 2 kHz only
81+ out.lowband = V8 / h2 ^ 3 / (lam * h2 / c) * rate * 1e-9;
82+else
83+ Ns = p.Ns(:)';
84+ fs = p.fs;
85+ te = (1:round(p.T * fs))' / fs;
86+ ye = drv_exact(te, L, p.xs, p.xr, sg, t0p, c);
87+ sc = max(abs(ye));
88+ out.clipExact = wv_audio(ye, fs, sc);
89+ clips = cell(1, numel(Ns));
90+ for k = 1:numel(Ns)
91+ [y, ~, dt, ~, ~] = drv_run(Ns(k), L, lam, p.T, p.xs, p.xr, sg, t0p, c, true);
92+ clips{k} = wv_audio(y, 1 / dt, sc);
93+ fsk(k) = 1 / dt;
94+ end
95+ out.clips = clips;
96+ out.Ns = Ns;
97+ out.fsk = fsk;
98+ nw = round(0.25 * fs);
99+ [fr, db] = wv_spectrum(ye(1:nw), fs, 3000, 600);
100+ out.fr = fr;
101+ out.dbe = db;
102+end
103+wv_write(out);
104+
105+function [y, ye, dt, ms, n] = drv_run(N, L, lam, T, xs, xr, sg, t0p, c, noexact)
106+h = L(1) / N;
107+n = round(L / h) + 1;
108+dt = lam * h / c;
109+M = round(T / dt);
110+t = (1:M)' * dt;
111+% step k computes u at t(k) from the two levels before it, so the source term
112+% it adds is the one at the middle level, f(t(k) - dt)
113+ts = t - dt;
114+f = drv_pulse(ts, sg, t0p);
115+src = dt ^ 2 * f / h ^ 3;
116+is = round(xs / h) + 1;
117+ir = round(xr / h) + 1;
118+room3d(n, lam, 3, is, ir, src(1:3)); % warm-up
119+tic0 = tic;
120+y = room3d(n, lam, M, is, ir, src);
121+ms = toc(tic0) * 1000;
122+if nargin > 9 && noexact
123+ ye = [];
124+else
125+ ye = drv_exact(t, L, xs, xr, sg, t0p, c);
126+end
127+end
128+
129+function f = drv_pulse(t, sg, t0p)
130+% the derivative of a Gaussian: no DC, so no permanent pressure in the box
131+s = (t - t0p) / sg;
132+f = -s .* exp(-0.5 * s .^ 2);
133+end
134+
135+function y = drv_exact(t, L, xs, xr, sg, t0p, c)
136+% the image sum for rigid walls, every image close enough to be heard by
137+% the end of t
138+y = zeros(size(t));
139+tend = t(end);
140+rmax = c * (tend + 0.001);
141+nl = ceil(rmax ./ (2 * L)) + 1;
142+dtt = t(2) - t(1);
143+w = 7 * sg; % the pulse is below 1e-10 beyond this
144+for p = 0:1
145+ for q = 0:1
146+ for r = 0:1
147+ for l = -nl(1):nl(1)
148+ dx = (1 - 2 * p) * xs(1) + 2 * l * L(1) - xr(1);
149+ for m = -nl(2):nl(2)
150+ dy = (1 - 2 * q) * xs(2) + 2 * m * L(2) - xr(2);
151+ for k = -nl(3):nl(3)
152+ dz = (1 - 2 * r) * xs(3) + 2 * k * L(3) - xr(3);
153+ d = real(sqrt(dx * dx + dy * dy + dz * dz));
154+ if d > rmax
155+ continue;
156+ end
157+ tc = d / c + t0p;
158+ i0 = max(1, floor((tc - w - t(1)) / dtt) + 1);
159+ i1 = min(numel(t), ceil((tc + w - t(1)) / dtt) + 1);
160+ a = 1 / (4 * pi * c * c * d);
161+ for i = i0:i1
162+ s = (t(i) - tc) / sg;
163+ y(i) = y(i) - a * s * exp(-0.5 * s * s);
164+ end
165+ end
166+ end
167+ end
168+ end
169+ end
170+end
171+end
src/chapters/ch09/room3d.madded+72−0View file
@@ -0,0 +1,72 @@
1+% Leapfrog in a rigid rectangular room: the seven-point Laplacian in three
2+% dimensions, stable for lam <= 1/sqrt(3).
3+%
4+% The grid has n(1) by n(2) by n(3) nodes, with the walls on the outermost
5+% nodes. A rigid wall means du/dn = 0, which is imposed by mirroring the
6+% first node inside each wall into a ghost node just outside it. That
7+% mirror is the discrete version of the method of images: the grid's
8+% solution in the room is exactly the grid's free-space solution for the
9+% source and all its mirror images, so the only error left is the scheme's
10+% own dispersion.
11+%
12+% The room starts silent. At every step the source node receives src(n),
13+% already scaled by the driver to dt^2 f(t_n) / h^3, which is a point source
14+% of strength f(t) spread over one cell. The result is the pressure at the
15+% receiver node after each step.
16+%
17+% The field is stored as one long vector and the neighbours of node q are
18+% q +- 1, q +- sy and q +- sz. numbl runs this form about seven times faster
19+% than the same loop written with three subscripts.
20+
21+function y = room3d(n, lam, M, is, ir, src)
22+nx = n(1) + 2;
23+ny = n(2) + 2;
24+nz = n(3) + 2;
25+sy = nx;
26+sz = nx * ny;
27+u = zeros(nx * ny * nz, 1);
28+up = u;
29+un = u;
30+l2 = lam ^ 2;
31+qs = is(1) + 1 + is(2) * sy + is(3) * sz; % padded, so every index is one further in
32+qr = ir(1) + 1 + ir(2) * sy + ir(3) * sz;
33+y = zeros(M, 1);
34+for step = 1:M
35+ % ghosts: the mirror image of the first interior node, on all six faces
36+ for c = 1:nz
37+ for j = 1:ny
38+ b = (c - 1) * sz + (j - 1) * sy;
39+ u(b + 1) = u(b + 3);
40+ u(b + nx) = u(b + nx - 2);
41+ end
42+ end
43+ for c = 1:nz
44+ for i = 1:nx
45+ b = (c - 1) * sz + i;
46+ u(b) = u(b + 2 * sy);
47+ u(b + (ny - 1) * sy) = u(b + (ny - 3) * sy);
48+ end
49+ end
50+ for j = 1:ny
51+ for i = 1:nx
52+ b = (j - 1) * sy + i;
53+ u(b) = u(b + 2 * sz);
54+ u(b + (nz - 1) * sz) = u(b + (nz - 3) * sz);
55+ end
56+ end
57+ for c = 2:nz - 1
58+ for j = 2:ny - 1
59+ b = (c - 1) * sz + (j - 1) * sy;
60+ for i = 2:nx - 1
61+ q = b + i;
62+ un(q) = 2 * u(q) - up(q) + l2 * (u(q + 1) + u(q - 1) + u(q + sy) + u(q - sy) ...
63+ + u(q + sz) + u(q - sz) - 6 * u(q));
64+ end
65+ end
66+ end
67+ un(qs) = un(qs) + src(step);
68+ up = u;
69+ u = un;
70+ y(step) = u(qr);
71+end
72+end
src/chapters/ch09/text.mdadded+45−0View file
@@ -0,0 +1,45 @@
1+The image method of Chapter 8 needs a rectangular room with walls that reflect specularly, and it cannot represent anything that scatters, diffracts or absorbs differently at different frequencies. A grid can represent all of those, and in rooms of arbitrary shape, which is why finite-difference time-domain (FDTD) methods are used for room acoustics at all. This chapter measures the leapfrog scheme in three dimensions against the one room for which the exact answer is available, and then does the arithmetic that decides where such a method can be used.
2+
3+### The scheme and the reference
4+
5+The scheme is Chapter 4's with the seven-point Laplacian, stable for $\lambda \le 1/\sqrt{3}$; we run it at $\lambda = 0.55$. The walls are rigid, imposed by mirroring the node inside each wall into a ghost node outside it. That mirror is the discrete method of images: the grid's solution in the room is exactly its free-space solution for the source and all its mirror images, so the only error is the scheme's own dispersion and the representation of the source.
6+
7+The source is a point source of strength $f(t)$, the derivative of a Gaussian with a width of 0.5 ms, injected at one node as $\Delta t^2 f(t_n)/h^3$ per step. A derivative of a Gaussian integrates to zero, so it puts no net volume of air into the box, and there is no permanent pressure offset of the kind Chapter 8 warned of. For rigid walls the exact pressure at the receiver is then the image sum
8+
9+$$u(t) = \sum_{\text{images}} \frac{f(t - d/c)}{4\pi c^2 d},$$
10+
11+which the driver evaluates directly. With $\beta = 1$ this is the same formula as Chapter 8's, and here it is not a model but the solution.
12+
13+The room is $3.2 \times 2.4 \times 2$ m, the source and receiver are 1.8 m apart, and the recording lasts 30 ms, long enough for several dozen reflections. Most of the pulse's energy is below about 1.1 kHz, where its spectrum is 40 dB below its peak, and we count resolution in points per wavelength at that frequency.
14+
15+<!--lab:converge-->
16+
17+| nodes along 3.2 m | 16 | 24 | 32 | 48 | 64 |
18+|---|---|---|---|---|---|
19+| points per wavelength at 1.1 kHz | 1.5 | 2.3 | 3.0 | 4.5 | 6.0 |
20+| relative rms error | 1.29 | 0.92 | 0.48 | 0.21 | 0.11 |
21+| observed order | | 0.84 | 2.26 | 2.06 | 2.15 |
22+
23+The scheme converges at second order once it resolves the pulse at all, from about three points per wavelength. At six points per wavelength the error is 11 per cent of the signal, and by the observed order it would take about 20 points per wavelength to bring it to 1 per cent. The waveform panel shows where the error is: the direct sound and the first few reflections are nearly right on the finest grid, and the error grows with time, as each reflection arrives slightly later and slightly more smeared than it should.
24+
25+### The arithmetic
26+
27+The time per node per step settles at about 38 ns in the command-line version of numbl and about twice that in a browser, once the grid is large enough that fixed costs no longer matter. From that rate the driver works out what the same method would cost for the room of Chapter 8, $81$ m$^3$, for one second of sound, at 10 points per wavelength, which by the three-dimensional version of Chapter 4's formula is a pitch error of about 20 cents for waves along the grid's axes and is not generous.
28+
29+Up to 20 kHz the shortest wavelength is 17 mm, the spacing is 1.7 mm, and the room holds about $1.6\times10^{10}$ nodes. One second is about 370,000 steps. At the command-line rate this is about seven years of computation, and in a browser about twice that; the lab prints its own figure, from the rate it measured. Up to 2 kHz, the spacing is ten times larger, the number of nodes a thousand times smaller and the number of steps ten times smaller, and the same second of sound takes about six hours at the command-line rate.
30+
31+The scaling behind those two numbers is the whole story. The work of a three-dimensional explicit scheme grows as the fourth power of the highest frequency it must resolve: the cube for the nodes and one more power for the time step. Every octave costs a factor of sixteen. A factor of $10^4$ between 2 kHz and 20 kHz is what separates an afternoon from a decade.
32+
33+Practice follows the arithmetic. Room acoustics simulations commonly split the band: a wave-based method such as FDTD below a crossover frequency, often a few hundred hertz to a couple of kilohertz, where the room's individual modes and diffraction matter, and a geometric method such as image sources or ray tracing above it, where the sound field is dense enough to be treated statistically and the wavelengths are small compared with the room. A useful landmark for the crossover is the **Schroeder frequency**, $2000\sqrt{T_{60}/V}$ Hz, above which the room's modes overlap so densely that no single one can be heard; for Chapter 8's room at $\alpha = 0.2$ it is about 170 Hz. Large FDTD simulations of full rooms are run, on graphics processors and at considerable cost, and the methods of Chapter 5 help: a fourth-order scheme needs fewer points per wavelength, and since the work goes as the cube of the resolution and one more power, every factor saved in points per wavelength is saved four times over.
34+
35+### Listening
36+
37+<!--lab:hear-->
38+
39+The same pulse, in the same rigid room, for 0.3 s, exactly and on the grids of 16 and 32 nodes, which run at 3.1 and 6.2 kHz. The exact response rings, because the walls absorb nothing, and its timbre is set by the room's lowest modes. The coarse grid reproduces the character of that ring, a low, boxy resonance, but not its detail: its higher frequencies are missing, and those that remain have drifted in time, so the texture of the reverberation is audibly different. The finer grid is closer. Neither is close enough to pass for the exact response over the whole band, and the arithmetic above says what closing the gap would cost.
40+
41+### What this chapter establishes
42+
43+The leapfrog scheme in three dimensions converges at second order to the exact response of a rigid room, and with rigid walls imposed by mirrors its only error is its own dispersion. It is also the most expensive method in the tutorial by a wide margin, with a cost that grows as the fourth power of the frequency, and at the full audio bandwidth it is out of reach of a browser, and of most computers, for any room one might want to hear. That is not a failure of the method so much as a statement about where it belongs: at low frequencies, in rooms of any shape, alongside geometric methods that take over where the wavelengths become small.
44+
45+This is where the tutorial ends. Across nine chapters the same pattern has held: an exact answer, a method measured against it, and an error that is best judged by ear. The magic time step made one-dimensional strings exact and cheap; two dimensions took the magic away and made the order of accuracy and the treatment of the boundary decide the cost; three dimensions made the cost itself the limiting factor, and the choice of method a choice of which frequencies to compute at all.
src/chapters/common/panels.tsxadded+319−0View file
@@ -0,0 +1,319 @@
1+// Panels shared by every chapter.
2+//
3+// Almost every figure in the tutorial is one of three things: a signal against
4+// time, a spectrum against frequency, or an error against a resolution on
5+// log-log axes. SeriesPlot draws all three from a declarative list of series,
6+// so that a chapter's panels file says what is plotted rather than how.
7+//
8+// The conventions are the sibling tutorials': the exact answer is grey and is
9+// drawn first, every method is coloured and drawn over it, identity is never
10+// colour alone (markers, dashes, a legend and, on convergence plots, direct
11+// end-labels), and hovering a marker reads out its numbers.
12+import { useState, type ReactNode } from 'react'
13+import Plot, { type PointerPx } from '../../plot/Plot.tsx'
14+import Legend, { type LegendItem } from '../../plot/Legend.tsx'
15+import { EndLabel, Line, Markers, PointTip, RefSlope, Stems, type Shape } from '../../plot/Curves.tsx'
16+import { ink } from '../../plot/palette.ts'
17+import { extent, padDomain, type Frame } from '../../plot/scales.ts'
18+import type { Num } from '../../engine/types.ts'
19+
20+export function fmtNum(v: Num | undefined, d = 3): string {
21+ if (v == null || !isFinite(v)) return '--'
22+ return Number(v.toPrecision(d)).toString()
23+}
24+
25+export function fmtErr(v: Num | undefined): string {
26+ if (v == null || !isFinite(v)) return '--'
27+ if (v === 0) return '0'
28+ return v.toExponential(2)
29+}
30+
31+export function fmtCents(v: Num | undefined): string {
32+ if (v == null || !isFinite(v)) return '--'
33+ const a = Math.abs(v)
34+ const s = a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : a >= 0.01 ? v.toFixed(2) : v.toExponential(1)
35+ return `${s} ¢`
36+}
37+
38+export function fmtDb(v: Num | undefined): string {
39+ if (v == null || !isFinite(v)) return '--'
40+ return `${v.toFixed(1)} dB`
41+}
42+
43+export function fmtMs(v: Num | undefined): string {
44+ if (v == null || !isFinite(v)) return '--'
45+ if (v >= 1000) return `${(v / 1000).toPrecision(3)} s`
46+ if (v >= 1) return `${v.toPrecision(3)} ms`
47+ return `${(v * 1000).toFixed(0)} µs`
48+}
49+
50+export function fmtHz(v: Num | undefined): string {
51+ if (v == null || !isFinite(v)) return '--'
52+ return v >= 1000 ? `${(v / 1000).toPrecision(4)} kHz` : `${v.toPrecision(4)} Hz`
53+}
54+
55+export interface Series {
56+ x: readonly Num[]
57+ y: readonly Num[]
58+ color: string
59+ label?: string
60+ dash?: string
61+ width?: number
62+ /** markers at the data points */
63+ shape?: Shape
64+ /** draw the polyline (default true unless `stems`) */
65+ line?: boolean
66+ /** vertical stems from `base` instead of a polyline */
67+ stems?: boolean
68+ base?: number
69+ opacity?: number
70+ /** omit from the legend */
71+ noLegend?: boolean
72+ /** label the last finite point directly on the plot */
73+ endLabel?: string
74+ /** extra lines for the hover read-out of point i */
75+ tip?: (i: number) => string[]
76+}
77+
78+export interface RefLine {
79+ p: number
80+ through: [number, number]
81+ label: string
82+ labelAt?: 'start' | 'end'
83+ span?: number
84+}
85+
86+export interface SeriesPlotProps {
87+ title?: ReactNode
88+ series: Series[]
89+ height?: number
90+ xLog?: boolean
91+ yLog?: boolean
92+ xDomain?: [number, number]
93+ yDomain?: [number, number]
94+ xLabel?: string
95+ yLabel?: string
96+ xTicks?: number[]
97+ yTicks?: number[]
98+ formatX?: (v: number) => string
99+ formatY?: (v: number) => string
100+ refs?: RefLine[]
101+ /** thin vertical guides, e.g. the harmonics of a note or the Nyquist frequency */
102+ vlines?: { x: readonly number[]; color?: string; dash?: string; opacity?: number }[]
103+ /** thin horizontal guides, e.g. a threshold of audibility */
104+ hlines?: { y: number; color?: string; dash?: string; label?: string }[]
105+ legend?: boolean
106+ extraLegend?: LegendItem[]
107+ /** anything else, drawn in the frame over the data */
108+ overlay?: (f: Frame) => ReactNode
109+ margin?: Partial<{ l: number; r: number; t: number; b: number }>
110+}
111+
112+function finite(v: Num | undefined): v is number {
113+ return v != null && isFinite(v)
114+}
115+
116+/** A plot of several series with legend, guides and a hover read-out. */
117+export function SeriesPlot(props: SeriesPlotProps) {
118+ const {
119+ title,
120+ series,
121+ height = 230,
122+ xLog = false,
123+ yLog = false,
124+ xLabel,
125+ yLabel,
126+ refs,
127+ vlines,
128+ hlines,
129+ legend = true,
130+ } = props
131+ const [ptr, setPtr] = useState<PointerPx | null>(null)
132+
133+ const pos = (v: Num) => (finite(v) && (!yLog || v > 0) ? v : null)
134+ const xs = series.flatMap((s) => s.x.filter((v) => finite(v) && (!xLog || v > 0)) as number[])
135+ const ys = series.flatMap((s) => s.y.map(pos).filter(finite))
136+ let xd: [number, number] = props.xDomain ?? extent(xs)
137+ let yd: [number, number] = props.yDomain ?? extent(ys)
138+ if (!props.xDomain) {
139+ xd = xLog ? [xd[0] / 1.25, xd[1] * 1.25] : padDomain(xd, 0.02)
140+ }
141+ if (!props.yDomain) {
142+ yd = yLog ? [yd[0] / 2, yd[1] * 2] : padDomain(yd)
143+ }
144+
145+ const hasTips = series.some((s) => s.shape || s.tip)
146+ const over = (f: Frame) => {
147+ const out: ReactNode[] = []
148+ series.forEach((s, k) => {
149+ if (!s.endLabel) return
150+ for (let i = s.y.length - 1; i >= 0; i--) {
151+ const xv = s.x[i]
152+ const yv = s.y[i]
153+ if (finite(xv) && finite(yv) && (!yLog || yv > 0)) {
154+ out.push(<EndLabel key={`e${k}`} f={f} x={xv} y={yv} text={s.endLabel} color={s.color} />)
155+ break
156+ }
157+ }
158+ })
159+ if (ptr && hasTips) {
160+ let best: { k: number; i: number; d: number; px: number; py: number } | null = null
161+ series.forEach((s, k) => {
162+ if (!s.shape && !s.tip) return
163+ for (let i = 0; i < s.x.length; i++) {
164+ const xv = s.x[i]
165+ const yv = s.y[i]
166+ if (!finite(xv) || !finite(yv) || (yLog && yv <= 0) || (xLog && xv <= 0)) continue
167+ const px = f.sx(xv)
168+ const py = f.sy(yv)
169+ const d = Math.hypot(px - ptr.px, py - ptr.py)
170+ if (d < 24 && (!best || d < best.d)) best = { k, i, d, px, py }
171+ }
172+ })
173+ if (best) {
174+ const b = best as { k: number; i: number; px: number; py: number }
175+ const s = series[b.k]
176+ const fx = props.formatX ?? ((v: number) => fmtNum(v, 4))
177+ const fy = props.formatY ?? ((v: number) => fmtNum(v, 4))
178+ const lines = [
179+ s.label ?? '',
180+ ...(s.tip ? s.tip(b.i) : [`${xLabel ?? 'x'} ${fx(s.x[b.i] as number)}`, `${yLabel ?? 'y'} ${fy(s.y[b.i] as number)}`]),
181+ ]
182+ out.push(<PointTip key="tip" f={f} px={b.px} py={b.py} color={s.color} lines={lines} />)
183+ }
184+ }
185+ if (props.overlay) out.push(<g key="ov">{props.overlay(f)}</g>)
186+ return <>{out}</>
187+ }
188+
189+ const items: LegendItem[] = series
190+ .filter((s) => s.label && !s.noLegend)
191+ .map((s) => ({
192+ label: s.label!,
193+ color: s.color,
194+ dash: s.dash,
195+ shape: s.stems ? ('line' as const) : s.line === false && s.shape ? ('dot' as const) : undefined,
196+ }))
197+
198+ return (
199+ <div>
200+ {title && <div className="plot-title">{title}</div>}
201+ <Plot
202+ height={height}
203+ xDomain={xd}
204+ yDomain={yd}
205+ xLog={xLog}
206+ yLog={yLog}
207+ xLabel={xLabel}
208+ yLabel={yLabel}
209+ xTicks={props.xTicks}
210+ yTicks={props.yTicks}
211+ formatX={props.formatX}
212+ formatY={props.formatY}
213+ margin={props.margin ?? (series.some((s) => s.endLabel) ? { r: 78 } : undefined)}
214+ onPointer={hasTips ? setPtr : undefined}
215+ over={over}
216+ >
217+ {(f) => (
218+ <>
219+ {vlines?.map((g, k) =>
220+ g.x.map((xv, i) =>
221+ finite(xv) ? (
222+ <line
223+ key={`v${k}-${i}`}
224+ x1={f.sx(xv)}
225+ x2={f.sx(xv)}
226+ y1={0}
227+ y2={f.ih}
228+ stroke={g.color ?? ink.axis}
229+ strokeDasharray={g.dash}
230+ opacity={g.opacity ?? 1}
231+ strokeWidth={1}
232+ />
233+ ) : null,
234+ ),
235+ )}
236+ {hlines?.map((g, k) => (
237+ <g key={`h${k}`}>
238+ <line
239+ x1={0}
240+ x2={f.iw}
241+ y1={f.sy(g.y)}
242+ y2={f.sy(g.y)}
243+ stroke={g.color ?? ink.muted}
244+ strokeDasharray={g.dash ?? '4 4'}
245+ strokeWidth={1}
246+ />
247+ {g.label && (
248+ <text x={f.iw - 4} y={f.sy(g.y) - 5} fill={g.color ?? ink.muted} fontSize={11} textAnchor="end">
249+ {g.label}
250+ </text>
251+ )}
252+ </g>
253+ ))}
254+ {refs?.map((r, k) => (
255+ <RefSlope key={`r${k}`} f={f} p={r.p} through={r.through} label={r.label} labelAt={r.labelAt} span={r.span} />
256+ ))}
257+ {series.map((s, k) => {
258+ const x = s.x.map((v) => (finite(v) ? v : NaN))
259+ const y = s.y.map((v) => (finite(v) && (!yLog || v > 0) ? v : null))
260+ return (
261+ <g key={k} opacity={s.opacity ?? 1}>
262+ {s.stems ? (
263+ <Stems x={s.x} y={y} f={f} color={s.color} base={s.base ?? yd[0]} width={s.width ?? 1.5} />
264+ ) : (
265+ s.line !== false && <Line x={x} y={y} f={f} color={s.color} dash={s.dash} width={s.width ?? 2} />
266+ )}
267+ {s.shape && <Markers x={x} y={y} f={f} color={s.color} shape={s.shape} />}
268+ </g>
269+ )
270+ })}
271+ </>
272+ )}
273+ </Plot>
274+ {legend && items.length + (props.extraLegend?.length ?? 0) > 0 && (
275+ <Legend items={[...items, ...(props.extraLegend ?? [])]} />
276+ )}
277+ </div>
278+ )
279+}
280+
281+/** A small table of numbers, the way the sibling tutorials print a sweep. */
282+export function NumTable({
283+ head,
284+ rows,
285+ caption,
286+}: {
287+ head: string[]
288+ rows: (string | number)[][]
289+ caption?: string
290+}) {
291+ return (
292+ <div>
293+ {caption && <div className="plot-title">{caption}</div>}
294+ <table className="numtable">
295+ <thead>
296+ <tr>
297+ {head.map((h) => (
298+ <th key={h}>{h}</th>
299+ ))}
300+ </tr>
301+ </thead>
302+ <tbody>
303+ {rows.map((r, i) => (
304+ <tr key={i}>
305+ {r.map((c, j) => (
306+ <td key={j}>{c}</td>
307+ ))}
308+ </tr>
309+ ))}
310+ </tbody>
311+ </table>
312+ </div>
313+ )
314+}
315+
316+/** Two panels side by side, wrapping to one column on a narrow screen. */
317+export function Row({ children }: { children: ReactNode }) {
318+ return <div className="figure-row">{children}</div>
319+}
src/chapters/index.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import type { Chapter } from '../ui/types.ts'
2+import { intro } from './intro/chapter.ts'
3+import { ch01 } from './ch01/chapter.tsx'
4+import { ch02 } from './ch02/chapter.tsx'
5+import { ch03 } from './ch03/chapter.tsx'
6+import { ch04 } from './ch04/chapter.tsx'
7+import { ch05 } from './ch05/chapter.tsx'
8+import { ch06 } from './ch06/chapter.tsx'
9+import { ch07 } from './ch07/chapter.tsx'
10+import { ch08 } from './ch08/chapter.tsx'
11+import { ch09 } from './ch09/chapter.tsx'
12+
13+export const CHAPTERS: Chapter[] = [intro, ch01, ch02, ch03, ch04, ch05, ch06, ch07, ch08, ch09]
src/chapters/intro/chapter.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import type { Chapter } from '../../ui/types.ts'
2+import text from './text.md?raw'
3+
4+export const intro: Chapter = {
5+ id: 'introduction',
6+ nav: 'Introduction',
7+ part: 'Introduction',
8+ title: 'The wave equation, and what its errors sound like',
9+ blurb:
10+ 'Where the acoustic wave equation comes from, why every problem in this tutorial has an exact answer, and why a numerical error in acoustics is best measured in cents and decibels.',
11+ text,
12+ labs: [],
13+}
src/chapters/intro/text.mdadded+69−0View file
@@ -0,0 +1,69 @@
1+This is a tutorial about the wave equation as it is used in acoustics: for strings, for drums, and for the air in a room. Every method in it is written as a MATLAB-syntax script that you can edit and run in this page, and almost every result can be played as sound. There is no server, nothing to install, and nothing is sent anywhere.
2+
3+The organising idea is the same as in the two sibling tutorials on the Dirichlet problem and on particle filters. Every problem here has an answer that is known exactly, so the error of every computation is known exactly too, and what we measure is not whether a method works but what accuracy it delivers for what cost. The difference is in the units. A numerical error in acoustics is heard, and the ear is a peculiar instrument: it is very sensitive to some errors and almost blind to others. We therefore measure errors the way a listener would notice them, as a pitch in cents or a level in decibels, and we let the reader listen to the error as well as plot it.
4+
5+### The equation
6+
7+Sound in air is a small disturbance of pressure $p$ and velocity $\mathbf{v}$ about a state of rest. Linearising the conservation of mass and of momentum about that state gives two first-order equations,
8+
9+$$\frac{\partial p}{\partial t} = -K\, \nabla\cdot\mathbf{v}, \qquad \rho\, \frac{\partial \mathbf{v}}{\partial t} = -\nabla p,$$
10+
11+where $\rho$ is the density of the air and $K$ its bulk modulus. Eliminating $\mathbf{v}$ gives the wave equation
12+
13+$$\frac{\partial^2 p}{\partial t^2} = c^2\, \nabla^2 p, \qquad c = \sqrt{K/\rho},$$
14+
15+with $c \approx 343$ m/s in air at room temperature. The same equation, with a different $c$, governs the small transverse motion of a stretched string ($c^2$ is the tension divided by the mass per unit length) and of a stretched membrane ($c^2$ is the tension per unit length divided by the mass per unit area). We use one dimension for the string, two for the drum, and three for the room, and the pressure-velocity form above reappears in Chapter 7, where it is the natural setting for an absorbing layer.
16+
17+<!--fig:settings-->
18+
19+What distinguishes the three settings is mostly the boundary. A string is fixed at both ends, so its displacement vanishes there; this is a **Dirichlet** condition. A drumhead is clamped at its rim, which is Dirichlet again. A hard wall stops the air from moving through it, so the normal velocity vanishes, and by the momentum equation so does the normal derivative of the pressure; this is a **Neumann** condition, and a wall that absorbs some of the sound is modelled by a **Robin** (impedance) condition that relates the two. A computation that is supposed to model open space needs a boundary that pretends not to be there, which is harder than any of these, and Chapter 7 is about it.
20+
21+### Where the exact answers come from
22+
23+Four sources, and each chapter says which it is using.
24+
25+**Modes.** On a string, a rectangle or a disk, the wave equation separates, and its solutions are sums of standing waves whose shapes and frequencies are known in closed form: sines for the string and the rectangle, Bessel functions for the disk. The frequencies of a disk are zeros of Bessel functions, which the shared library computes to rounding error.
26+
27+**d'Alembert's solution.** On a string, the solution is also a sum of two travelling waves, $u = \tfrac12\big(\phi(x - ct) + \phi(x + ct)\big)$, which shares no algebra with the modal sum. Chapter 1 computes the same plucked string both ways.
28+
29+**Images.** A rigid wall acts as a mirror, so the sound in a rectangular room with rigid walls is exactly the sound in free space of the source and an infinite lattice of mirror images of it. Chapters 8 and 9 use this.
30+
31+**Causality.** An explicit scheme moves information by at most one grid node per step. The same scheme on a grid that is larger by $M$ nodes on every side therefore cannot tell, during $M$ steps, that it has edges at all. Chapter 7 uses such a grid as an exact reference for the scheme's own free-space solution, which is not a formula but is correct to rounding error.
32+
33+### What is measured
34+
35+Most of what a numerical method gets wrong in a wave computation is the speed at which waves of different lengths travel, which is called **numerical dispersion**. For a standing wave, a wrong speed is a wrong frequency, and a wrong frequency is heard as a wrong pitch. We measure it in **cents**: a cent is a hundredth of an equal-tempered semitone, so an octave is 1200 cents and a frequency ratio $r$ is $1200\log_2 r$ cents. A relative frequency error $\varepsilon$ is about $1731\,\varepsilon$ cents when it is small. Every frequency in the tutorial is read off a computed signal by one routine, `wv_peakfreq`, which finds the peak of a tapered spectrum and polishes it with Newton's method; on a clean partial it is good to about ten digits, so the error it reports is the method's and not its own.
36+
37+How large a pitch error matters is a question about listeners rather than about numerical analysis, and it has no single answer. Frequency discrimination thresholds for pure tones at moderate levels in the middle of the audible range are commonly reported at around 0.2 to 0.5 per cent, which is 3 to 9 cents, and they are larger at low and high frequencies and for untrained listeners. We draw a line at 5 cents on the pitch plots as a rough guide, and nothing in the tutorial depends on its exact position.
38+
39+Two other measures appear. Levels and energies are in **decibels**, $10\log_{10}$ of an energy ratio. And a waveform error, the root-mean-square difference between a computed signal and the exact one relative to the size of the exact one, is reported where it is informative. It is often not, and the reason is worth stating at the start: a note held for one second at 440 Hz lasts 440 periods, so a pitch error of one cent, which few listeners can hear, puts the computed wave a quarter of a period out of phase by the end of it, and the waveform error is then of order one. Much of what looks like failure on a waveform plot is inaudible. Chapter 1 has an example of the converse: an error of a few per cent in the waveform that can be heard as tones which do not belong to the note.
40+
41+Cost is wall-clock time, measured inside the MATLAB layer around the method and nothing else. Computing the exact answer, measuring frequencies, and encoding the sound are not charged to any method.
42+
43+### The chapters
44+
45+**Strings**
46+
47+1. **Sound from an exact solution**: the plucked string by d'Alembert and by Fourier, the harmonic series and the gaps a pluck leaves in it, and why an exact solution sampled directly aliases.
48+2. **Leapfrog, and the magic time step**: the standard explicit scheme, its stability limit, the one Courant number at which it is exact, the pitch of every partial measured everywhere else, and the sound of numerical dispersion.
49+3. **The digital waveguide**: the exact scheme without its grid, a delay loop that costs one operation per sample, and the tuning problem that comes with it.
50+
51+**Drums**
52+
53+4. **A square drum**: the same scheme in two dimensions, an inharmonic spectrum, and a pitch error that depends on the direction of travel.
54+5. **Fewer points per wavelength**: fourth-order stencils in space, in time and in both, and why raising the order in space alone buys little.
55+6. **A round drum on a square grid**: Bessel modes and the sound of an inharmonic membrane, and what a curved rim costs a grid that cannot follow it.
56+
57+**Rooms**
58+
59+7. **Letting sound out**: four ways of ending a grid, from a rigid wall to a perfectly matched layer, measured against a reference that is exact by causality.
60+8. **A room, by image sources**: the room's impulse response from a lattice of mirror images, and the reverberation formulas of Sabine and Eyring measured against it.
61+9. **A room, by finite differences**: the leapfrog scheme in three dimensions against the exact image sum for rigid walls, and the arithmetic that confines it to low frequencies.
62+
63+A **shared MATLAB library** page lists the function files every lab boots with.
64+
65+### Using it
66+
67+Each lab has controls above its figures and, behind the "show script" button, the method itself. Edit the script and press run: the session reboots with your version compiled in, and the figures and sounds are whatever your code produces. Ctrl-Enter (Cmd-Enter) runs from inside the editor, and "reset" restores the original. Some labs run as soon as the chapter opens; the heavier ones wait for the run button, and their captions say so.
68+
69+The listen buttons play through your browser's audio output. Start with the volume low: some of the clips are deliberately harsh, and within one comparison they share a scale, so that a louder clip means a larger signal.
src/chapters/membrane/driver.madded+221−0View file
@@ -0,0 +1,221 @@
1+% Driver for Chapters 4 and 5: the square membrane.
2+%
3+% The editable script supplies
4+%
5+% [y, U] = drum2d(u, uold, lam, M, scheme, ir, jr, K)
6+%
7+% which advances a scheme M steps on the unit square, rim held at zero.
8+%
9+% The exact solution is a sum of the modes sin(m pi x) sin(n pi y), each with
10+% frequency (c/2) sqrt(m^2 + n^2). Those same sine vectors, sampled on the
11+% grid, are exact eigenvectors of every stencil in these chapters, so a
12+% scheme started from a single mode keeps it and changes only its frequency,
13+% which the driver measures from the recording and nothing else.
14+%
15+% mode 'strike' a struck drum, with any scheme: the membrane at rest, given a
16+% Gaussian blow of velocity at one point, simulated and heard
17+% at another. The reference is the modal sum, evaluated
18+% exactly at the receiver. The time step is lam h / c, so the
19+% sound comes out at its own sample rate, 1/dt.
20+% mode 'cents' the pitch error of modes in every direction, at three
21+% wavelengths, for several Courant numbers (c = 1 here).
22+% mode 'orders' the pitch error of three fixed modes against N, for each
23+% scheme at its own Courant number, and the time each run took.
24+p = wv_params();
25+out = struct();
26+
27+if strcmp(p.mode, 'strike')
28+ c = p.c;
29+ N = p.N;
30+ h = 1 / N;
31+ lam = p.lam;
32+ dt = lam * h / c;
33+ fs = 1 / dt;
34+ M = round(p.T / dt);
35+ K = p.kmax;
36+ [nn, mm] = meshgrid(1:K, 1:K);
37+ k2 = (mm .^ 2 + nn .^ 2) * pi ^ 2;
38+ w = c * sqrt(k2);
39+ sw = p.width;
40+ % sine coefficients of the initial velocity exp(-r^2 / (2 sw^2)); the
41+ % Gaussian is far enough from the rim that its truncation there is below
42+ % rounding, so these are exact
43+ A = 4 * 2 * pi * sw ^ 2 * sin(mm * pi * p.xs) .* sin(nn * pi * p.ys) .* exp(-k2 * sw ^ 2 / 2);
44+ x = (0:N)' * h;
45+ S = sin(pi * x * (1:K));
46+ u0 = zeros(N + 1, N + 1);
47+ um = S * (A .* sin(-w * dt) ./ w) * S';
48+ ir = round(p.xr * N) + 1;
49+ jr = round(p.yr * N) + 1;
50+ t0 = tic;
51+ [y, ~] = drum2d(u0, um, lam, M, p.scheme, ir, jr, 0);
52+ out.ms = toc(t0) * 1000;
53+ % the exact signal at the same node
54+ amp = A .* sin(mm * pi * x(ir)) .* sin(nn * pi * x(jr)) ./ w;
55+ keep = find(abs(amp) > 1e-10 * max(abs(amp(:))));
56+ t = (1:M)' * dt;
57+ ye = zeros(M, 1);
58+ for q = keep'
59+ ye = ye + amp(q) * sin(w(q) * t);
60+ end
61+ e = y - ye;
62+ out.N = N;
63+ out.lam = lam;
64+ out.fs = fs;
65+ out.rel = sqrt(sum(e .^ 2) / sum(ye .^ 2));
66+ out.nmodes = numel(keep);
67+ sc = max(abs(ye));
68+ out.clipExact = wv_audio(ye, fs, sc);
69+ out.clipFd = wv_audio(y, fs, sc);
70+ out.clipDiff = wv_audio(e, fs, sc);
71+ nw = min(M, round(0.4 * fs));
72+ [fr, dbe] = wv_spectrum(ye(1:nw), fs, p.fmax, 800);
73+ [~, dbf] = wv_spectrum(y(1:nw), fs, p.fmax, 800);
74+ out.fr = fr;
75+ out.dbe = dbe;
76+ out.dbf = dbf;
77+ % the lowest few mode frequencies that the blow excites, for the plot
78+ fm = w(:) / (2 * pi);
79+ am = abs(amp(:));
80+ [fm, ord] = sort(fm);
81+ am = am(ord);
82+ sel = am > 1e-3 * max(am) & fm < p.fmax;
83+ fsel = fm(sel);
84+ out.fmodes = fsel(1:min(end, 40))';
85+ out.f11 = c * sqrt(2) / 2;
86+ w0 = find(t >= 0.1 & t < 0.1 + 4 / out.f11);
87+ out.tw = t(w0)' * 1000;
88+ out.yw = y(w0)';
89+ out.yew = ye(w0)';
90+elseif strcmp(p.mode, 'cents')
91+ N = p.N;
92+ h = 1 / N;
93+ x = (0:N)' * h;
94+ lams = p.lams(:)';
95+ Rs = p.rs(:)';
96+ th = linspace(4, 86, p.nang) * pi / 180;
97+ MN = zeros(0, 3);
98+ for r = 1:numel(Rs)
99+ R = Rs(r);
100+ for q = 1:numel(th)
101+ m = round(R * cos(th(q)));
102+ n = round(R * sin(th(q)));
103+ if m >= 1 && n >= 1 && m < N && n < N
104+ MN(end + 1, :) = [m, n, r];
105+ end
106+ end
107+ end
108+ % no mode twice
109+ [~, iu] = unique(MN(:, 1) * 10000 + MN(:, 2));
110+ MN = MN(sort(iu), :);
111+ nq = size(MN, 1);
112+ Phi = zeros((N + 1) ^ 2, nq);
113+ for q = 1:nq
114+ f = sin(MN(q, 1) * pi * x) * sin(MN(q, 2) * pi * x)';
115+ Phi(:, q) = f(:);
116+ end
117+ wq = pi * sqrt(MN(:, 1) .^ 2 + MN(:, 2) .^ 2); % exact, with c = 1
118+ fex = wq / (2 * pi);
119+ MEAS = NaN(numel(lams), nq);
120+ PRED = NaN(numel(lams), nq);
121+ for k = 1:numel(lams)
122+ lam = lams(k);
123+ dt = lam * h;
124+ Kst = ceil(12 / (min(fex) * dt));
125+ u0 = reshape(sum(Phi, 2), N + 1, N + 1);
126+ um = reshape(Phi * cos(wq * dt), N + 1, N + 1);
127+ [~, U] = drum2d(u0, um, lam, Kst, 'fd2', 1, 1, Kst);
128+ A = Phi' * U;
129+ for q = 1:nq
130+ fmq = wv_peakfreq(A(q, :), dt, 0, 0.5 / dt);
131+ MEAS(k, q) = wv_cents(fmq, fex(q));
132+ end
133+ s2 = sin(MN(:, 1) * pi * h / 2) .^ 2 + sin(MN(:, 2) * pi * h / 2) .^ 2;
134+ wp = 2 * asin(lam * sqrt(s2)) / dt;
135+ PRED(k, :) = wv_cents(wp / (2 * pi), fex)';
136+ end
137+ out.N = N;
138+ out.lams = wv_vec(lams);
139+ out.m = MN(:, 1)';
140+ out.n = MN(:, 2)';
141+ out.ring = MN(:, 3)';
142+ out.rs = Rs;
143+ out.angle = atan2(MN(:, 2), MN(:, 1))' * 180 / pi;
144+ out.ppw = (2 * N ./ sqrt(MN(:, 1) .^ 2 + MN(:, 2) .^ 2))';
145+ out.meas = wv_rows(MEAS);
146+ out.pred = wv_rows(PRED);
147+ out.agree = max(abs(MEAS(:) - PRED(:)));
148+else
149+ Ns = p.Ns(:)';
150+ schemes = p.schemes;
151+ lams = p.lams(:)';
152+ modes = reshape(p.modes, 2, [])';
153+ nmode = size(modes, 1);
154+ ns = numel(schemes);
155+ CE = NaN(ns * nmode, numel(Ns));
156+ TM = NaN(ns, numel(Ns));
157+ WORST = NaN(ns, numel(Ns));
158+ for s = 1:ns
159+ drum2d(zeros(12), zeros(12), 0.5, 20, schemes{s}, 3, 3, 0); % warm-up
160+ for j = 1:numel(Ns)
161+ N = Ns(j);
162+ h = 1 / N;
163+ x = (0:N)' * h;
164+ lam = lams(s);
165+ dt = lam * h;
166+ % the first interior node: sin(m pi / N) is never zero for m < N,
167+ % so no mode has a nodal line through it
168+ ir = 2;
169+ jr = 2;
170+ wst = 0;
171+ for q = 1:nmode
172+ m = modes(q, 1);
173+ n = modes(q, 2);
174+ if m >= N || n >= N
175+ continue;
176+ end
177+ wq = pi * sqrt(m ^ 2 + n ^ 2);
178+ f = sin(m * pi * x) * sin(n * pi * x)';
179+ M = ceil(12 * 2 * pi / (wq * dt));
180+ if q == 1
181+ % the lowest mode's run is also the one that is timed:
182+ % twelve of its periods, whatever the scheme, fastest of
183+ % at least three runs while they are cheap
184+ best = Inf;
185+ tot = 0;
186+ rep = 0;
187+ while rep == 0 || (rep < 20 && tot < 0.3 && (rep < 3 || tot < 0.05))
188+ t0 = tic;
189+ y = drum2d(f, f * cos(wq * dt), lam, M, schemes{s}, ir, jr, 0);
190+ el = toc(t0);
191+ best = min(best, el);
192+ tot = tot + el;
193+ rep = rep + 1;
194+ end
195+ TM(s, j) = best * 1000;
196+ else
197+ y = drum2d(f, f * cos(wq * dt), lam, M, schemes{s}, ir, jr, 0);
198+ end
199+ fm = wv_peakfreq(y, dt, 0, 0.5 / dt);
200+ cq = wv_cents(fm, wq / (2 * pi));
201+ CE((s - 1) * nmode + q, j) = cq;
202+ wst = max(wst, abs(cq));
203+ end
204+ WORST(s, j) = wst;
205+ end
206+ end
207+ out.Ns = Ns;
208+ out.schemes = schemes;
209+ out.lams = wv_vec(lams);
210+ out.modes = wv_rows(modes);
211+ out.cents = wv_rows(CE);
212+ out.worst = wv_rows(WORST);
213+ out.ms = wv_rows(TM);
214+ Q = NaN(ns, numel(Ns));
215+ for s = 1:ns
216+ Q(s, :) = wv_orders(WORST(s, :), Ns);
217+ end
218+ out.orders = wv_rows(Q);
219+end
220+
221+wv_write(out);
src/chapters/membrane/panels.tsxadded+237−0View file
@@ -0,0 +1,237 @@
1+// Panels for the square-membrane labs of Chapters 4 and 5.
2+import type { Num, Params } from '../../engine/types.ts'
3+import Listen, { type Clip } from '../../audio/Listen.tsx'
4+import { Row, SeriesPlot, NumTable, fmtCents, fmtErr, fmtMs, fmtNum } from '../common/panels.tsx'
5+import { faded, familyColor, familyShapes, ink, method, type MethodKey } from '../../plot/palette.ts'
6+import type { Control } from '../../ui/types.ts'
7+
8+export interface StrikeOut {
9+ ms: number
10+ N: number
11+ lam: number
12+ fs: number
13+ rel: number
14+ nmodes: number
15+ f11: number
16+ fr: number[]
17+ dbe: number[]
18+ dbf: number[]
19+ fmodes: number[]
20+ tw: number[]
21+ yw: number[]
22+ yew: number[]
23+ clipExact: Clip
24+ clipFd: Clip
25+ clipDiff: Clip
26+}
27+
28+export interface CentsOut {
29+ N: number
30+ lams: number[]
31+ m: number[]
32+ n: number[]
33+ ring: number[]
34+ rs: number[]
35+ angle: number[]
36+ ppw: number[]
37+ meas: Num[][]
38+ pred: Num[][]
39+ agree: number
40+}
41+
42+export interface OrdersOut {
43+ Ns: number[]
44+ schemes: string[]
45+ lams: number[]
46+ modes: number[][]
47+ cents: Num[][]
48+ worst: Num[][]
49+ ms: Num[][]
50+ orders: Num[][]
51+}
52+
53+/**
54+ * Which colour and shape each scheme wears, in every chapter. Fourth order
55+ * in space alone borrows the waveguide's amber, since no chapter shows both;
56+ * the spare red is too close to fd4's orange to sit beside it.
57+ */
58+export const SCHEME: Record<string, { key: MethodKey; label: string; shape: 'circle' | 'square' | 'triangle' }> = {
59+ fd2: { key: 'fd2', label: 'second order (leapfrog, five-point)', shape: 'circle' },
60+ fd4: { key: 'waveguide', label: 'fourth order in space only', shape: 'square' },
61+ fd44: { key: 'fd4', label: 'fourth order in space and time', shape: 'triangle' },
62+}
63+
64+/** The drum of Chapters 4 and 5: 150 Hz fundamental on a unit square. */
65+export const DRUM = { c: 150 * Math.SQRT2, kmax: 90, width: 0.03, xr: 0.77, yr: 0.63, fmax: 3000 }
66+
67+export const STRIKE_CONTROL: Control = {
68+ kind: 'select',
69+ key: 'at',
70+ label: 'strike at',
71+ options: [
72+ { value: 'off', label: 'off centre, (0.31, 0.42)' },
73+ { value: 'centre', label: 'the centre' },
74+ { value: 'rim', label: 'near the rim, (0.12, 0.2)' },
75+ ],
76+}
77+
78+export function strikeParams(p: Params, scheme: string) {
79+ const at = String(p.at ?? 'off')
80+ const [xs, ys] = at === 'centre' ? [0.5, 0.5] : at === 'rim' ? [0.12, 0.2] : [0.31, 0.42]
81+ return { mode: 'strike', ...DRUM, N: p.N, lam: p.lam, T: 1.0, xs, ys, scheme }
82+}
83+
84+export function StrikePanels({ d, scheme = 'fd2' }: { d: StrikeOut; scheme?: string }) {
85+ const col = method[SCHEME[scheme].key]
86+ return (
87+ <>
88+ <SeriesPlot
89+ title="the recording from 0.1 s after the blow, over four periods of the fundamental"
90+ height={180}
91+ xLabel="ms"
92+ series={[
93+ { x: d.tw, y: d.yew, color: ink.reference, label: 'exact (modal sum)', width: 2.6 },
94+ { x: d.tw, y: d.yw, color: col, label: `N = ${d.N}, λ = ${d.lam}`, width: 1.3 },
95+ ]}
96+ />
97+ <SeriesPlot
98+ title="spectrum of the first 0.4 s; faint lines are the exact mode frequencies the blow excites"
99+ height={230}
100+ xLabel="Hz"
101+ yLabel="dB"
102+ yDomain={[-90, 3]}
103+ vlines={[{ x: d.fmodes, color: faded(ink.muted, 0.5) }]}
104+ series={[
105+ { x: d.fr, y: d.dbe, color: ink.reference, label: 'exact', width: 1.8 },
106+ { x: d.fr, y: d.dbf, color: col, label: 'simulated', width: 1.2 },
107+ ]}
108+ />
109+ <Listen
110+ items={[
111+ { label: 'exact', clip: d.clipExact },
112+ { label: 'simulated', clip: d.clipFd },
113+ { label: 'the difference', clip: d.clipDiff },
114+ ]}
115+ note={`all three at the same scale, at the simulation's own rate of ${(d.fs / 1000).toFixed(1)} kHz`}
116+ />
117+ </>
118+ )
119+}
120+
121+export function strikeSummary(d: StrikeOut) {
122+ return [
123+ { label: 'sample rate 1/dt', value: `${(d.fs / 1000).toFixed(2)} kHz` },
124+ { label: 'modes in the exact sum', value: String(d.nmodes) },
125+ { label: 'relative rms error', value: fmtErr(d.rel) },
126+ ]
127+}
128+
129+export function CentsPanels({ d }: { d: CentsOut }) {
130+ const nl = d.lams.length
131+ return (
132+ <Row>
133+ {d.rs.map((R, r) => {
134+ const idx = d.ring.map((g, i) => (g === r + 1 ? i : -1)).filter((i) => i >= 0)
135+ const ppw = (2 * d.N) / R
136+ return (
137+ <SeriesPlot
138+ key={R}
139+ title={`|k| ≈ ${R}π: about ${fmtNum(ppw, 2)} points per wavelength`}
140+ height={220}
141+ xDomain={[0, 90]}
142+ xTicks={[0, 15, 30, 45, 60, 75, 90]}
143+ xLabel="direction, degrees from the x axis"
144+ yLabel="cents"
145+ series={d.lams.flatMap((l, k) => [
146+ {
147+ x: idx.map((i) => d.angle[i]),
148+ y: idx.map((i) => d.pred[k][i]),
149+ color: familyColor(k, nl),
150+ width: 1.1,
151+ noLegend: true,
152+ },
153+ {
154+ x: idx.map((i) => d.angle[i]),
155+ y: idx.map((i) => d.meas[k][i]),
156+ color: familyColor(k, nl),
157+ shape: familyShapes[k],
158+ line: false,
159+ label: `λ = ${fmtNum(l, 3)}`,
160+ tip: (j: number) => {
161+ const i = idx[j]
162+ return [`mode (${d.m[i]}, ${d.n[i]}), ${d.angle[i].toFixed(1)}°`, `measured ${fmtCents(d.meas[k][i])}`]
163+ },
164+ },
165+ ])}
166+ />
167+ )
168+ })}
169+ </Row>
170+ )
171+}
172+
173+export function OrdersPanels({ d }: { d: OrdersOut }) {
174+ const sc = d.schemes.map((s) => SCHEME[s])
175+ const nm = d.modes.length
176+ const tip = (s: number) => (j: number) => [
177+ `N = ${d.Ns[j]}, run ${fmtMs(d.ms[s][j])}`,
178+ ...d.modes.map((m, q) => `mode (${m[0]},${m[1]}): ${fmtCents(d.cents[s * nm + q][j])}`),
179+ ]
180+ return (
181+ <>
182+ <Row>
183+ <SeriesPlot
184+ title="worst pitch error of the three test modes, against the grid"
185+ height={250}
186+ xLog
187+ yLog
188+ yDomain={[1e-3, 3000]}
189+ xLabel="grid intervals N"
190+ xTicks={d.Ns}
191+ yLabel="|cents|"
192+ hlines={[{ y: 5, label: '5 cents' }]}
193+ refs={[
194+ { p: -2, through: [40, 150], label: 'slope −2', span: 0.35, labelAt: 'end' },
195+ { p: -4, through: [40, 12], label: 'slope −4', span: 0.35 },
196+ ]}
197+ series={sc.map((s, k) => ({
198+ x: d.Ns,
199+ y: d.worst[k],
200+ color: method[s.key],
201+ shape: s.shape,
202+ label: s.label,
203+ tip: tip(k),
204+ }))}
205+ />
206+ <SeriesPlot
207+ title="the same errors against the time each run took"
208+ height={250}
209+ xLog
210+ yLog
211+ yDomain={[1e-3, 3000]}
212+ xLabel="ms"
213+ yLabel="|cents|"
214+ hlines={[{ y: 5, label: '5 cents' }]}
215+ series={sc.map((s, k) => ({
216+ x: d.ms[k],
217+ y: d.worst[k],
218+ color: method[s.key],
219+ shape: s.shape,
220+ label: s.label,
221+ tip: tip(k),
222+ }))}
223+ legend={false}
224+ />
225+ </Row>
226+ <NumTable
227+ caption="worst pitch error in cents, and its observed order between successive grids"
228+ head={['N', ...d.schemes.map((s) => `${s} error`), ...d.schemes.map((s) => `${s} order`)]}
229+ rows={d.Ns.map((N, j) => [
230+ N,
231+ ...d.schemes.map((_, s) => fmtNum(d.worst[s][j], 3)),
232+ ...d.schemes.map((_, s) => fmtNum(d.orders[s][j], 3)),
233+ ])}
234+ />
235+ </>
236+ )
237+}
src/chapters/shared.tsadded+29−0View file
@@ -0,0 +1,29 @@
1+// Settings reused across chapters, so that "the audio rate" and "A4" mean the
2+// same thing everywhere.
3+import type { Control } from '../ui/types.ts'
4+
5+/** Every sound in the tutorial that is not a simulation's own output is at CD rate. */
6+export const FS = 44100
7+
8+const NAMES = ['C', 'C♯', 'D', 'E♭', 'E', 'F', 'F♯', 'G', 'A♭', 'A', 'B♭', 'B']
9+
10+/** MIDI note number to a name like A4, with A4 = 69. */
11+export function noteName(m: number): string {
12+ const r = Math.round(m)
13+ return `${NAMES[((r % 12) + 12) % 12]}${Math.floor(r / 12) - 1}`
14+}
15+
16+/** Equal-tempered frequency of a MIDI note, A4 = 440 Hz. */
17+export function noteHz(m: number): number {
18+ return 440 * Math.pow(2, (m - 69) / 12)
19+}
20+
21+export function noteLabel(m: number): string {
22+ const f = noteHz(m)
23+ return `${noteName(m)}, ${f >= 1000 ? f.toFixed(0) : f.toFixed(1)} Hz`
24+}
25+
26+/** A slider over a list of MIDI notes, showing the name and the frequency. */
27+export function noteControl(key: string, label: string, notes: number[]): Control {
28+ return { kind: 'slider', key, label, min: 0, max: 0, values: notes, format: noteLabel }
29+}
src/editor/ScriptEditor.tsxadded+70−0View file
@@ -0,0 +1,70 @@
1+import { useEffect, useRef } from 'react'
2+import { EditorView, basicSetup } from 'codemirror'
3+import { EditorState } from '@codemirror/state'
4+import { keymap } from '@codemirror/view'
5+import { indentUnit, StreamLanguage } from '@codemirror/language'
6+import { octave } from '@codemirror/legacy-modes/mode/octave'
7+import { oneDark } from '@codemirror/theme-one-dark'
8+
9+export interface ScriptEditorProps {
10+ value: string
11+ onChange: (value: string) => void
12+ /** Ctrl/Cmd+Enter */
13+ onRun: () => void
14+}
15+
16+export default function ScriptEditor({ value, onChange, onRun }: ScriptEditorProps) {
17+ const hostRef = useRef<HTMLDivElement>(null)
18+ const viewRef = useRef<EditorView | null>(null)
19+ const onChangeRef = useRef(onChange)
20+ onChangeRef.current = onChange
21+ const onRunRef = useRef(onRun)
22+ onRunRef.current = onRun
23+
24+ useEffect(() => {
25+ if (!hostRef.current) return
26+ const view = new EditorView({
27+ parent: hostRef.current,
28+ state: EditorState.create({
29+ doc: value,
30+ extensions: [
31+ basicSetup,
32+ keymap.of([
33+ {
34+ key: 'Mod-Enter',
35+ run: () => {
36+ onRunRef.current()
37+ return true
38+ },
39+ },
40+ ]),
41+ StreamLanguage.define(octave),
42+ oneDark,
43+ indentUnit.of(' '),
44+ EditorView.updateListener.of((update) => {
45+ if (update.docChanged) onChangeRef.current(update.state.doc.toString())
46+ }),
47+ ],
48+ }),
49+ })
50+ viewRef.current = view
51+ return () => {
52+ view.destroy()
53+ viewRef.current = null
54+ }
55+ // created once; external value changes are synced below
56+ // eslint-disable-next-line react-hooks/exhaustive-deps
57+ }, [])
58+
59+ // sync an externally loaded script (a different method picked) into the editor
60+ useEffect(() => {
61+ const view = viewRef.current
62+ if (!view) return
63+ const current = view.state.doc.toString()
64+ if (current !== value) {
65+ view.dispatch({ changes: { from: 0, to: current.length, insert: value } })
66+ }
67+ }, [value])
68+
69+ return <div className="editor-host" ref={hostRef} />
70+}
src/engine/lib.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+// Shared .m function files, put into every session as ordinary function files.
2+import type { BootFile } from 'numbl/browser'
3+
4+const libRaw = import.meta.glob('../matlab/*.m', {
5+ query: '?raw',
6+ import: 'default',
7+ eager: true,
8+}) as Record<string, string>
9+
10+export const LIB_FILES: BootFile[] = Object.entries(libRaw).map(([path, content]) => ({
11+ path: path.slice(path.lastIndexOf('/') + 1),
12+ content,
13+}))
src/engine/runner.tsadded+94−0View file
@@ -0,0 +1,94 @@
1+// Runs a lab's MATLAB layer in a numbl session.
2+//
3+// One session per (driver, script) pair, booted on first use and then reused:
4+// changing a control only rewrites params.json and re-runs main.m, which is
5+// fast enough to drive sliders. Editing the script needs a fresh session,
6+// because the script is compiled into main.m at boot.
7+import { createNumblSession, type BootFile, type NumblSession } from 'numbl/browser'
8+import type { Params, RunResult } from './types.ts'
9+import { LIB_FILES } from './lib.ts'
10+
11+function bootFiles(driver: string, script: string): BootFile[] {
12+ return [
13+ ...LIB_FILES,
14+ { path: 'main.m', content: `${driver}\n${script}` },
15+ // params.json is overwritten before every run; it only needs to exist so
16+ // that the first fileread has something to find
17+ { path: 'params.json', content: '{}' },
18+ ]
19+}
20+
21+export class Engine {
22+ private session: NumblSession | null = null
23+ private bootedFor: string | null = null
24+ private queue: Promise<unknown> = Promise.resolve()
25+ private chunks: string[] = []
26+
27+ /** Resolves when the run finishes; runs are serialised in call order. */
28+ run<T>(driver: string, script: string, params: Params): Promise<RunResult<T>> {
29+ const task = this.queue.then(
30+ () => this.exec<T>(driver, script, params),
31+ () => this.exec<T>(driver, script, params),
32+ )
33+ this.queue = task.catch(() => undefined)
34+ return task
35+ }
36+
37+ private async exec<T>(driver: string, script: string, params: Params): Promise<RunResult<T>> {
38+ const t0 = performance.now()
39+ const fail = (error: string): RunResult<T> => ({
40+ ok: false,
41+ error,
42+ output: this.chunks.join(''),
43+ ms: performance.now() - t0,
44+ })
45+
46+ const key = `${driver}\n---\n${script}`
47+ try {
48+ if (!this.session || this.bootedFor !== key) {
49+ this.session?.dispose()
50+ this.session = null
51+ this.bootedFor = null
52+ this.chunks = []
53+ const session = await createNumblSession({
54+ files: bootFiles(driver, script),
55+ mip: false,
56+ persistSystem: false,
57+ optimization: '1',
58+ onOutput: (text) => {
59+ this.chunks.push(text)
60+ },
61+ })
62+ this.session = session
63+ this.bootedFor = key
64+ }
65+
66+ this.chunks = []
67+ this.session.writeFile('params.json', JSON.stringify(params))
68+ // `run` rather than a bare `main;`: the file is a script with local
69+ // functions, and this says so at the call site.
70+ const res = await this.session.execute("run('main.m');")
71+ if (!res.ok) return fail(res.error ?? 'the script failed')
72+
73+ const bytes = await this.session.readFile('out.json')
74+ return {
75+ ok: true,
76+ data: JSON.parse(new TextDecoder().decode(bytes)) as T,
77+ output: this.chunks.join(''),
78+ ms: performance.now() - t0,
79+ }
80+ } catch (err) {
81+ // a boot failure leaves nothing usable behind
82+ this.session?.dispose()
83+ this.session = null
84+ this.bootedFor = null
85+ return fail(err instanceof Error ? err.message : String(err))
86+ }
87+ }
88+
89+ dispose() {
90+ this.session?.dispose()
91+ this.session = null
92+ this.bootedFor = null
93+ }
94+}
src/engine/types.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+// The contract between the app and the MATLAB layer.
2+//
3+// Every lab writes its controls into params.json and reads back out.json, so
4+// the TypeScript side never needs to know more than "some JSON goes in, some
5+// JSON comes out". Individual panels narrow the output to their own shape.
6+
7+/** NaN and +-Inf all cross the JSON boundary as null. */
8+export type Num = number | null
9+
10+export type ParamValue = number | string | boolean | ParamValue[]
11+export type Params = Record<string, ParamValue>
12+
13+export type RunResult<T> =
14+ | { ok: true; data: T; output: string; ms: number }
15+ | { ok: false; error: string; output: string; ms: number }
src/figures/drums.tsxadded+281−0View file
@@ -0,0 +1,281 @@
1+// Figures for Part II: the square's modes, the anisotropy of the grid, the
2+// three stencils of Chapter 5, and the two rims of Chapter 6.
3+import type { ReactElement } from 'react'
4+import { Figure, Label, Node } from './svg.tsx'
5+import { faded, familyColor, ink, method } from '../plot/palette.ts'
6+
7+const pts = (xy: [number, number][]) => xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join('')
8+
9+/** Six modes of the square membrane, with their frequencies relative to the lowest. */
10+export function Modes() {
11+ const modes: [number, number][] = [
12+ [1, 1],
13+ [2, 1],
14+ [2, 2],
15+ [3, 1],
16+ [3, 2],
17+ [3, 3],
18+ ]
19+ const s = 92
20+ const gap = 22
21+ const x0 = (716 - (6 * s + 5 * gap)) / 2
22+ const y0 = 18
23+ const n = 18
24+ return (
25+ <Figure
26+ width={716}
27+ height={176}
28+ caption="Six modes of a square membrane, shaded by the sign of the displacement, with their frequencies as multiples of the lowest. Modes (m, n) and (n, m) have the same frequency, so the square's spectrum is full of coincident pairs, and none of the ratios but (2, 2) is a whole number."
29+ >
30+ {modes.map(([m, k], q) => {
31+ const ox = x0 + q * (s + gap)
32+ const cells: ReactElement[] = []
33+ for (let i = 0; i < n; i++) {
34+ for (let j = 0; j < n; j++) {
35+ const v = Math.sin((m * Math.PI * (j + 0.5)) / n) * Math.sin((k * Math.PI * (i + 0.5)) / n)
36+ const c = v > 0 ? method.boundary : method.fd2
37+ cells.push(
38+ <rect
39+ key={`${i}-${j}`}
40+ x={ox + (j * s) / n}
41+ y={y0 + (i * s) / n}
42+ width={s / n + 0.4}
43+ height={s / n + 0.4}
44+ fill={faded(c, 1 - Math.abs(v) * 0.8)}
45+ />,
46+ )
47+ }
48+ }
49+ return (
50+ <g key={q}>
51+ {cells}
52+ <rect x={ox} y={y0} width={s} height={s} fill="none" stroke={ink.secondary} strokeWidth={1.6} />
53+ <Label x={ox + s / 2} y={y0 + s + 18} size={12} fill={ink.primary}>
54+ {`(${m}, ${k})`}
55+ </Label>
56+ <Label x={ox + s / 2} y={y0 + s + 35} size={11.5} fill={ink.muted}>
57+ {`× ${(Math.sqrt(m * m + k * k) / Math.SQRT2).toFixed(3)}`}
58+ </Label>
59+ </g>
60+ )
61+ })}
62+ </Figure>
63+ )
64+}
65+
66+/** Where a circular wavefront ends up on the grid, for a short wavelength. */
67+export function Anisotropy() {
68+ const cx = 250
69+ const cy = 200
70+ const R = 170
71+ const P = 3
72+ const kh = (2 * Math.PI) / P
73+ const speed = (th: number, lam: number) => {
74+ const s2 = Math.sin((kh * Math.cos(th)) / 2) ** 2 + Math.sin((kh * Math.sin(th)) / 2) ** 2
75+ return (2 * Math.asin(lam * Math.sqrt(s2))) / (lam * kh)
76+ }
77+ const lams = [0.3, 0.5, Math.SQRT1_2]
78+ const front = (lam: number) => {
79+ const out: [number, number][] = []
80+ for (let i = 0; i <= 90; i++) {
81+ const th = (Math.PI / 2) * (i / 90)
82+ const r = R * speed(th, lam)
83+ out.push([cx + r * Math.cos(th), cy - r * Math.sin(th)])
84+ }
85+ return pts(out)
86+ }
87+ return (
88+ <Figure
89+ width={716}
90+ height={228}
91+ caption="A quarter of a circular wavefront at three points per wavelength: where the exact wave has reached (grey), and where the grid's waves of the same length have reached in the same time, for three Courant numbers. Waves along the grid's axes are slowest; along the diagonal, at λ = 1/√2, the grid is exact. At twenty points per wavelength the same picture would be indistinguishable from a circle."
92+ >
93+ <line x1={cx} y1={cy} x2={cx + R + 20} y2={cy} stroke={ink.axis} />
94+ <line x1={cx} y1={cy} x2={cx} y2={cy - R - 14} stroke={ink.axis} />
95+ <line x1={cx} y1={cy} x2={cx + (R + 10) * Math.SQRT1_2} y2={cy - (R + 10) * Math.SQRT1_2} stroke={ink.axis} strokeDasharray="3 3" />
96+ <path d={`M${cx + R} ${cy}A${R} ${R} 0 0 0 ${cx} ${cy - R}`} fill="none" stroke={ink.reference} strokeWidth={2.4} />
97+ {lams.map((l, k) => (
98+ <path key={l} d={front(l)} fill="none" stroke={familyColor(k, lams.length)} strokeWidth={2} />
99+ ))}
100+ <Label x={cx + R + 24} y={cy} size={11} fill={ink.muted} anchor="start">
101+ along an axis
102+ </Label>
103+ <Label x={cx + (R + 16) * Math.SQRT1_2} y={cy - (R + 16) * Math.SQRT1_2} size={11} fill={ink.muted} anchor="start">
104+ along the diagonal
105+ </Label>
106+ <Label x={cx - 6} y={cy + 14} size={11} fill={ink.muted} anchor="end">
107+ start
108+ </Label>
109+ {lams.map((l, k) => (
110+ <g key={l}>
111+ <line x1={500} x2={526} y1={70 + 22 * k} y2={70 + 22 * k} stroke={familyColor(k, lams.length)} strokeWidth={2} />
112+ <Label x={534} y={70 + 22 * k} size={11.5} fill={ink.secondary} anchor="start">
113+ {k === 2 ? 'λ = 1/√2' : `λ = ${l}`}
114+ </Label>
115+ </g>
116+ ))}
117+ <line x1={500} x2={526} y1={70 + 22 * 3} y2={70 + 22 * 3} stroke={ink.reference} strokeWidth={2.4} />
118+ <Label x={534} y={70 + 22 * 3} size={11.5} fill={ink.secondary} anchor="start">
119+ exact
120+ </Label>
121+ </Figure>
122+ )
123+}
124+
125+/** The nodes each of Chapter 5's schemes reads to update one node. */
126+export function Stencils() {
127+ const g = 22
128+ const panel = (ox: number, title: string, sub: string, set: [number, number, string][]) => {
129+ const cy = 100
130+ const grid: ReactElement[] = []
131+ for (let i = -2; i <= 2; i++) {
132+ for (let j = -2; j <= 2; j++) {
133+ grid.push(<Node key={`g${i}${j}`} x={ox + j * g} y={cy + i * g} r={2.2} fill={ink.axis} />)
134+ }
135+ }
136+ return (
137+ <g>
138+ {grid}
139+ {set.map(([i, j, c]) => (
140+ <Node key={`${i}${j}`} x={ox + j * g} y={cy + i * g} r={i === 0 && j === 0 ? 7 : 5.5} fill={c} />
141+ ))}
142+ <Label x={ox} y={172} size={12} fill={ink.primary}>
143+ {title}
144+ </Label>
145+ <Label x={ox} y={190} size={11} fill={ink.muted}>
146+ {sub}
147+ </Label>
148+ </g>
149+ )
150+ }
151+ const five: [number, number, string][] = [
152+ [0, 0, method.fd2],
153+ [0, 1, method.fd2],
154+ [0, -1, method.fd2],
155+ [1, 0, method.fd2],
156+ [-1, 0, method.fd2],
157+ ]
158+ const cross = (c: string): [number, number, string][] => [
159+ [0, 0, c],
160+ [0, 1, c],
161+ [0, -1, c],
162+ [1, 0, c],
163+ [-1, 0, c],
164+ [0, 2, c],
165+ [0, -2, c],
166+ [2, 0, c],
167+ [-2, 0, c],
168+ ]
169+ const both: [number, number, string][] = [
170+ ...cross(method.fd4),
171+ [1, 1, faded(method.fd4, 0.35)],
172+ [1, -1, faded(method.fd4, 0.35)],
173+ [-1, 1, faded(method.fd4, 0.35)],
174+ [-1, -1, faded(method.fd4, 0.35)],
175+ ]
176+ return (
177+ <Figure
178+ width={716}
179+ height={206}
180+ caption="The nodes each scheme reads at the current step to update the centre node. The fourth-order Laplacian widens the five-point cross to nine nodes; the fully fourth-order scheme also applies the five-point Laplacian twice, which reaches the four diagonal neighbours (lighter) as well."
181+ >
182+ {panel(130, 'second order', 'five-point cross', five)}
183+ {panel(358, 'fourth order in space', 'nine-point cross', cross(method.waveguide))}
184+ {panel(586, 'fourth order in both', 'thirteen nodes', both)}
185+ </Figure>
186+ )
187+}
188+
189+/** A curved rim on a square grid: staircase against Shortley-Weller. */
190+export function Rim() {
191+ const g = 34
192+ const panel = (ox: number, sw: boolean) => {
193+ // a circle of radius R centred below-left of the panel, so that its arc
194+ // runs diagonally across it
195+ const R = 7.3
196+ const cxg = -2.2
197+ const cyg = -2.6
198+ const inside = (i: number, j: number) => (j - cxg) ** 2 + (i - cyg) ** 2 < R * R
199+ const X = (j: number) => ox + j * g
200+ const Y = (i: number) => 206 - i * g
201+ const arc: [number, number][] = []
202+ for (let k = 0; k <= 60; k++) {
203+ const t = (Math.PI / 2) * (k / 60)
204+ const j = cxg + R * Math.cos(t)
205+ const i = cyg + R * Math.sin(t)
206+ if (j >= -0.4 && j <= 6.4 && i >= -0.4 && i <= 5.4) arc.push([X(j), Y(i)])
207+ }
208+ const els: ReactElement[] = []
209+ for (let i = 0; i <= 5; i++) {
210+ for (let j = 0; j <= 6; j++) {
211+ const inn = inside(i, j)
212+ els.push(
213+ <circle
214+ key={`n${i}${j}`}
215+ cx={X(j)}
216+ cy={Y(i)}
217+ r={4}
218+ fill={inn ? ink.node : 'none'}
219+ stroke={inn ? 'none' : ink.muted}
220+ strokeWidth={1.2}
221+ />,
222+ )
223+ if (inn && sw) {
224+ // where the rim crosses the grid line to an outside neighbour
225+ if (!inside(i, j + 1) && j + 1 <= 6.4) {
226+ const jc = cxg + Math.sqrt(R * R - (i - cyg) ** 2)
227+ els.push(<line key={`e${i}${j}`} x1={X(j)} y1={Y(i)} x2={X(jc)} y2={Y(i)} stroke={method.boundary} strokeWidth={2} />)
228+ els.push(<circle key={`c${i}${j}`} cx={X(jc)} cy={Y(i)} r={3} fill={method.boundary} />)
229+ }
230+ if (!inside(i + 1, j) && i + 1 <= 5.4) {
231+ const ic = cyg + Math.sqrt(R * R - (j - cxg) ** 2)
232+ els.push(<line key={`u${i}${j}`} x1={X(j)} y1={Y(i)} x2={X(j)} y2={Y(ic)} stroke={method.boundary} strokeWidth={2} />)
233+ els.push(<circle key={`d${i}${j}`} cx={X(j)} cy={Y(ic)} r={3} fill={method.boundary} />)
234+ }
235+ }
236+ }
237+ }
238+ // the staircase: the stencil reaches outside neighbours, which are held
239+ // at zero as if the rim ran through them
240+ const stair: ReactElement[] = []
241+ if (!sw) {
242+ for (let i = 0; i <= 5; i++) {
243+ for (let j = 0; j <= 6; j++) {
244+ if (!inside(i, j)) continue
245+ const nb: [number, number][] = [
246+ [i, j + 1],
247+ [i + 1, j],
248+ ]
249+ for (const [a, b] of nb) {
250+ if (a > 5 || b > 6 || inside(a, b)) continue
251+ stair.push(<line key={`s${i}${j}${a}${b}`} x1={X(j)} y1={Y(i)} x2={X(b)} y2={Y(a)} stroke={method.fd2} strokeWidth={2} />)
252+ stair.push(<circle key={`z${a}${b}${i}${j}`} cx={X(b)} cy={Y(a)} r={4} fill={method.fd2} />)
253+ }
254+ }
255+ }
256+ }
257+ return (
258+ <g>
259+ <path d={pts(arc)} fill="none" stroke={ink.reference} strokeWidth={2} />
260+ {stair}
261+ {els}
262+ </g>
263+ )
264+ }
265+ return (
266+ <Figure
267+ width={716}
268+ height={262}
269+ caption="The rim of a drum crossing a square grid. Filled nodes are inside and are unknowns; open ones are outside and held at zero. Left, the staircase: the stencil reaches outside neighbours (blue), which are held at zero as if the rim passed through them, so the drum the grid sees extends up to one spacing beyond the true rim (grey), by an amount that varies irregularly along it. Right, Shortley–Weller: the true crossing points (pink) are kept, at a fraction θ of a spacing from the node, and the stencil uses the unequal spacing."
270+ >
271+ {panel(70, false)}
272+ {panel(430, true)}
273+ <Label x={172} y={248} size={12} fill={ink.primary}>
274+ staircase
275+ </Label>
276+ <Label x={532} y={248} size={12} fill={ink.primary}>
277+ Shortley–Weller
278+ </Label>
279+ </Figure>
280+ )
281+}
src/figures/index.tsadded+21−0View file
@@ -0,0 +1,21 @@
1+// Static figures, addressed from the prose by <!--fig:id--> the same way labs
2+// are addressed by <!--lab:id-->.
3+import type { ComponentType } from 'react'
4+import { Dalembert, Dispersion, Folding, Loop, Settings, Stencil } from './strings.tsx'
5+import { Anisotropy, Modes, Rim, Stencils } from './drums.tsx'
6+import { Causality, Images } from './rooms.tsx'
7+
8+export const FIGURES: Record<string, ComponentType> = {
9+ settings: Settings,
10+ dalembert: Dalembert,
11+ folding: Folding,
12+ stencil: Stencil,
13+ dispersion: Dispersion,
14+ loop: Loop,
15+ modes: Modes,
16+ anisotropy: Anisotropy,
17+ stencils: Stencils,
18+ rim: Rim,
19+ causality: Causality,
20+ images: Images,
21+}
src/figures/rooms.tsxadded+132−0View file
@@ -0,0 +1,132 @@
1+// Figures for Part III: the causal reference of Chapter 7 and the image
2+// lattice of Chapter 8.
3+import type { ReactElement } from 'react'
4+import { Arrowheads, Figure, Label, Node, arrow } from './svg.tsx'
5+import { faded, ink, method } from '../plot/palette.ts'
6+
7+/** A larger grid, run for fewer steps than it is wide, is exact in the region. */
8+export function Causality() {
9+ const cx = 250
10+ const cy = 132
11+ const r = 44
12+ const e = 96
13+ return (
14+ <Figure
15+ width={716}
16+ height={272}
17+ caption="The reference of Chapter 7. The region of interest (solid) sits inside a grid that is larger by M + 2 nodes on every side. In M steps, anything that starts at the outer edge can travel at most M nodes inwards (the shaded band), so it never reaches the region: inside it, the larger grid computes the scheme's free-space solution exactly, whatever happens at its own edges."
18+ >
19+ <Arrowheads id="cau" colors={[ink.muted, method.boundary]} />
20+ <rect x={cx - r - e} y={cy - r - e + 40} width={2 * (r + e)} height={2 * (r + e) - 80} fill="none" stroke={ink.secondary} strokeWidth={1.4} />
21+ <rect
22+ x={cx - r - e + 4}
23+ y={cy - r - e + 44}
24+ width={2 * (r + e) - 8}
25+ height={2 * (r + e) - 88}
26+ fill="none"
27+ stroke={faded(method.boundary, 0.55)}
28+ strokeWidth={30}
29+ opacity={0.5}
30+ />
31+ <rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} fill={faded(method.fd2, 0.82)} stroke={method.fd2} strokeWidth={2} />
32+ <Node x={cx - 12} y={cy + 4} r={4} fill={method.waveguide} />
33+ <Label x={cx} y={cy - r + 14} size={11} fill={ink.primary}>
34+ region
35+ </Label>
36+ <line x1={cx + r + 2} y1={cy + 24} x2={cx + r + e - 2} y2={cy + 24} stroke={ink.muted} strokeWidth={1.2} markerStart={arrow('cau', 0)} markerEnd={arrow('cau', 0)} />
37+ <Label x={cx + r + e / 2} y={cy + 38} size={11} fill={ink.muted}>
38+ M + 2 nodes
39+ </Label>
40+ <line x1={cx + r + e - 4} y1={cy - 28} x2={cx + r + e - 30} y2={cy - 28} stroke={method.boundary} strokeWidth={1.6} markerEnd={arrow('cau', 1)} />
41+ <Label x={470} y={96} size={11.5} fill={ink.secondary} anchor="start">
42+ after M steps, the influence of the
43+ </Label>
44+ <Label x={470} y={112} size={11.5} fill={ink.secondary} anchor="start">
45+ outer edge reaches at most M nodes in
46+ </Label>
47+ <Label x={470} y={148} size={11.5} fill={method.fd2} anchor="start">
48+ the region never feels it: its
49+ </Label>
50+ <Label x={470} y={164} size={11.5} fill={method.fd2} anchor="start">
51+ values are the free-space ones
52+ </Label>
53+ </Figure>
54+ )
55+}
56+
57+/** The mirror rooms and image sources of a rectangular room, in plan. */
58+export function Images() {
59+ const W = 120
60+ const H = 72
61+ const ox = 358 - 1.5 * W
62+ const oy = 20
63+ const xs = 0.3
64+ const ys = 0.62
65+ const xr = 0.72
66+ const yr = 0.3
67+ const rooms: ReactElement[] = []
68+ const imgs: ReactElement[] = []
69+ let chosen: [number, number] = [0, 0]
70+ for (let a = -1; a <= 1; a++) {
71+ for (let b = -1; b <= 1; b++) {
72+ const x = ox + (a + 1) * W
73+ const y = oy + (b + 1) * H
74+ rooms.push(
75+ <rect
76+ key={`r${a}${b}`}
77+ x={x}
78+ y={y}
79+ width={W}
80+ height={H}
81+ fill={a === 0 && b === 0 ? faded(method.fd2, 0.85) : 'none'}
82+ stroke={a === 0 && b === 0 ? ink.secondary : ink.axis}
83+ strokeWidth={a === 0 && b === 0 ? 2 : 1}
84+ />,
85+ )
86+ // the image in room (a, b) is mirrored once for every odd index
87+ const fx = a % 2 === 0 ? xs : 1 - xs
88+ const fy = b % 2 === 0 ? ys : 1 - ys
89+ const px = x + fx * W
90+ const py = y + fy * H
91+ if (a === 1 && b === 0) chosen = [px, py]
92+ imgs.push(
93+ <Node
94+ key={`i${a}${b}`}
95+ x={px}
96+ y={py}
97+ r={a === 0 && b === 0 ? 5 : 4}
98+ fill={a === 0 && b === 0 ? method.waveguide : faded(method.waveguide, 0.35)}
99+ />,
100+ )
101+ }
102+ }
103+ const R: [number, number] = [ox + W + xr * W, oy + H + yr * H]
104+ const S: [number, number] = [ox + W + xs * W, oy + H + ys * H]
105+ // the path from the image in the right-hand room crosses the shared wall
106+ const wx = ox + 2 * W
107+ const t = (wx - chosen[0]) / (R[0] - chosen[0])
108+ const hitY = chosen[1] + t * (R[1] - chosen[1])
109+ return (
110+ <Figure
111+ width={716}
112+ height={262}
113+ caption="Image sources, in plan. The real room is in the middle, with its source (orange) and listener (white). Each neighbouring room is its mirror image in a wall, with a mirror-image source in it. The straight line from the image on the right to the listener (dashed) crosses the shared wall exactly where the real reflected path (solid) bounces off it, and has the same length; so the reflection arrives when, and as strongly as, sound from the image would, times the wall's reflection factor."
114+ >
115+ {rooms}
116+ {imgs}
117+ <line x1={chosen[0]} y1={chosen[1]} x2={R[0]} y2={R[1]} stroke={method.boundary} strokeWidth={1.4} strokeDasharray="5 4" />
118+ <path d={`M${S[0]} ${S[1]}L${wx} ${hitY}L${R[0]} ${R[1]}`} fill="none" stroke={method.boundary} strokeWidth={2} />
119+ <line x1={S[0]} y1={S[1]} x2={R[0]} y2={R[1]} stroke={faded(ink.node, 0.4)} strokeWidth={1.4} />
120+ <Node x={R[0]} y={R[1]} r={5} fill={ink.node} />
121+ <Label x={S[0] - 8} y={S[1] + 14} size={11} fill={method.waveguide} anchor="end">
122+ source
123+ </Label>
124+ <Label x={R[0] - 4} y={R[1] - 12} size={11} fill={ink.secondary} anchor="middle">
125+ listener
126+ </Label>
127+ <Label x={chosen[0] + 8} y={chosen[1] + 16} size={11} fill={faded(method.waveguide, 0.1)} anchor="start">
128+ image
129+ </Label>
130+ </Figure>
131+ )
132+}
src/figures/strings.tsxadded+418−0View file
@@ -0,0 +1,418 @@
1+// Figures for the introduction and Part I: the three settings, d'Alembert's
2+// travelling halves, folding, the leapfrog stencil, the dispersion curves and
3+// the delay loop. Every curve that looks computed is computed, here, from
4+// the same closed forms the chapters use.
5+import type { ReactElement } from 'react'
6+import { Arrowheads, Figure, Label, Node, arrow } from './svg.tsx'
7+import { faded, familyColor, ink, method } from '../plot/palette.ts'
8+
9+const pts = (xy: [number, number][]) => xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join('')
10+
11+/** The three settings of the tutorial, with the boundary each one has. */
12+export function Settings() {
13+ // a plucked string
14+ const s0 = 40
15+ const s1 = 210
16+ const sy = 110
17+ const apex = s0 + 0.3 * (s1 - s0)
18+ // a square membrane, drawn with the sign pattern of mode (2, 1)
19+ const q0 = 282
20+ const qs = 132
21+ const qy = 44
22+ const cells: ReactElement[] = []
23+ const n = 12
24+ for (let i = 0; i < n; i++) {
25+ for (let j = 0; j < n; j++) {
26+ const x = (j + 0.5) / n
27+ const y = (i + 0.5) / n
28+ const v = Math.sin(2 * Math.PI * x) * Math.sin(Math.PI * y)
29+ const c = v > 0 ? method.boundary : method.fd2
30+ cells.push(
31+ <rect
32+ key={`${i}-${j}`}
33+ x={q0 + (j * qs) / n}
34+ y={qy + (i * qs) / n}
35+ width={qs / n + 0.4}
36+ height={qs / n + 0.4}
37+ fill={faded(c, 1 - Math.min(1, Math.abs(v)) * 0.75)}
38+ />,
39+ )
40+ }
41+ }
42+ // a room in oblique projection
43+ const r0 = 500
44+ const ry = 72
45+ const rw = 150
46+ const rh = 96
47+ const dx = 44
48+ const dy = -30
49+ const S: [number, number] = [r0 + 36, ry + 70]
50+ const R: [number, number] = [r0 + 128, ry + 52]
51+ const hit: [number, number] = [r0 + 84, ry + rh]
52+ return (
53+ <Figure
54+ width={716}
55+ height={236}
56+ caption="The three settings. A string, fixed at both ends; a drumhead, clamped at its rim, shown here in one of its modes with the two signs of the displacement in two colours; and a room, whose walls reflect sound, with the direct path from a source to a listener and one reflection from the floor."
57+ >
58+ <Arrowheads id="set" colors={[ink.muted]} />
59+ {/* string */}
60+ <line x1={s0} y1={sy - 18} x2={s0} y2={sy + 18} stroke={ink.secondary} strokeWidth={3} />
61+ <line x1={s1} y1={sy - 18} x2={s1} y2={sy + 18} stroke={ink.secondary} strokeWidth={3} />
62+ <line x1={s0} y1={sy} x2={s1} y2={sy} stroke={ink.axis} strokeWidth={1} strokeDasharray="3 3" />
63+ <path d={pts([[s0, sy], [apex, sy - 46], [s1, sy]])} fill="none" stroke={method.modal} strokeWidth={2.2} />
64+ <Label x={(s0 + s1) / 2} y={196} size={12.5} fill={ink.primary}>
65+ string
66+ </Label>
67+ <Label x={(s0 + s1) / 2} y={215} size={11.5} fill={ink.muted}>
68+ u = 0 at both ends
69+ </Label>
70+ {/* drum */}
71+ {cells}
72+ <rect x={q0} y={qy} width={qs} height={qs} fill="none" stroke={ink.secondary} strokeWidth={2.5} />
73+ <Label x={q0 + qs / 2} y={196} size={12.5} fill={ink.primary}>
74+ drum
75+ </Label>
76+ <Label x={q0 + qs / 2} y={215} size={11.5} fill={ink.muted}>
77+ u = 0 on the rim
78+ </Label>
79+ {/* room */}
80+ <g stroke={ink.secondary} strokeWidth={1.4} fill="none">
81+ <rect x={r0} y={ry} width={rw} height={rh} />
82+ <path d={`M${r0} ${ry}l${dx} ${dy}h${rw}v${rh}l${-dx} ${-dy}`} />
83+ <path d={`M${r0 + rw} ${ry}l${dx} ${dy}`} />
84+ </g>
85+ <path d={`M${r0} ${ry + rh}l${dx} ${dy}h${rw}`} fill="none" stroke={ink.axis} strokeWidth={1} strokeDasharray="3 3" />
86+ <line x1={S[0]} y1={S[1]} x2={R[0]} y2={R[1]} stroke={method.boundary} strokeWidth={1.6} />
87+ <path d={pts([S, hit, R])} fill="none" stroke={faded(method.boundary, 0.35)} strokeWidth={1.4} strokeDasharray="4 3" />
88+ <Node x={S[0]} y={S[1]} r={4.5} fill={method.waveguide} />
89+ <Node x={R[0]} y={R[1]} r={4.5} fill={ink.node} />
90+ <Label x={S[0] - 8} y={S[1] - 10} size={11} fill={method.waveguide} anchor="end">
91+ source
92+ </Label>
93+ <Label x={R[0] + 8} y={R[1] - 10} size={11} fill={ink.secondary} anchor="start">
94+ listener
95+ </Label>
96+ <Label x={r0 + (rw + dx) / 2} y={196} size={12.5} fill={ink.primary}>
97+ room
98+ </Label>
99+ <Label x={r0 + (rw + dx) / 2} y={215} size={11.5} fill={ink.muted}>
100+ ∂p/∂n = 0 on rigid walls
101+ </Label>
102+ </Figure>
103+ )
104+}
105+
106+/** d'Alembert: the plucked shape as two halves travelling apart. */
107+export function Dalembert() {
108+ const beta = 0.3
109+ const x0 = 70
110+ const x1 = 646
111+ const W = x1 - x0
112+ const phi = (x: number) => {
113+ let r = ((x % 2) + 2) % 2
114+ let sg = 1
115+ if (r > 1) {
116+ r = 2 - r
117+ sg = -1
118+ }
119+ return sg * Math.min(r / beta, (1 - r) / (1 - beta))
120+ }
121+ const rows = [0, 0.12, 0.3, 0.55, 1.0]
122+ const rh = 64
123+ const top = 18
124+ const amp = 26
125+ const curve = (f: (x: number) => number, y0: number) => {
126+ const out: [number, number][] = []
127+ for (let i = 0; i <= 200; i++) {
128+ const x = i / 200
129+ out.push([x0 + x * W, y0 - amp * f(x)])
130+ }
131+ return pts(out)
132+ }
133+ return (
134+ <Figure
135+ width={716}
136+ height={top + rows.length * rh + 24}
137+ caption="d'Alembert's solution for a string plucked at β = 0.3. The shape (solid) is the sum of two copies of the initial triangle at half height (dashed), one moving right and one moving left, each reflected inverted at the ends. After half a period (bottom row) the string is the mirror image of its starting shape, upside down."
138+ >
139+ {rows.map((ct, k) => {
140+ const y0 = top + k * rh + rh / 2
141+ return (
142+ <g key={k}>
143+ <line x1={x0} y1={y0} x2={x1} y2={y0} stroke={ink.grid} strokeWidth={1} />
144+ <line x1={x0} y1={y0 - 22} x2={x0} y2={y0 + 22} stroke={ink.axis} strokeWidth={2} />
145+ <line x1={x1} y1={y0 - 22} x2={x1} y2={y0 + 22} stroke={ink.axis} strokeWidth={2} />
146+ <path d={curve((x) => 0.5 * phi(x - ct), y0)} fill="none" stroke={method.fd2} strokeWidth={1.2} strokeDasharray="4 3" />
147+ <path d={curve((x) => 0.5 * phi(x + ct), y0)} fill="none" stroke={method.boundary} strokeWidth={1.2} strokeDasharray="4 3" />
148+ <path d={curve((x) => 0.5 * (phi(x - ct) + phi(x + ct)), y0)} fill="none" stroke={method.modal} strokeWidth={2.2} />
149+ <Label x={x0 - 12} y={y0} size={11.5} fill={ink.muted} anchor="end">
150+ {`ct = ${ct}`}
151+ </Label>
152+ </g>
153+ )
154+ })}
155+ <Label x={x0 + W / 2} y={top + rows.length * rh + 10} size={11.5} fill={ink.muted}>
156+ right-moving half dashed blue, left-moving half dashed pink, their sum solid
157+ </Label>
158+ </Figure>
159+ )
160+}
161+
162+/** Partials above the Nyquist frequency fold back below it. */
163+export function Folding() {
164+ const x0 = 50
165+ const x1 = 670
166+ const fs = 1
167+ const f0 = fs / 7.35
168+ const fmax = 1.02 * fs
169+ const sx = (f: number) => x0 + ((x1 - x0) * f) / fmax
170+ const base = 140
171+ const h = 118
172+ const parts = Array.from({ length: 7 }, (_, i) => i + 1)
173+ const amp = (n: number) => h / n
174+ const fold = (f: number) => {
175+ const r = f % fs
176+ return r > fs / 2 ? fs - r : r
177+ }
178+ const ay = base + 26
179+ return (
180+ <Figure
181+ width={716}
182+ height={262}
183+ caption="Aliasing. A note's partials (green) lie at whole multiples of its fundamental. Sampling at fs cannot tell a partial above the Nyquist frequency fs/2 from one at the same distance below it, so each partial above fs/2 is reflected about fs/2 (arcs) and heard at a frequency that is generally not a multiple of the fundamental (red)."
184+ >
185+ <line x1={x0} y1={base} x2={x1} y2={base} stroke={ink.axis} strokeWidth={1.2} />
186+ <line x1={sx(fs / 2)} y1={ay + 70} x2={sx(fs / 2)} y2={14} stroke={ink.muted} strokeWidth={1.2} strokeDasharray="4 4" />
187+ <Label x={sx(fs / 2)} y={8} size={11.5} fill={ink.muted}>
188+ fs / 2
189+ </Label>
190+ <line x1={sx(fs)} y1={base + 6} x2={sx(fs)} y2={base - 6} stroke={ink.muted} strokeWidth={1.2} />
191+ <Label x={sx(fs)} y={base + 16} size={11.5} fill={ink.muted}>
192+ fs
193+ </Label>
194+ <Label x={sx(0)} y={base + 16} size={11.5} fill={ink.muted}>
195+ 0
196+ </Label>
197+ {parts.map((n) => {
198+ const f = n * f0
199+ const above = f > fs / 2
200+ const g = fold(f)
201+ const rx = (sx(f) - sx(g)) / 2
202+ return (
203+ <g key={n}>
204+ <line
205+ x1={sx(f)}
206+ y1={base}
207+ x2={sx(f)}
208+ y2={base - amp(n)}
209+ stroke={above ? faded(method.modal, 0.55) : method.modal}
210+ strokeWidth={3}
211+ />
212+ <Label x={sx(f)} y={base + 16} size={10.5} fill={ink.muted}>
213+ {n === 1 ? 'f₀' : `${n}f₀`}
214+ </Label>
215+ {above && (
216+ <>
217+ <line x1={sx(g)} y1={base} x2={sx(g)} y2={base - amp(n)} stroke={method.alt} strokeWidth={3} />
218+ <path
219+ d={`M${sx(f)} ${ay}A${rx} ${rx * 0.28} 0 0 1 ${sx(g)} ${ay}`}
220+ fill="none"
221+ stroke={faded(method.alt, 0.25)}
222+ strokeWidth={1.3}
223+ />
224+ <line x1={sx(f)} y1={base + 22} x2={sx(f)} y2={ay} stroke={faded(method.alt, 0.25)} strokeWidth={1.3} />
225+ <line x1={sx(g)} y1={base + 4} x2={sx(g)} y2={ay} stroke={faded(method.alt, 0.25)} strokeWidth={1.3} />
226+ </>
227+ )}
228+ </g>
229+ )
230+ })}
231+ </Figure>
232+ )
233+}
234+
235+/** The leapfrog stencil in space and time, and the two domains of dependence. */
236+export function Stencil() {
237+ const gx = 60
238+ const gy = 30
239+ const ox = 90
240+ const oy = 190
241+ const node = (j: number, n: number): [number, number] => [ox + j * gx, oy - n * gy * 1.5]
242+ const lam = 0.6
243+ const P = node(4, 3)
244+ const nodes: ReactElement[] = []
245+ for (let n = 0; n <= 3; n++) {
246+ for (let j = 0; j <= 8; j++) {
247+ const [x, y] = node(j, n)
248+ nodes.push(<Node key={`${j}-${n}`} x={x} y={y} r={2.4} fill={ink.axis} />)
249+ }
250+ }
251+ const used: [number, number, string][] = [
252+ [3, 2, method.fd2],
253+ [4, 2, method.fd2],
254+ [5, 2, method.fd2],
255+ [4, 1, method.fd2],
256+ ]
257+ const cone = (w: number) => pts([node(4 - 3 * w, 0), P, node(4 + 3 * w, 0)])
258+ return (
259+ <Figure
260+ width={716}
261+ height={236}
262+ caption="The leapfrog stencil. The new value at the top (orange) is computed from three values at the previous step and one at the step before (blue). Information therefore moves at most one node per step, which bounds the numerical domain of dependence (grey); the physical one (dashed) spreads at c Δt per step, λ nodes. The scheme is stable only if the numerical domain contains the physical one, λ ≤ 1."
263+ >
264+ <path d={`${cone(1)}Z`} fill={faded(ink.muted, 0.85)} stroke={ink.muted} strokeWidth={1} />
265+ <path d={cone(lam)} fill="none" stroke={method.modal} strokeWidth={1.6} strokeDasharray="5 4" />
266+ {nodes}
267+ {used.map(([j, n, c]) => {
268+ const [x, y] = node(j, n)
269+ return (
270+ <g key={`${j}-${n}`}>
271+ <line x1={x} y1={y} x2={P[0]} y2={P[1]} stroke={faded(method.fd2, 0.4)} strokeWidth={1.3} />
272+ <Node x={x} y={y} r={5} fill={c} />
273+ </g>
274+ )
275+ })}
276+ <Node x={P[0]} y={P[1]} r={6} fill={method.fd4} />
277+ <Label x={P[0] + 10} y={P[1] - 14} size={11.5} fill={method.fd4} anchor="start">
278+ u(j, n+1)
279+ </Label>
280+ {[0, 1, 2, 3].map((n) => (
281+ <Label key={n} x={ox - 16} y={node(0, n)[1]} size={11} fill={ink.muted} anchor="end">
282+ {n === 3 ? 'n + 1' : n === 2 ? 'n' : n === 1 ? 'n − 1' : 'n − 2'}
283+ </Label>
284+ ))}
285+ <Label x={ox + 4 * gx} y={oy + 20} size={11} fill={ink.muted}>
286+ x, one node every h
287+ </Label>
288+ <Label x={596} y={70} size={11.5} fill={ink.secondary} anchor="start">
289+ numerical:
290+ </Label>
291+ <Label x={596} y={86} size={11.5} fill={ink.secondary} anchor="start">
292+ 1 node per step
293+ </Label>
294+ <Label x={596} y={116} size={11.5} fill={method.modal} anchor="start">
295+ physical:
296+ </Label>
297+ <Label x={596} y={132} size={11.5} fill={method.modal} anchor="start">
298+ λ nodes per step
299+ </Label>
300+ </Figure>
301+ )
302+}
303+
304+/** Phase speed against wavenumber for several Courant numbers. */
305+export function Dispersion() {
306+ const x0 = 70
307+ const x1 = 560
308+ const y0 = 196
309+ const y1 = 24
310+ const vmin = 0.55
311+ const sx = (t: number) => x0 + (x1 - x0) * t
312+ const sy = (v: number) => y0 - ((y0 - y1) * (v - vmin)) / (1 - vmin)
313+ const lams = [1, 0.75, 0.5, 0.25]
314+ const curve = (lam: number) => {
315+ const out: [number, number][] = []
316+ for (let i = 1; i <= 200; i++) {
317+ const kh = (Math.PI * i) / 200
318+ const r = (2 * Math.asin(lam * Math.sin(kh / 2))) / (lam * kh)
319+ out.push([sx(i / 200), sy(r)])
320+ }
321+ return pts(out)
322+ }
323+ const ticks = [0.55, 0.7, 0.85, 1]
324+ const ppw = [20, 10, 5, 4, 3, 2]
325+ return (
326+ <Figure
327+ width={716}
328+ height={250}
329+ caption="The leapfrog dispersion relation, as the speed of each wave on the grid relative to the true speed, against its wavenumber. At λ = 1 every wave travels at exactly the right speed; below it, short waves travel slowly, and at two points per wavelength (right edge) they travel at 2 arcsin(λ) / (λπ) of the right speed."
330+ >
331+ <line x1={x0} y1={y0} x2={x1} y2={y0} stroke={ink.axis} />
332+ <line x1={x0} y1={y0} x2={x0} y2={y1 - 6} stroke={ink.axis} />
333+ {ticks.map((v) => (
334+ <g key={v}>
335+ <line x1={x0} x2={x1} y1={sy(v)} y2={sy(v)} stroke={ink.grid} />
336+ <Label x={x0 - 8} y={sy(v)} size={11} fill={ink.muted} anchor="end">
337+ {v.toFixed(2)}
338+ </Label>
339+ </g>
340+ ))}
341+ {ppw.map((P) => (
342+ <g key={P}>
343+ <line x1={sx(2 / P)} x2={sx(2 / P)} y1={y0} y2={y0 + 5} stroke={ink.axis} />
344+ <Label x={sx(2 / P)} y={y0 + 16} size={11} fill={ink.muted}>
345+ {P}
346+ </Label>
347+ </g>
348+ ))}
349+ <Label x={x1} y={y0 + 34} size={11} fill={ink.muted} anchor="end">
350+ points per wavelength
351+ </Label>
352+ <Label x={x0 - 44} y={y1 - 12} size={11} fill={ink.muted} anchor="start">
353+ speed on the grid / true speed
354+ </Label>
355+ {lams.map((l, k) => (
356+ <g key={l}>
357+ <path d={curve(l)} fill="none" stroke={familyColor(k, lams.length)} strokeWidth={2} />
358+ <Label
359+ x={x1 + 10}
360+ y={sy((2 * Math.asin(l)) / (l * Math.PI)) + [0, -4, 0, 9][k]}
361+ size={11.5}
362+ fill={familyColor(k, lams.length)}
363+ anchor="start"
364+ >
365+ {`λ = ${l}`}
366+ </Label>
367+ </g>
368+ ))}
369+ </Figure>
370+ )
371+}
372+
373+/** The Karplus-Strong loop as a block diagram. */
374+export function Loop() {
375+ const y = 90
376+ const box = (x: number, w: number, label: string, sub: string, c: string) => (
377+ <g>
378+ <rect x={x} y={y - 24} width={w} height={48} rx={6} fill={faded(c, 0.82)} stroke={c} strokeWidth={1.4} />
379+ <Label x={x + w / 2} y={y - 6} size={12.5} fill={ink.primary}>
380+ {label}
381+ </Label>
382+ <Label x={x + w / 2} y={y + 11} size={10.5} fill={ink.muted}>
383+ {sub}
384+ </Label>
385+ </g>
386+ )
387+ return (
388+ <Figure
389+ width={716}
390+ height={200}
391+ caption="The Karplus–Strong string. A delay of P samples, a two-point average that damps each partial a little on every trip and delays the loop by half a sample, and a fractional delay that makes up the length the whole samples cannot. The output can be taken anywhere in the loop; the pitch is fs divided by the total delay."
392+ >
393+ <Arrowheads id="ks" colors={[ink.secondary]} />
394+ <circle cx={70} cy={y} r={15} fill="none" stroke={ink.secondary} strokeWidth={1.4} />
395+ <Label x={70} y={y + 1} size={15} fill={ink.secondary}>
396+ +
397+ </Label>
398+ <Label x={70} y={y - 40} size={11} fill={ink.muted}>
399+ pluck
400+ </Label>
401+ <line x1={70} y1={y - 32} x2={70} y2={y - 17} stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
402+ <line x1={85} y1={y} x2={122} y2={y} stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
403+ {box(124, 150, 'delay z⁻ᴾ', 'P whole samples', method.waveguide)}
404+ <line x1={274} y1={y} x2={312} y2={y} stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
405+ {box(314, 160, '½ (1 + z⁻¹)', 'loss, and ½ sample', method.fd2)}
406+ <line x1={474} y1={y} x2={512} y2={y} stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
407+ {box(514, 140, 'fractional delay', 'η of a sample', method.boundary)}
408+ <path d={`M654 ${y}H690V${y + 62}H70V${y + 17}`} fill="none" stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
409+ <line x1={690} y1={y} x2={690} y2={y - 42} stroke={ink.secondary} strokeWidth={1.3} markerEnd={arrow('ks', 0)} />
410+ <Label x={690} y={y - 52} size={11} fill={ink.muted}>
411+ out
412+ </Label>
413+ <Label x={380} y={y + 76} size={11} fill={ink.muted}>
414+ total delay P + ½ + η = fs / f₀
415+ </Label>
416+ </Figure>
417+ )
418+}
src/figures/svg.tsxadded+113−0View file
@@ -0,0 +1,113 @@
1+// Static figures: hand-drawn SVG, no data and no session behind them.
2+//
3+// A lab shows what a method does once the reader presses run; these show what
4+// it *is*, on first sight and in print. They are deliberately plain: the same
5+// palette as the plots, unicode rather than KaTeX for the few symbols that
6+// appear, and one idea per picture.
7+import type { ReactNode } from 'react'
8+import { ink } from '../plot/palette.ts'
9+
10+export const SANS = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif'
11+export const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
12+
13+/** Wrapper: a captioned, width-filling SVG with a fixed aspect ratio. */
14+export function Figure({
15+ width,
16+ height,
17+ caption,
18+ maxWidth,
19+ children,
20+}: {
21+ width: number
22+ height: number
23+ caption?: string
24+ /** in px; the figure never grows past this even in a wide column */
25+ maxWidth?: number
26+ children: ReactNode
27+}) {
28+ return (
29+ <figure className="static-figure" style={maxWidth ? { maxWidth } : undefined}>
30+ <svg
31+ viewBox={`0 0 ${width} ${height}`}
32+ width="100%"
33+ preserveAspectRatio="xMidYMid meet"
34+ role="img"
35+ aria-label={caption ?? 'diagram'}
36+ style={{ display: 'block', overflow: 'visible' }}
37+ >
38+ {children}
39+ </svg>
40+ {caption ? <figcaption>{caption}</figcaption> : null}
41+ </figure>
42+ )
43+}
44+
45+/** Arrowhead markers, one set per figure so the ids never collide. */
46+export function Arrowheads({ id, colors }: { id: string; colors: string[] }) {
47+ return (
48+ <defs>
49+ {colors.map((c, i) => (
50+ <marker
51+ key={i}
52+ id={`${id}-ah-${i}`}
53+ viewBox="0 0 8 8"
54+ refX="7"
55+ refY="4"
56+ markerWidth="6"
57+ markerHeight="6"
58+ orient="auto-start-reverse"
59+ >
60+ <path d="M 0 0.7 L 8 4 L 0 7.3 z" fill={c} />
61+ </marker>
62+ ))}
63+ </defs>
64+ )
65+}
66+
67+export const arrow = (id: string, i: number) => `url(#${id}-ah-${i})`
68+
69+/** A text label. `anchor` and `baseline` are the usual SVG values. */
70+export function Label({
71+ x,
72+ y,
73+ children,
74+ fill = ink.secondary,
75+ size = 12,
76+ anchor = 'middle',
77+ baseline = 'middle',
78+ italic = false,
79+ mono = false,
80+ weight,
81+}: {
82+ x: number
83+ y: number
84+ children: ReactNode
85+ fill?: string
86+ size?: number
87+ anchor?: 'start' | 'middle' | 'end'
88+ baseline?: 'middle' | 'hanging' | 'auto' | 'central'
89+ italic?: boolean
90+ mono?: boolean
91+ weight?: number
92+}) {
93+ return (
94+ <text
95+ x={x}
96+ y={y}
97+ fill={fill}
98+ fontSize={size}
99+ fontFamily={mono ? MONO : SANS}
100+ fontStyle={italic ? 'italic' : undefined}
101+ fontWeight={weight}
102+ textAnchor={anchor}
103+ dominantBaseline={baseline}
104+ >
105+ {children}
106+ </text>
107+ )
108+}
109+
110+/** A grid node. */
111+export function Node({ x, y, r = 3.2, fill = ink.node, stroke }: { x: number; y: number; r?: number; fill?: string; stroke?: string }) {
112+ return <circle cx={x} cy={y} r={r} fill={fill} stroke={stroke} strokeWidth={stroke ? 1.2 : undefined} />
113+}
src/index.cssadded+625−0View file
@@ -0,0 +1,625 @@
1+:root {
2+ --surface: #1a1a19;
3+ --surface-2: #201f1e;
4+ --surface-3: #262523;
5+ --line: #3c3c38;
6+ --line-soft: #2b2a28;
7+ --text: #e8e6df;
8+ --text-2: #c3c2b7;
9+ --muted: #8a8880;
10+ --accent: #3987e5;
11+ --warn: #c98500;
12+ --bad: #e66767;
13+ color-scheme: dark;
14+}
15+
16+* {
17+ box-sizing: border-box;
18+}
19+
20+html,
21+body {
22+ margin: 0;
23+ padding: 0;
24+ background: var(--surface);
25+ color: var(--text);
26+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
27+ font-size: 15px;
28+ line-height: 1.6;
29+}
30+
31+a {
32+ color: var(--accent);
33+}
34+
35+code,
36+pre,
37+.mono {
38+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
39+}
40+
41+.app {
42+ display: flex;
43+ align-items: flex-start;
44+ min-height: 100vh;
45+}
46+
47+/* ---------- sidebar ---------- */
48+
49+.sidebar {
50+ position: sticky;
51+ top: 0;
52+ flex: 0 0 250px;
53+ height: 100vh;
54+ overflow-y: auto;
55+ padding: 20px 14px 24px 20px;
56+ border-right: 1px solid var(--line-soft);
57+ background: var(--surface-2);
58+}
59+
60+.brand-title {
61+ font-size: 16px;
62+ font-weight: 600;
63+ letter-spacing: 0.01em;
64+}
65+
66+.brand-sub {
67+ font-size: 12px;
68+ color: var(--muted);
69+ margin-bottom: 18px;
70+}
71+
72+.nav-part-title {
73+ font-size: 11px;
74+ text-transform: uppercase;
75+ letter-spacing: 0.08em;
76+ color: var(--muted);
77+ margin: 16px 0 4px;
78+}
79+
80+.sidebar ol {
81+ list-style: none;
82+ margin: 0;
83+ padding: 0;
84+}
85+
86+.nav-item {
87+ display: flex;
88+ gap: 8px;
89+ align-items: baseline;
90+ width: 100%;
91+ text-align: left;
92+ background: none;
93+ border: none;
94+ color: var(--text-2);
95+ padding: 4px 6px;
96+ border-radius: 5px;
97+ font: inherit;
98+ font-size: 13.5px;
99+ line-height: 1.35;
100+ cursor: pointer;
101+}
102+
103+.nav-item:hover {
104+ background: var(--surface-3);
105+ color: var(--text);
106+}
107+
108+.nav-item.active {
109+ background: var(--surface-3);
110+ color: var(--text);
111+ box-shadow: inset 2px 0 0 var(--accent);
112+}
113+
114+.nav-num {
115+ display: inline-block;
116+ min-width: 14px;
117+ color: var(--muted);
118+ font-variant-numeric: tabular-nums;
119+ font-size: 12px;
120+}
121+
122+.nav-foot {
123+ margin-top: 26px;
124+ font-size: 11.5px;
125+ line-height: 1.5;
126+ color: var(--muted);
127+}
128+
129+/* ---------- main column ---------- */
130+
131+.main {
132+ flex: 1 1 auto;
133+ min-width: 0;
134+ padding: 28px 32px 80px;
135+}
136+
137+.chapter {
138+ max-width: 860px;
139+ margin: 0 auto;
140+}
141+
142+.chapter-part {
143+ font-size: 11px;
144+ text-transform: uppercase;
145+ letter-spacing: 0.08em;
146+ color: var(--muted);
147+}
148+
149+.chapter-head h2 {
150+ margin: 4px 0 6px;
151+ font-size: 25px;
152+ font-weight: 600;
153+ letter-spacing: -0.01em;
154+}
155+
156+.chapter-blurb {
157+ margin: 0 0 8px;
158+ color: var(--text-2);
159+}
160+
161+.prose {
162+ max-width: 720px;
163+}
164+
165+.prose h3 {
166+ font-size: 17px;
167+ margin: 26px 0 6px;
168+}
169+
170+.prose p {
171+ margin: 12px 0;
172+}
173+
174+.prose ul,
175+.prose ol {
176+ padding-left: 22px;
177+}
178+
179+.prose li {
180+ margin: 5px 0;
181+}
182+
183+.prose code {
184+ background: var(--surface-3);
185+ padding: 1px 4px;
186+ border-radius: 3px;
187+ font-size: 13px;
188+}
189+
190+.prose blockquote {
191+ margin: 14px 0;
192+ padding: 2px 14px;
193+ border-left: 2px solid var(--line);
194+ color: var(--text-2);
195+}
196+
197+.prose table {
198+ border-collapse: collapse;
199+ font-size: 13.5px;
200+ margin: 12px 0;
201+}
202+
203+.prose th,
204+.prose td {
205+ border-bottom: 1px solid var(--line-soft);
206+ padding: 4px 12px 4px 0;
207+ text-align: left;
208+ font-variant-numeric: tabular-nums;
209+}
210+
211+.katex-display {
212+ overflow-x: auto;
213+ overflow-y: hidden;
214+ padding: 2px 0;
215+}
216+
217+/* ---------- labs ---------- */
218+
219+.lab {
220+ margin: 24px 0 30px;
221+ padding: 14px 16px 16px;
222+ border: 1px solid var(--line-soft);
223+ border-radius: 8px;
224+ background: var(--surface-2);
225+}
226+
227+.lab-head h3 {
228+ margin: 0;
229+ font-size: 15px;
230+ font-weight: 600;
231+}
232+
233+.lab-caption {
234+ margin: 2px 0 0;
235+ font-size: 13px;
236+ color: var(--muted);
237+}
238+
239+.controls {
240+ display: flex;
241+ flex-wrap: wrap;
242+ gap: 10px 20px;
243+ margin: 12px 0 4px;
244+}
245+
246+.control {
247+ display: flex;
248+ flex-direction: column;
249+ gap: 2px;
250+ font-size: 12px;
251+}
252+
253+.control-toggle {
254+ flex-direction: row;
255+ align-items: center;
256+ gap: 6px;
257+}
258+
259+.control-label {
260+ color: var(--muted);
261+}
262+
263+.control-value {
264+ color: var(--text);
265+ font-variant-numeric: tabular-nums;
266+}
267+
268+.control input[type='range'] {
269+ width: 150px;
270+ accent-color: var(--accent);
271+}
272+
273+.control select {
274+ background: var(--surface-3);
275+ color: var(--text);
276+ border: 1px solid var(--line);
277+ border-radius: 4px;
278+ padding: 2px 6px;
279+ font: inherit;
280+ font-size: 12.5px;
281+}
282+
283+.lab-bar {
284+ display: flex;
285+ align-items: center;
286+ gap: 14px;
287+ margin: 10px 0 6px;
288+ flex-wrap: wrap;
289+}
290+
291+.lab-bar-gap {
292+ flex: 1 1 auto;
293+}
294+
295+button {
296+ font: inherit;
297+ font-size: 12.5px;
298+ padding: 3px 12px;
299+ border-radius: 5px;
300+ border: 1px solid var(--line);
301+ background: var(--surface-3);
302+ color: var(--text);
303+ cursor: pointer;
304+}
305+
306+button:hover:not(:disabled) {
307+ border-color: var(--muted);
308+}
309+
310+button:disabled {
311+ opacity: 0.5;
312+ cursor: default;
313+}
314+
315+button.primary {
316+ background: #1c5cab;
317+ border-color: #256abf;
318+}
319+
320+button.ghost {
321+ background: none;
322+ border-color: transparent;
323+ color: var(--muted);
324+}
325+
326+button.ghost:hover {
327+ color: var(--text);
328+}
329+
330+.stat {
331+ display: flex;
332+ flex-direction: column;
333+ line-height: 1.25;
334+}
335+
336+.stat-label {
337+ font-size: 10.5px;
338+ text-transform: uppercase;
339+ letter-spacing: 0.06em;
340+ color: var(--muted);
341+}
342+
343+.stat-value {
344+ font-size: 13px;
345+ font-variant-numeric: tabular-nums;
346+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
347+}
348+
349+.warn {
350+ color: var(--warn);
351+ font-size: 12px;
352+}
353+
354+.lab-error {
355+ color: var(--bad);
356+ background: #2a1d1d;
357+ border: 1px solid #4a2b2b;
358+ border-radius: 6px;
359+ padding: 8px 10px;
360+ font-size: 12.5px;
361+ white-space: pre-wrap;
362+ overflow-x: auto;
363+}
364+
365+.lab-log {
366+ background: var(--surface-3);
367+ border-radius: 6px;
368+ padding: 8px 10px;
369+ font-size: 12px;
370+ max-height: 260px;
371+ overflow: auto;
372+ white-space: pre;
373+ color: var(--text-2);
374+}
375+
376+.lab-placeholder {
377+ color: var(--muted);
378+ font-size: 13px;
379+ padding: 26px 0;
380+ text-align: center;
381+}
382+
383+.lab-figures {
384+ display: flex;
385+ flex-direction: column;
386+ gap: 6px;
387+}
388+
389+.figure-row {
390+ display: flex;
391+ flex-wrap: wrap;
392+ gap: 10px;
393+}
394+
395+.figure-row > * {
396+ flex: 1 1 320px;
397+ min-width: 300px;
398+}
399+
400+.lab-editor {
401+ margin-top: 10px;
402+ border: 1px solid var(--line-soft);
403+ border-radius: 6px;
404+ overflow: hidden;
405+}
406+
407+.lab-editor-bar {
408+ display: flex;
409+ align-items: center;
410+ gap: 10px;
411+ padding: 4px 8px;
412+ font-size: 12px;
413+ color: var(--muted);
414+ background: var(--surface-3);
415+}
416+
417+.editor-host .cm-editor {
418+ max-height: 460px;
419+ font-size: 12.5px;
420+}
421+
422+/* ---------- static figures ---------- */
423+
424+.static-figure {
425+ margin: 22px 0;
426+ max-width: 720px;
427+}
428+
429+.static-figure figcaption {
430+ margin-top: 8px;
431+ font-size: 12.5px;
432+ line-height: 1.5;
433+ color: var(--muted);
434+}
435+
436+/* ---------- plots ---------- */
437+
438+.plot-host {
439+ width: 100%;
440+}
441+
442+.plot-title {
443+ font-size: 12px;
444+ color: var(--text-2);
445+ margin: 6px 0 -4px;
446+}
447+
448+.legend {
449+ display: flex;
450+ flex-wrap: wrap;
451+ gap: 4px 16px;
452+ font-size: 12px;
453+ color: var(--text-2);
454+ margin: -4px 0 6px 44px;
455+}
456+
457+.legend-item {
458+ display: inline-flex;
459+ align-items: center;
460+ gap: 5px;
461+}
462+
463+.legend-item.dim {
464+ opacity: 0.4;
465+}
466+
467+.legend-value {
468+ color: var(--muted);
469+ font-variant-numeric: tabular-nums;
470+}
471+
472+/* ---------- tables of numbers ---------- */
473+
474+.numtable {
475+ border-collapse: collapse;
476+ font-size: 12.5px;
477+ font-variant-numeric: tabular-nums;
478+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
479+ margin: 4px 0 2px;
480+}
481+
482+.numtable th {
483+ text-align: right;
484+ color: var(--muted);
485+ font-weight: 500;
486+ padding: 2px 10px 2px 0;
487+ border-bottom: 1px solid var(--line-soft);
488+}
489+
490+.numtable td {
491+ text-align: right;
492+ padding: 1px 10px 1px 0;
493+}
494+
495+/* ---------- library ---------- */
496+
497+.lib-file {
498+ border-bottom: 1px solid var(--line-soft);
499+}
500+
501+.lib-name {
502+ background: none;
503+ border: none;
504+ color: var(--text);
505+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
506+ font-size: 13px;
507+ padding: 6px 0;
508+ cursor: pointer;
509+}
510+
511+.lib-src {
512+ background: var(--surface-2);
513+ border-radius: 6px;
514+ padding: 10px 12px;
515+ font-size: 12.5px;
516+ overflow-x: auto;
517+ color: var(--text-2);
518+}
519+
520+.pager {
521+ display: flex;
522+ max-width: 860px;
523+ margin: 30px auto 0;
524+ padding-top: 14px;
525+ border-top: 1px solid var(--line-soft);
526+}
527+
528+/* ---------- the standing disclaimer ---------- */
529+
530+.ai-disclaimer {
531+ position: fixed;
532+ right: 14px;
533+ bottom: 14px;
534+ z-index: 50;
535+ max-width: 340px;
536+ padding: 6px 11px;
537+ border: 1px solid var(--bad);
538+ border-radius: 6px;
539+ background: color-mix(in srgb, var(--bad) 16%, var(--surface));
540+ box-shadow: 0 2px 10px rgb(0 0 0 / 45%);
541+ color: var(--bad);
542+ font-size: 12px;
543+ line-height: 1.45;
544+ /* it is a standing notice, not a control: never intercept a click meant for
545+ a slider or an editor underneath it */
546+ pointer-events: none;
547+}
548+
549+.ai-disclaimer strong {
550+ font-weight: 650;
551+}
552+
553+@media (max-width: 900px) {
554+ .app {
555+ flex-direction: column;
556+ }
557+
558+ .ai-disclaimer {
559+ left: 14px;
560+ max-width: none;
561+ }
562+
563+ .sidebar {
564+ position: static;
565+ width: 100%;
566+ height: auto;
567+ flex: none;
568+ border-right: none;
569+ border-bottom: 1px solid var(--line-soft);
570+ }
571+
572+ .main {
573+ padding: 18px 14px 60px;
574+ }
575+}
576+
577+/* ---------- listening ---------- */
578+
579+.listen {
580+ display: flex;
581+ flex-wrap: wrap;
582+ align-items: center;
583+ gap: 8px;
584+ margin: 4px 0 14px;
585+ padding: 8px 10px;
586+ border: 1px solid var(--line-soft);
587+ border-radius: 6px;
588+ background: var(--surface-2);
589+}
590+
591+.listen-title {
592+ font-size: 11px;
593+ text-transform: uppercase;
594+ letter-spacing: 0.08em;
595+ color: var(--muted);
596+ margin-right: 4px;
597+}
598+
599+.listen-btn {
600+ display: inline-flex;
601+ align-items: center;
602+ gap: 7px;
603+}
604+
605+.listen-btn.on {
606+ border-color: var(--accent);
607+ color: var(--text);
608+}
609+
610+.listen-icon {
611+ font-size: 10px;
612+ color: var(--accent);
613+ width: 10px;
614+}
615+
616+.listen-dur {
617+ color: var(--muted);
618+ font-size: 11.5px;
619+ font-variant-numeric: tabular-nums;
620+}
621+
622+.listen-note {
623+ font-size: 12.5px;
624+ color: var(--muted);
625+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import App from './App.tsx'
4+import './index.css'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/matlab/wv_audio.madded+56−0View file
@@ -0,0 +1,56 @@
1+function s = wv_audio(x, fs, scale)
2+% WV_AUDIO A signal packed for the page's audio player.
3+%
4+% The samples are divided by `scale` and sent as 16-bit integers, which keeps
5+% the JSON about a third the size of full-precision numbers and loses nothing
6+% that a loudspeaker could reproduce. Pass the same scale to every clip in a
7+% comparison, so that "louder" on the page means louder in the computation;
8+% with no scale the clip is normalised to its own peak. A short fade at each
9+% end keeps the player from clicking; it is applied to every clip alike.
10+%
11+% Browsers are only obliged to play sample rates from 8 kHz up, and a coarse
12+% simulation's own rate can be lower, so such a clip is first raised to a
13+% whole multiple of its rate by band-limited interpolation (zero-padding its
14+% spectrum), which adds nothing and changes nothing below its Nyquist
15+% frequency.
16+x = x(:)';
17+if fs < 8000
18+ k = ceil(8000 / fs);
19+ x = wv_upsample(x, k);
20+ fs = fs * k;
21+end
22+if nargin < 3 || scale <= 0
23+ scale = max(abs(x));
24+ if scale == 0
25+ scale = 1;
26+ end
27+end
28+n = numel(x);
29+nf = min(floor(n / 4), round(0.004 * fs));
30+if nf > 1
31+ r = 0.5 - 0.5 * cos(pi * (0:nf-1) / nf);
32+ x(1:nf) = x(1:nf) .* r;
33+ x(n-nf+1:n) = x(n-nf+1:n) .* fliplr(r);
34+end
35+y = round(29000 * x / scale);
36+y(y > 32767) = 32767;
37+y(y < -32767) = -32767;
38+s = struct();
39+s.fs = fs;
40+s.pcm = int16(y);
41+end
42+
43+function y = wv_upsample(x, k)
44+n = numel(x);
45+X = fft(x);
46+Y = zeros(1, k * n);
47+h = floor(n / 2);
48+Y(1:h + 1) = X(1:h + 1);
49+Y(k * n - (n - h - 2):k * n) = X(h + 2:n);
50+if mod(n, 2) == 0
51+ % the Nyquist bin is shared between the two halves
52+ Y(h + 1) = X(h + 1) / 2;
53+ Y(k * n - h + 1) = X(h + 1) / 2;
54+end
55+y = real(ifft(Y)) * k;
56+end
src/matlab/wv_bjzeros.madded+27−0View file
@@ -0,0 +1,27 @@
1+function z = wv_bjzeros(m, k)
2+% WV_BJZEROS The first k positive zeros of the Bessel function J_m.
3+%
4+% Bracketed by a scan for sign changes and polished by Newton's method with
5+% J_m' = (J_{m-1} - J_{m+1})/2, so each is good to a unit or two in the last
6+% place. These are the exact frequencies of a circular drum, up to a factor
7+% c/(2 pi R), and nothing about them is approximate.
8+z = zeros(1, k);
9+x = max(m, 0.5) : 0.05 : (m + 4 * k + 10);
10+s = besselj(m, x);
11+found = 0;
12+for i = 1:numel(x) - 1
13+ if s(i) * s(i+1) < 0 && found < k
14+ r = x(i) - s(i) * (x(i+1) - x(i)) / (s(i+1) - s(i));
15+ for it = 1:20
16+ d = 0.5 * (besselj(m - 1, r) - besselj(m + 1, r));
17+ st = besselj(m, r) / d;
18+ r = r - st;
19+ if abs(st) < 1e-15 * r
20+ break;
21+ end
22+ end
23+ found = found + 1;
24+ z(found) = r;
25+ end
26+end
27+end
src/matlab/wv_cents.madded+8−0View file
@@ -0,0 +1,8 @@
1+function c = wv_cents(f, fref)
2+% WV_CENTS The interval from fref up to f, in cents.
3+%
4+% A cent is a hundredth of an equal-tempered semitone, so an octave is 1200
5+% cents and a relative frequency error e is about 1731 e cents when e is
6+% small. Negative means flat.
7+c = 1200 * log2(f ./ fref);
8+end
src/matlab/wv_fftconv.madded+14−0View file
@@ -0,0 +1,14 @@
1+function y = wv_fftconv(a, b)
2+% WV_FFTCONV Linear convolution of two real vectors, by FFT.
3+%
4+% The direct sum costs the product of the two lengths, which for a two-second
5+% sound and a one-second impulse response is about 4e9 operations; the FFT
6+% costs a few times n log n. The result is the full convolution, of length
7+% numel(a) + numel(b) - 1, as a column.
8+a = a(:);
9+b = b(:);
10+n = numel(a) + numel(b) - 1;
11+nfft = 2 ^ nextpow2(n);
12+y = real(ifft(fft(a, nfft) .* fft(b, nfft)));
13+y = y(1:n);
14+end
src/matlab/wv_note.madded+4−0View file
@@ -0,0 +1,4 @@
1+function f = wv_note(midi)
2+% WV_NOTE Equal-tempered frequency of a MIDI note number, with A4 = 69 = 440 Hz.
3+f = 440 * 2 .^ ((midi - 69) / 12);
4+end
src/matlab/wv_orders.madded+14−0View file
@@ -0,0 +1,14 @@
1+function q = wv_orders(e, n)
2+% WV_ORDERS Observed convergence order between successive resolutions.
3+%
4+% q(k) = log(e(k-1)/e(k)) / log(n(k)/n(k-1)), and NaN wherever the ratio is not
5+% a positive finite number. A second-order method should print numbers near
6+% 2 once it is resolved.
7+q = NaN(size(e));
8+for k = 2:numel(e)
9+ r = e(k-1) / e(k);
10+ if isfinite(r) && r > 0
11+ q(k) = log(r) / log(n(k) / n(k-1));
12+ end
13+end
14+end
src/matlab/wv_params.madded+4−0View file
@@ -0,0 +1,4 @@
1+function p = wv_params()
2+% WV_PARAMS The control settings for this run, as a struct.
3+p = jsondecode(fileread('params.json'));
4+end
src/matlab/wv_peakfreq.madded+44−0View file
@@ -0,0 +1,44 @@
1+function f = wv_peakfreq(a, dt, flo, fhi)
2+% WV_PEAKFREQ The frequency of the strongest partial of a between flo and fhi.
3+%
4+% This is how every frequency in the tutorial is measured, from a recorded
5+% signal and nothing else: taper the record with wv_window, find the largest
6+% peak of its zero-padded DFT inside the band, and then polish the peak with
7+% Newton's method on |X(f)|^2, using the exact derivatives of the windowed
8+% discrete-time Fourier transform. For a clean partial well separated from
9+% its neighbours the result is good to about 1e-10 relative; the method used
10+% on it never needs to be.
11+%
12+% a is a real record sampled every dt seconds; the band is in hertz.
13+a = a(:);
14+n = numel(a);
15+w = wv_window(n);
16+b = w .* (a - mean(a));
17+nfft = 2 ^ nextpow2(4 * n);
18+B = abs(fft(b, nfft));
19+df = 1 / (nfft * dt);
20+k0 = max(2, floor(flo / df) + 1);
21+k1 = min(floor(nfft / 2), ceil(fhi / df) + 1);
22+[~, i] = max(B(k0:k1));
23+f = (k0 + i - 2) * df;
24+tt = (0:n-1)' * dt;
25+for it = 1:30
26+ e = exp(-2i * pi * f * tt);
27+ X = sum(b .* e);
28+ X1 = sum(b .* e .* (-2i * pi * tt));
29+ X2 = sum(b .* e .* (-4 * pi ^ 2 * tt .^ 2));
30+ g = 2 * real(conj(X) * X1);
31+ h = 2 * real(conj(X1) * X1 + conj(X) * X2);
32+ if h >= 0
33+ break;
34+ end
35+ step = -g / h;
36+ if abs(step) > df
37+ step = sign(step) * df;
38+ end
39+ f = f + step;
40+ if abs(step) < 1e-13 * f
41+ break;
42+ end
43+end
44+end
src/matlab/wv_rows.madded+12−0View file
@@ -0,0 +1,12 @@
1+function C = wv_rows(M)
2+% WV_ROWS Matrix to a cell array of rows, for jsonencode.
3+%
4+% jsonencode([1 2 3]) is [1,2,3], not [[1,2,3]], so a matrix with a single row
5+% arrives on the JavaScript side with the wrong nesting. Encoding a cell array
6+% of rows instead always gives an array of arrays.
7+n = size(M, 1);
8+C = cell(1, n);
9+for i = 1:n
10+ C{i} = M(i, :);
11+end
12+end
src/matlab/wv_spectrum.madded+29−0View file
@@ -0,0 +1,29 @@
1+function [fr, db] = wv_spectrum(x, fs, fmax, npts)
2+% WV_SPECTRUM Magnitude spectrum of x in decibels, for plotting.
3+%
4+% Tapered with wv_window and zero-padded, then reduced to npts points up to
5+% fmax by keeping the largest value in each bin of the plot, so that narrow
6+% peaks survive the thinning. 0 dB is the largest peak.
7+x = x(:);
8+n = numel(x);
9+w = wv_window(n);
10+nfft = 2 ^ nextpow2(2 * n);
11+X = abs(fft(w .* x, nfft));
12+X = X(1:nfft/2+1);
13+X = X / max(max(X), 1e-300);
14+f = (0:nfft/2)' * fs / nfft;
15+fr = linspace(0, fmax, npts);
16+db = -200 * ones(1, npts);
17+bw = fr(2) - fr(1);
18+idx = floor(f / bw + 0.5) + 1;
19+% real() because log10 is typed as possibly complex, which would keep this
20+% loop off numbl's compiled path
21+v = real(20 * log10(max(X, 1e-300)));
22+for k = 1:numel(f)
23+ j = idx(k);
24+ if j >= 1 && j <= npts && v(k) > db(j)
25+ db(j) = v(k);
26+ end
27+end
28+db(db < -160) = -160;
29+end
src/matlab/wv_vec.madded+8−0View file
@@ -0,0 +1,8 @@
1+function c = wv_vec(v)
2+% WV_VEC Wrap a vector so that jsonencode always produces an array.
3+%
4+% jsonencode(5) is 5 and jsonencode([5]) is also 5, so a per-method quantity
5+% arrives on the JavaScript side as a bare number whenever a lab happens to
6+% show one method. A cell array of scalars always encodes as an array.
7+c = num2cell(v(:)');
8+end
src/matlab/wv_window.madded+11−0View file
@@ -0,0 +1,11 @@
1+function w = wv_window(n)
2+% WV_WINDOW A smooth taper for spectral estimates: sin(pi t)^8 on n points.
3+%
4+% The eighth power is deliberate. Its sidelobes fall off like k^-9, so a
5+% strong partial ten bins away leaks into a weak one at about 1e-9 of its
6+% amplitude, which is what lets wv_peakfreq measure a frequency to nine or
7+% ten digits from a few dozen periods. The price is a main lobe about five
8+% bins wide, which only matters when two partials are closer than that.
9+t = ((0:n-1)' + 0.5) / n;
10+w = sin(pi * t) .^ 8;
11+end
src/matlab/wv_write.madded+6−0View file
@@ -0,0 +1,6 @@
1+function wv_write(out)
2+% WV_WRITE Hand a struct back to the page as out.json.
3+f = fopen('out.json', 'w');
4+fwrite(f, jsonencode(out));
5+fclose(f);
6+end
src/plot/Curves.tsxadded+348−0View file
@@ -0,0 +1,348 @@
1+// Marks drawn inside a Plot frame: polylines, markers, reference slopes and
2+// direct end-labels. Every mark is thin (2px lines, >=8px markers) and every
3+// marker carries a shape as well as a colour.
4+import type { ReactNode } from 'react'
5+import type { Num } from '../engine/types.ts'
6+import { ink, surface } from './palette.ts'
7+import { linePath, type Frame } from './scales.ts'
8+
9+export type Shape = 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star'
10+
11+export function Line({
12+ x,
13+ y,
14+ f,
15+ color,
16+ dash,
17+ width = 2,
18+ opacity = 1,
19+}: {
20+ x: readonly number[]
21+ y: readonly Num[]
22+ f: Frame
23+ color: string
24+ dash?: string
25+ width?: number
26+ opacity?: number
27+}) {
28+ return (
29+ <path
30+ d={linePath(x, y, f)}
31+ fill="none"
32+ stroke={color}
33+ strokeWidth={width}
34+ strokeDasharray={dash}
35+ strokeLinejoin="round"
36+ strokeLinecap="round"
37+ opacity={opacity}
38+ />
39+ )
40+}
41+
42+/** One marker, centred on (cx, cy) in pixels, about 9px across. */
43+export function markerPath(shape: Shape, cx: number, cy: number, r = 4.5): string {
44+ switch (shape) {
45+ case 'square':
46+ return `M${cx - r} ${cy - r}h${2 * r}v${2 * r}h${-2 * r}z`
47+ case 'triangle':
48+ return `M${cx} ${cy - r * 1.15}L${cx + r} ${cy + r * 0.8}L${cx - r} ${cy + r * 0.8}z`
49+ case 'diamond':
50+ return `M${cx} ${cy - r * 1.2}L${cx + r * 1.2} ${cy}L${cx} ${cy + r * 1.2}L${cx - r * 1.2} ${cy}z`
51+ case 'cross':
52+ return `M${cx - r} ${cy - r}L${cx + r} ${cy + r}M${cx + r} ${cy - r}L${cx - r} ${cy + r}`
53+ case 'plus':
54+ return `M${cx - r * 1.2} ${cy}h${2.4 * r}M${cx} ${cy - r * 1.2}v${2.4 * r}`
55+ case 'star': {
56+ let d = ''
57+ for (let k = 0; k < 3; k++) {
58+ const a = (Math.PI * k) / 3
59+ d += `M${cx - r * Math.cos(a)} ${cy - r * Math.sin(a)}L${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`
60+ }
61+ return d
62+ }
63+ default:
64+ return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0z`
65+ }
66+}
67+
68+export function Markers({
69+ x,
70+ y,
71+ f,
72+ color,
73+ shape = 'circle',
74+ r = 4.5,
75+}: {
76+ x: readonly number[]
77+ y: readonly Num[]
78+ f: Frame
79+ color: string
80+ shape?: Shape
81+ r?: number
82+}) {
83+ const stroked = shape === 'cross' || shape === 'plus' || shape === 'star'
84+ const out: ReactNode[] = []
85+ for (let i = 0; i < x.length && i < y.length; i++) {
86+ const v = y[i]
87+ if (v == null || !isFinite(v) || !isFinite(x[i])) continue
88+ const px = f.sx(x[i])
89+ const py = f.sy(v)
90+ if (py < -20 || py > f.ih + 20) continue
91+ out.push(
92+ <path
93+ key={i}
94+ d={markerPath(shape, px, py, r)}
95+ fill={stroked ? 'none' : color}
96+ stroke={stroked ? color : surface}
97+ strokeWidth={stroked ? 2 : 1.5}
98+ />,
99+ )
100+ }
101+ return <g>{out}</g>
102+}
103+
104+/**
105+ * A filled band between two curves: an exact posterior's plus-or-minus two
106+ * standard deviations, a spread over repetitions. Drawn under everything
107+ * else, at low opacity, so that it reads as context rather than as a series.
108+ */
109+export function Band({
110+ x,
111+ lo,
112+ hi,
113+ f,
114+ color,
115+ opacity = 0.16,
116+}: {
117+ x: readonly number[]
118+ lo: readonly Num[]
119+ hi: readonly Num[]
120+ f: Frame
121+ color: string
122+ opacity?: number
123+}) {
124+ const up: string[] = []
125+ const down: string[] = []
126+ for (let i = 0; i < x.length; i++) {
127+ const a = lo[i]
128+ const b = hi[i]
129+ if (a == null || b == null || !isFinite(a) || !isFinite(b)) continue
130+ up.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(b).toFixed(2)}`)
131+ down.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(a).toFixed(2)}`)
132+ }
133+ if (up.length === 0) return null
134+ down.reverse()
135+ return <path d={`M${up.join('L')}L${down.join('L')}Z`} fill={color} opacity={opacity} stroke="none" />
136+}
137+
138+/** Vertical stems from a baseline: a weighted rug, a spike train. */
139+export function Stems({
140+ x,
141+ y,
142+ f,
143+ color,
144+ base = 0,
145+ width = 1,
146+ opacity = 1,
147+}: {
148+ x: readonly Num[]
149+ y: readonly Num[]
150+ f: Frame
151+ color: string
152+ base?: number
153+ width?: number
154+ opacity?: number
155+}) {
156+ let d = ''
157+ const y0 = f.sy(base)
158+ for (let i = 0; i < x.length && i < y.length; i++) {
159+ const xv = x[i]
160+ const yv = y[i]
161+ if (xv == null || yv == null || !isFinite(xv) || !isFinite(yv)) continue
162+ d += `M${f.sx(xv).toFixed(2)} ${y0.toFixed(2)}L${f.sx(xv).toFixed(2)} ${f.sy(yv).toFixed(2)}`
163+ }
164+ return <path d={d} stroke={color} strokeWidth={width} opacity={opacity} fill="none" />
165+}
166+
167+/** A step function through (edges implied by x), for a histogram. */
168+export function Steps({
169+ x,
170+ y,
171+ f,
172+ color,
173+ width = 1.8,
174+ dash,
175+ opacity = 1,
176+}: {
177+ /** bin centres, equally spaced */
178+ x: readonly number[]
179+ y: readonly Num[]
180+ f: Frame
181+ color: string
182+ width?: number
183+ dash?: string
184+ opacity?: number
185+}) {
186+ if (x.length < 2) return null
187+ const h = (x[1] - x[0]) / 2
188+ let d = ''
189+ let pen = false
190+ for (let i = 0; i < x.length; i++) {
191+ const v = y[i]
192+ if (v == null || !isFinite(v)) {
193+ pen = false
194+ continue
195+ }
196+ const yy = f.sy(v).toFixed(2)
197+ d += `${pen ? 'L' : 'M'}${f.sx(x[i] - h).toFixed(2)} ${yy}L${f.sx(x[i] + h).toFixed(2)} ${yy}`
198+ pen = true
199+ }
200+ return <path d={d} stroke={color} strokeWidth={width} strokeDasharray={dash} opacity={opacity} fill="none" />
201+}
202+
203+/**
204+ * A dashed reference line of slope p through a chosen point, for log-log
205+ * convergence plots: the eye reads "is the data parallel to this?" much better
206+ * than it reads a printed rate.
207+ */
208+export function RefSlope({
209+ f,
210+ p,
211+ through,
212+ label,
213+ span = 0.6,
214+ labelAt = 'start',
215+}: {
216+ f: Frame
217+ /** the exponent: error ~ n^p */
218+ p: number
219+ /** a point the line passes through, in data coordinates */
220+ through: [number, number]
221+ label: string
222+ /** fraction of the x range the line covers */
223+ span?: number
224+ labelAt?: 'start' | 'end'
225+}) {
226+ const [lx, hx] = f.xDomain
227+ const l = Math.log10(lx)
228+ const h = Math.log10(hx)
229+ const c = Math.log10(through[0])
230+ const x0 = Math.pow(10, Math.max(l, c - (span * (h - l)) / 2))
231+ const x1 = Math.pow(10, Math.min(h, c + (span * (h - l)) / 2))
232+ const y = (xx: number) => through[1] * Math.pow(xx / through[0], p)
233+ return (
234+ <g>
235+ <path
236+ d={`M${f.sx(x0)} ${f.sy(y(x0))}L${f.sx(x1)} ${f.sy(y(x1))}`}
237+ stroke={ink.muted}
238+ strokeWidth={1.5}
239+ strokeDasharray="5 4"
240+ fill="none"
241+ />
242+ {labelAt === 'end' ? (
243+ <text x={f.sx(x1) - 3} y={f.sy(y(x1)) - 6} fill={ink.muted} fontSize={11} textAnchor="end">
244+ {label}
245+ </text>
246+ ) : (
247+ <text x={f.sx(x0) + 3} y={f.sy(y(x0)) + 14} fill={ink.muted} fontSize={11} textAnchor="start">
248+ {label}
249+ </text>
250+ )}
251+ </g>
252+ )
253+}
254+
255+/**
256+ * A ring around the data point under the cursor, and a box of its numbers.
257+ *
258+ * Drawn as SVG rather than an HTML overlay, like everything else in this
259+ * layer, so it needs no positioning relative to the host element. The box
260+ * width is estimated from the character count, which is why the lines are kept
261+ * short; it flips to the other side of the point rather than leave the frame.
262+ */
263+export function PointTip({
264+ f,
265+ px,
266+ py,
267+ color,
268+ lines,
269+}: {
270+ f: Frame
271+ px: number
272+ py: number
273+ color: string
274+ /** first line is the series, in its own colour; the rest are the numbers */
275+ lines: string[]
276+}) {
277+ const size = 11.5
278+ const pad = 6
279+ const lh = 15
280+ const w = Math.max(...lines.map((l) => l.length)) * 6.15 + 2 * pad
281+ const h = lines.length * lh + 2 * pad - 3
282+ const right = px + 14 + w < f.iw
283+ const bx = Math.min(Math.max(right ? px + 14 : px - 14 - w, 2), Math.max(2, f.iw - w - 2))
284+ const by = Math.min(Math.max(py - h / 2, 2), Math.max(2, f.ih - h - 2))
285+ return (
286+ <g pointerEvents="none">
287+ <circle cx={px} cy={py} r={7} fill="none" stroke={color} strokeWidth={2} />
288+ <circle cx={px} cy={py} r={7} fill="none" stroke={surface} strokeWidth={1} opacity={0.7} />
289+ <rect
290+ x={bx}
291+ y={by}
292+ width={w}
293+ height={h}
294+ rx={4}
295+ fill={surface}
296+ fillOpacity={0.94}
297+ stroke={ink.axis}
298+ strokeWidth={1}
299+ />
300+ {lines.map((l, i) => (
301+ <text
302+ key={i}
303+ x={bx + pad}
304+ y={by + pad + lh * i + size}
305+ fill={i === 0 ? color : ink.secondary}
306+ fontSize={size}
307+ style={i === 0 ? undefined : { fontVariantNumeric: 'tabular-nums' }}
308+ >
309+ {l}
310+ </text>
311+ ))}
312+ </g>
313+ )
314+}
315+
316+/** A label placed at the end of a curve, so the reader need not use the legend. */
317+export function EndLabel({
318+ f,
319+ x,
320+ y,
321+ text,
322+ color,
323+ dx = 6,
324+ dy = 4,
325+ anchor = 'start',
326+}: {
327+ f: Frame
328+ x: number
329+ y: number
330+ text: string
331+ color: string
332+ dx?: number
333+ dy?: number
334+ anchor?: 'start' | 'end' | 'middle'
335+}) {
336+ return (
337+ <text
338+ x={f.sx(x) + dx}
339+ y={f.sy(y) + dy}
340+ fill={color}
341+ fontSize={11}
342+ textAnchor={anchor}
343+ style={{ paintOrder: 'stroke', stroke: surface, strokeWidth: 3 }}
344+ >
345+ {text}
346+ </text>
347+ )
348+}
src/plot/Field.tsxadded+234−0View file
@@ -0,0 +1,234 @@
1+// A scalar field on a rectangle, drawn as an image with a colour bar.
2+//
3+// The field is painted into a canvas one pixel per grid cell and then scaled to
4+// the frame, which keeps a 512-by-512 grid as cheap to draw as a 16-by-16 one.
5+// Cells outside the domain (an L-shaped region, the outside of a curve) are
6+// passed as null and left as surface.
7+import { useEffect, useRef, useState, useCallback } from 'react'
8+import type { Num } from '../engine/types.ts'
9+import { divColor, faded, ink, seqColor, surface } from './palette.ts'
10+import { formatTick, niceTicks } from './scales.ts'
11+
12+export interface FieldProps {
13+ /** z[i][j] is the value at (x_j, y_i); null is "not in the domain" */
14+ z: Num[][]
15+ xDomain: [number, number]
16+ yDomain: [number, number]
17+ height?: number
18+ /** 'seq' for a magnitude, 'div' for a signed quantity, 'log' for |value| over decades */
19+ kind?: 'seq' | 'div' | 'log'
20+ /**
21+ * Reverse the sequential ramp, so that zero is dark and large is light.
22+ * The ramp runs light-to-dark, which is right on a white page; on this
23+ * page's dark surface a field whose typical value is near zero comes out
24+ * almost entirely pale unless it is turned round.
25+ */
26+ reverse?: boolean
27+ /** upper end of the colour scale; taken from the data when omitted */
28+ zmax?: number
29+ /** how many decades the 'log' scale spans below zmax */
30+ decades?: number
31+ title?: string
32+ /** polylines in data coordinates, drawn over the field (a boundary, a mesh) */
33+ curves?: { x: number[]; y: number[]; color?: string; dash?: string }[]
34+ points?: { x: number[]; y: number[]; color?: string; r?: number }[]
35+ formatValue?: (v: number) => string
36+}
37+
38+function useWidth(): [(el: HTMLDivElement | null) => void, number] {
39+ const [width, setWidth] = useState(560)
40+ const obs = useRef<ResizeObserver | null>(null)
41+ const ref = useCallback((el: HTMLDivElement | null) => {
42+ obs.current?.disconnect()
43+ if (!el) return
44+ const ro = new ResizeObserver((entries) => {
45+ const w = entries[0]?.contentRect.width
46+ if (w && w > 0) setWidth(w)
47+ })
48+ ro.observe(el)
49+ obs.current = ro
50+ setWidth(el.clientWidth || 560)
51+ }, [])
52+ useEffect(() => () => obs.current?.disconnect(), [])
53+ return [ref, width]
54+}
55+
56+const M = { l: 44, r: 74, t: 18, b: 30 }
57+
58+export default function Field(props: FieldProps) {
59+ const { z, xDomain, yDomain, height = 340, kind = 'seq', decades = 8, title, curves, points, reverse } = props
60+ // Reversed, the ramp also fades towards the page's surface at the bottom
61+ // end, so that "nothing here" is background rather than a saturated blue.
62+ const ramp = (t: number) => {
63+ if (!reverse) return seqColor(t)
64+ const u = Math.max(0, Math.min(1, t))
65+ return faded(seqColor(1 - u), 1 - Math.pow(u, 0.55))
66+ }
67+ const [hostRef, width] = useWidth()
68+ const canvasRef = useRef<HTMLCanvasElement>(null)
69+
70+ const nrow = z.length
71+ const ncol = nrow > 0 ? z[0].length : 0
72+
73+ // colour scale limits
74+ let lo = Infinity
75+ let hi = -Infinity
76+ for (const row of z) {
77+ for (const v of row) {
78+ if (v == null || !isFinite(v)) continue
79+ if (v < lo) lo = v
80+ if (v > hi) hi = v
81+ }
82+ }
83+ if (!isFinite(lo)) {
84+ lo = 0
85+ hi = 1
86+ }
87+ const amax = props.zmax ?? Math.max(Math.abs(lo), Math.abs(hi))
88+ const logHi = props.zmax ?? (hi > 0 ? Math.log10(hi) : 0)
89+ const logLo = logHi - decades
90+
91+ const colorOf = (v: Num): string | null => {
92+ if (v == null || !isFinite(v)) return null
93+ if (kind === 'div') return divColor(amax > 0 ? v / amax : 0)
94+ if (kind === 'log') {
95+ const l = Math.log10(Math.max(Math.abs(v), Number.MIN_VALUE))
96+ return ramp((l - logLo) / (logHi - logLo))
97+ }
98+ return ramp(hi > lo ? (v - lo) / (hi - lo) : 0.5)
99+ }
100+
101+ const iw = Math.max(10, width - M.l - M.r)
102+ const ih = Math.max(10, height - M.t - M.b)
103+
104+ useEffect(() => {
105+ const cv = canvasRef.current
106+ if (!cv || ncol === 0) return
107+ cv.width = ncol
108+ cv.height = nrow
109+ const ctx = cv.getContext('2d')
110+ if (!ctx) return
111+ const img = ctx.createImageData(ncol, nrow)
112+ for (let i = 0; i < nrow; i++) {
113+ // row 0 of z is the bottom of the picture
114+ const dst = (nrow - 1 - i) * ncol
115+ for (let j = 0; j < ncol; j++) {
116+ const c = colorOf(z[i][j])
117+ const o = (dst + j) * 4
118+ if (c == null) {
119+ img.data[o] = 26
120+ img.data[o + 1] = 26
121+ img.data[o + 2] = 25
122+ img.data[o + 3] = 255
123+ } else {
124+ const n = parseInt(c.slice(1), 16)
125+ img.data[o] = (n >> 16) & 255
126+ img.data[o + 1] = (n >> 8) & 255
127+ img.data[o + 2] = n & 255
128+ img.data[o + 3] = 255
129+ }
130+ }
131+ }
132+ ctx.putImageData(img, 0, 0)
133+ })
134+
135+ const sx = (v: number) => ((v - xDomain[0]) / (xDomain[1] - xDomain[0])) * iw
136+ const sy = (v: number) => ih - ((v - yDomain[0]) / (yDomain[1] - yDomain[0])) * ih
137+
138+ const xt = niceTicks(xDomain[0], xDomain[1], 5)
139+ const yt = niceTicks(yDomain[0], yDomain[1], 5)
140+
141+ // colour bar: 24 steps from the same scale
142+ const bar = Array.from({ length: 24 }, (_, k) => k / 23)
143+ const barLabel = (t: number): string => {
144+ if (kind === 'div') return formatTick(Number(((2 * t - 1) * amax).toPrecision(2)))
145+ if (kind === 'log') {
146+ const e = logLo + t * (logHi - logLo)
147+ return `1e${Math.round(e)}`
148+ }
149+ return formatTick(Number((lo + t * (hi - lo)).toPrecision(2)))
150+ }
151+
152+ return (
153+ <div className="plot-host field-host" ref={hostRef}>
154+ <div style={{ position: 'relative', width, height }}>
155+ <canvas
156+ ref={canvasRef}
157+ style={{
158+ position: 'absolute',
159+ left: M.l,
160+ top: M.t,
161+ width: iw,
162+ height: ih,
163+ imageRendering: ncol < 96 ? 'pixelated' : 'auto',
164+ }}
165+ />
166+ <svg width={width} height={height} style={{ position: 'absolute', left: 0, top: 0 }} role="img">
167+ <defs>
168+ <clipPath id={`fc-${nrow}-${ncol}`}>
169+ <rect x={0} y={0} width={iw} height={ih} />
170+ </clipPath>
171+ </defs>
172+ <g transform={`translate(${M.l},${M.t})`} clipPath={`url(#fc-${nrow}-${ncol})`}>
173+ {curves?.map((c, k) => (
174+ <path
175+ key={`c${k}`}
176+ d={c.x.map((xv, i) => `${i ? 'L' : 'M'}${sx(xv).toFixed(2)} ${sy(c.y[i]).toFixed(2)}`).join('')}
177+ fill="none"
178+ stroke={c.color ?? ink.node}
179+ strokeWidth={1.5}
180+ strokeDasharray={c.dash}
181+ />
182+ ))}
183+ {points?.map((p, k) => (
184+ <g key={`p${k}`}>
185+ {p.x.map((xv, i) => (
186+ <circle key={i} cx={sx(xv)} cy={sy(p.y[i])} r={p.r ?? 1.8} fill={p.color ?? ink.node} />
187+ ))}
188+ </g>
189+ ))}
190+ </g>
191+ <g transform={`translate(${M.l},${M.t})`}>
192+ <rect x={0} y={0} width={iw} height={ih} fill="none" stroke={ink.axis} strokeWidth={1} />
193+ {xt.map((v) => (
194+ <text key={`x${v}`} x={sx(v)} y={ih + 15} fill={ink.muted} fontSize={11} textAnchor="middle">
195+ {formatTick(v)}
196+ </text>
197+ ))}
198+ {yt.map((v) => (
199+ <text key={`y${v}`} x={-7} y={sy(v) + 4} fill={ink.muted} fontSize={11} textAnchor="end">
200+ {formatTick(v)}
201+ </text>
202+ ))}
203+ {title && (
204+ <text x={0} y={-6} fill={ink.secondary} fontSize={11}>
205+ {title}
206+ </text>
207+ )}
208+ {/* colour bar */}
209+ <g transform={`translate(${iw + 12},0)`}>
210+ {bar.map((t, k) => (
211+ <rect
212+ key={k}
213+ x={0}
214+ y={ih - ((k + 1) * ih) / bar.length}
215+ width={12}
216+ height={ih / bar.length + 0.5}
217+ fill={kind === 'div' ? divColor(2 * t - 1) : ramp(t)}
218+ />
219+ ))}
220+ <rect x={0} y={0} width={12} height={ih} fill="none" stroke={ink.axis} strokeWidth={1} />
221+ {[0, 0.5, 1].map((t) => (
222+ <text key={t} x={16} y={ih - t * ih + 4} fill={ink.muted} fontSize={10}>
223+ {barLabel(t)}
224+ </text>
225+ ))}
226+ </g>
227+ </g>
228+ </svg>
229+ </div>
230+ </div>
231+ )
232+}
233+
234+export { surface }
src/plot/Legend.tsxadded+62−0View file
@@ -0,0 +1,62 @@
1+import { ink } from './palette.ts'
2+
3+export interface LegendItem {
4+ label: string
5+ color: string
6+ /** stroke-dasharray for a line swatch */
7+ dash?: string
8+ shape?: 'line' | 'dot' | 'cross'
9+ /** e.g. the max error for this series, shown after the label */
10+ value?: string
11+}
12+
13+/**
14+ * Identity is never carried by colour alone: every series that appears in a
15+ * plot appears here too, and the ones the reader most needs are also labelled
16+ * directly on the plot.
17+ */
18+export default function Legend({
19+ items,
20+ onHover,
21+ active,
22+}: {
23+ items: LegendItem[]
24+ /** hovering an entry reports its index, so the plot can pick that curve out */
25+ onHover?: (k: number | null) => void
26+ /** the entry to show as picked out; the others are dimmed */
27+ active?: number | null
28+}) {
29+ return (
30+ <div className="legend" onPointerLeave={onHover ? () => onHover(null) : undefined}>
31+ {items.map((it, k) => (
32+ <span
33+ className={active != null && active !== k ? 'legend-item dim' : 'legend-item'}
34+ key={it.label}
35+ onPointerEnter={onHover ? () => onHover(k) : undefined}
36+ style={onHover ? { cursor: 'default' } : undefined}
37+ >
38+ <svg width={18} height={10} aria-hidden="true">
39+ {it.shape === 'dot' ? (
40+ <circle cx={9} cy={5} r={3.5} fill={it.color} stroke={ink.grid} strokeWidth={1} />
41+ ) : it.shape === 'cross' ? (
42+ <path d="M5 1 L13 9 M13 1 L5 9" stroke={it.color} strokeWidth={2} strokeLinecap="round" />
43+ ) : (
44+ <line
45+ x1={1}
46+ x2={17}
47+ y1={5}
48+ y2={5}
49+ stroke={it.color}
50+ strokeWidth={2}
51+ strokeDasharray={it.dash}
52+ strokeLinecap="round"
53+ />
54+ )}
55+ </svg>
56+ <span>{it.label}</span>
57+ {it.value && <span className="legend-value">{it.value}</span>}
58+ </span>
59+ ))}
60+ </div>
61+ )
62+}
src/plot/Plot.tsxadded+224−0View file
@@ -0,0 +1,224 @@
1+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
2+import { ink } from './palette.ts'
3+import { decadeTicks, formatTick, niceTicks, type Frame } from './scales.ts'
4+
5+export interface PlotProps {
6+ height: number
7+ xDomain: [number, number]
8+ yDomain: [number, number]
9+ xLog?: boolean
10+ yLog?: boolean
11+ xLabel?: string
12+ yLabel?: string
13+ xTicks?: number[]
14+ yTicks?: number[]
15+ formatX?: (v: number) => string
16+ formatY?: (v: number) => string
17+ margin?: Partial<{ l: number; r: number; t: number; b: number }>
18+ /** drawn under the data, inside the frame but outside the clip */
19+ under?: (f: Frame) => ReactNode
20+ /** drawn over the data and outside the clip, so labels may reach the margin */
21+ over?: (f: Frame) => ReactNode
22+ children: (f: Frame) => ReactNode
23+ /** enables the crosshair; called with the data x under the pointer, or null */
24+ onHoverX?: (x: number | null) => void
25+ /** data x at which to draw the crosshair (usually what onHoverX last gave) */
26+ hoverX?: number | null
27+ /** rendered as an absolutely positioned box over the plot */
28+ overlay?: ReactNode
29+ /**
30+ * Pointer position in frame pixels, or null when it leaves. The caller is
31+ * expected to keep it in state and use the frame it is handed in `over` to
32+ * turn it back into data coordinates, which is how a plot picks the point
33+ * nearest the cursor without either side owning the scales twice.
34+ */
35+ onPointer?: (p: { px: number; py: number } | null) => void
36+}
37+
38+/** Pointer position in frame pixels: what `onPointer` reports. */
39+export interface PointerPx {
40+ px: number
41+ py: number
42+}
43+
44+const DEFAULT_MARGIN = { l: 54, r: 14, t: 10, b: 30 }
45+
46+/** Measures its own width so plots reflow with the panel. */
47+function useWidth(): [(el: HTMLDivElement | null) => void, number] {
48+ const [width, setWidth] = useState(640)
49+ const obs = useRef<ResizeObserver | null>(null)
50+ const ref = useCallback((el: HTMLDivElement | null) => {
51+ obs.current?.disconnect()
52+ if (!el) return
53+ const ro = new ResizeObserver((entries) => {
54+ const w = entries[0]?.contentRect.width
55+ if (w && w > 0) setWidth(w)
56+ })
57+ ro.observe(el)
58+ obs.current = ro
59+ setWidth(el.clientWidth || 640)
60+ }, [])
61+ useEffect(() => () => obs.current?.disconnect(), [])
62+ return [ref, width]
63+}
64+
65+export default function Plot(props: PlotProps) {
66+ const {
67+ height,
68+ xDomain,
69+ yDomain,
70+ xLog = false,
71+ yLog = false,
72+ xLabel,
73+ yLabel,
74+ formatX = formatTick,
75+ formatY = formatTick,
76+ under,
77+ over,
78+ children,
79+ onHoverX,
80+ hoverX,
81+ overlay,
82+ onPointer,
83+ } = props
84+ const m = { ...DEFAULT_MARGIN, ...props.margin }
85+ const [hostRef, width] = useWidth()
86+ const clipId = useId().replace(/:/g, '')
87+ const svgRef = useRef<SVGSVGElement>(null)
88+
89+ const iw = Math.max(10, width - m.l - m.r)
90+ const ih = Math.max(10, height - m.t - m.b)
91+
92+ const fwd = (v: number, [lo, hi]: [number, number], log: boolean, span: number, flip: boolean) => {
93+ const t = log
94+ ? (Math.log10(Math.max(v, Number.MIN_VALUE)) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo))
95+ : (v - lo) / (hi - lo)
96+ return flip ? span * (1 - t) : span * t
97+ }
98+
99+ const f: Frame = {
100+ sx: (v) => fwd(v, xDomain, xLog, iw, false),
101+ sy: (v) => fwd(v, yDomain, yLog, ih, true),
102+ ix: (px) => {
103+ const t = px / iw
104+ return xLog
105+ ? Math.pow(10, Math.log10(xDomain[0]) + t * (Math.log10(xDomain[1]) - Math.log10(xDomain[0])))
106+ : xDomain[0] + t * (xDomain[1] - xDomain[0])
107+ },
108+ iw,
109+ ih,
110+ xDomain,
111+ yDomain,
112+ }
113+
114+ const xt = props.xTicks ?? (xLog ? decadeTicks(xDomain[0], xDomain[1]) : niceTicks(xDomain[0], xDomain[1], 7))
115+ const yt = props.yTicks ?? (yLog ? decadeTicks(yDomain[0], yDomain[1]) : niceTicks(yDomain[0], yDomain[1], 5))
116+
117+ const pointer = (e: React.PointerEvent) => {
118+ if (!onHoverX && !onPointer) return
119+ const rect = svgRef.current?.getBoundingClientRect()
120+ if (!rect) return
121+ const px = e.clientX - rect.left - m.l
122+ const py = e.clientY - rect.top - m.t
123+ onHoverX?.(px < -4 || px > iw + 4 ? null : f.ix(Math.min(iw, Math.max(0, px))))
124+ // a little slack outside the frame, so a marker sitting on an axis is
125+ // still reachable
126+ const out = px < -10 || px > iw + 10 || py < -10 || py > ih + 10
127+ onPointer?.(out ? null : { px, py })
128+ }
129+
130+ const leave = () => {
131+ onHoverX?.(null)
132+ onPointer?.(null)
133+ }
134+
135+ return (
136+ <div className="plot-host" ref={hostRef}>
137+ <svg
138+ ref={svgRef}
139+ width={width}
140+ height={height}
141+ role="img"
142+ onPointerMove={pointer}
143+ onPointerLeave={leave}
144+ style={onPointer ? { cursor: 'crosshair' } : undefined}
145+ >
146+ <defs>
147+ <clipPath id={clipId}>
148+ <rect x={0} y={0} width={iw} height={ih} />
149+ </clipPath>
150+ </defs>
151+ <g transform={`translate(${m.l},${m.t})`}>
152+ {/* gridlines, recessive */}
153+ {xt.map((v) => (
154+ <line key={`gx${v}`} x1={f.sx(v)} x2={f.sx(v)} y1={0} y2={ih} stroke={ink.grid} strokeWidth={1} />
155+ ))}
156+ {yt.map((v) => (
157+ <line key={`gy${v}`} x1={0} x2={iw} y1={f.sy(v)} y2={f.sy(v)} stroke={ink.grid} strokeWidth={1} />
158+ ))}
159+
160+ {under?.(f)}
161+
162+ <g clipPath={`url(#${clipId})`}>{children(f)}</g>
163+
164+ {over?.(f)}
165+
166+ {hoverX != null && hoverX >= Math.min(...xDomain) && hoverX <= Math.max(...xDomain) && (
167+ <line
168+ x1={f.sx(hoverX)}
169+ x2={f.sx(hoverX)}
170+ y1={0}
171+ y2={ih}
172+ stroke={ink.secondary}
173+ strokeWidth={1}
174+ strokeDasharray="3 3"
175+ opacity={0.7}
176+ pointerEvents="none"
177+ />
178+ )}
179+
180+ {/* axes */}
181+ <line x1={0} x2={iw} y1={ih} y2={ih} stroke={ink.axis} strokeWidth={1} />
182+ <line x1={0} x2={0} y1={0} y2={ih} stroke={ink.axis} strokeWidth={1} />
183+ {xt.map((v) => (
184+ <text
185+ key={`tx${v}`}
186+ x={f.sx(v)}
187+ y={ih + 15}
188+ fill={ink.muted}
189+ fontSize={11}
190+ textAnchor="middle"
191+ style={{ fontVariantNumeric: 'tabular-nums' }}
192+ >
193+ {formatX(v)}
194+ </text>
195+ ))}
196+ {yt.map((v) => (
197+ <text
198+ key={`ty${v}`}
199+ x={-7}
200+ y={f.sy(v) + 4}
201+ fill={ink.muted}
202+ fontSize={11}
203+ textAnchor="end"
204+ style={{ fontVariantNumeric: 'tabular-nums' }}
205+ >
206+ {formatY(v)}
207+ </text>
208+ ))}
209+ {xLabel && (
210+ <text x={iw} y={ih + 27} fill={ink.muted} fontSize={11} textAnchor="end">
211+ {xLabel}
212+ </text>
213+ )}
214+ {yLabel && (
215+ <text x={-m.l + 4} y={-1} fill={ink.muted} fontSize={11} textAnchor="start">
216+ {yLabel}
217+ </text>
218+ )}
219+ </g>
220+ </svg>
221+ {overlay}
222+ </div>
223+ )
224+}
src/plot/palette.tsadded+150−0View file
@@ -0,0 +1,150 @@
1+// Colours, assigned by the job each one does.
2+//
3+// The set is the reference data-viz palette for a dark surface (#1a1a19),
4+// taken unchanged from the sibling laplace-dirichlet tutorial, where it was
5+// validated as a set:
6+//
7+// node scripts/validate_palette.js \
8+// "#3987e5,#d95926,#199e70,#c98500,#d55181,#008300,#9085e9,#e66767" --mode dark
9+// lightness band PASS chroma floor PASS CVD separation PASS (worst 8.4)
10+// normal-vision floor PASS (worst 19.3) contrast vs surface PASS
11+//
12+// Only the assignment of hues to meanings is new here; the particle-filters
13+// tutorial reuses the same eight hexes with its own assignment. The worst adjacent
14+// pair sits in the 6-8 CVD band's neighbourhood, so every plot that shows more
15+// than two series also carries a legend, a distinct marker shape per series,
16+// and direct end-labels: identity is never colour alone. If you add a series,
17+// re-run the validator rather than picking a hex that looks nice.
18+
19+export const surface = '#1a1a19'
20+
21+/**
22+ * Identity: which method produced a curve or a sound. Fixed slots, never
23+ * cycled and never reassigned when a plot shows a subset.
24+ */
25+export const method = {
26+ /** second-order leapfrog finite differences, the workhorse */
27+ fd2: '#3987e5',
28+ /** fourth-order finite differences, in space or in space and time */
29+ fd4: '#d95926',
30+ /** modal synthesis and spectral methods: a sum of exact standing waves */
31+ modal: '#199e70',
32+ /** delay lines: the digital waveguide and Karplus-Strong */
33+ waveguide: '#c98500',
34+ /** boundary treatments: cut-cell stencils, absorbing layers, image sources */
35+ boundary: '#d55181',
36+ /** the exact answer when it is drawn as a series rather than as reference */
37+ exact: '#9085e9',
38+ /** a spare slot, for the extra series a single chapter needs */
39+ alt: '#e66767',
40+} as const
41+
42+export type MethodKey = keyof typeof method
43+
44+/** Marker shapes, so that identity survives greyscale and colour blindness. */
45+export const marker: Record<MethodKey, 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star'> = {
46+ fd2: 'circle',
47+ fd4: 'square',
48+ modal: 'triangle',
49+ waveguide: 'diamond',
50+ boundary: 'cross',
51+ exact: 'plus',
52+ alt: 'star',
53+}
54+
55+/** State. Reserved: never reused as a series colour. */
56+export const status = {
57+ good: '#008300',
58+ warning: '#c98500',
59+ critical: '#e66767',
60+} as const
61+
62+/** Sequential ramp for magnitude (an energy, a spectrum level): one hue. */
63+export const sequential = [
64+ '#cde2fb',
65+ '#b7d3f6',
66+ '#9ec5f4',
67+ '#86b6ef',
68+ '#6da7ec',
69+ '#5598e7',
70+ '#3987e5',
71+ '#2a78d6',
72+ '#256abf',
73+ '#1c5cab',
74+ '#184f95',
75+ '#104281',
76+ '#0d366b',
77+] as const
78+
79+/** Diverging pair for a signed field: two poles, neutral grey midpoint. */
80+export const diverging = {
81+ low: '#3987e5',
82+ mid: '#383835',
83+ high: '#e66767',
84+} as const
85+
86+/** Chart chrome and ink. Text always wears these, never a series colour. */
87+export const ink = {
88+ primary: '#e8e6df',
89+ secondary: '#c3c2b7',
90+ muted: '#8a8880',
91+ grid: '#272724',
92+ axis: '#3c3c38',
93+ /** the exact solution: the reference everything is measured against */
94+ reference: '#8a8880',
95+ /** grid nodes, receivers, individual sample points */
96+ node: '#e8e6df',
97+ /** a family of curves drawn as a mass: many modes, many images */
98+ family: '#4a4a45',
99+} as const
100+
101+function hexToRgb(hex: string): [number, number, number] {
102+ const v = parseInt(hex.slice(1), 16)
103+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
104+}
105+
106+function rgbToHex(r: number, g: number, b: number): string {
107+ const c = (x: number) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0')
108+ return `#${c(r)}${c(g)}${c(b)}`
109+}
110+
111+function mix(a: string, b: string, t: number): string {
112+ const [r1, g1, b1] = hexToRgb(a)
113+ const [r2, g2, b2] = hexToRgb(b)
114+ return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
115+}
116+
117+/** Sequential colour for t in [0, 1]: light at 0 (near zero), dark at 1. */
118+export function seqColor(t: number): string {
119+ if (!isFinite(t)) return ink.grid
120+ const u = Math.max(0, Math.min(1, t)) * (sequential.length - 1)
121+ const i = Math.floor(u)
122+ return i >= sequential.length - 1 ? sequential[sequential.length - 1] : mix(sequential[i], sequential[i + 1], u - i)
123+}
124+
125+/** Diverging colour for t in [-1, 1]: blue below, grey at zero, red above. */
126+export function divColor(t: number): string {
127+ if (!isFinite(t)) return ink.grid
128+ const u = Math.max(-1, Math.min(1, t))
129+ return u < 0 ? mix(diverging.mid, diverging.low, -u) : mix(diverging.mid, diverging.high, u)
130+}
131+
132+/** Mix a colour towards the surface, for a recessive version of a series. */
133+export function faded(c: string, t = 0.55): string {
134+ return mix(c, surface, t)
135+}
136+
137+/**
138+ * One method at several settings of a parameter (a Courant number, a grid
139+ * size): a family rather than several identities, so it is drawn in shades of
140+ * one hue, light to dark in the order of the parameter, and every member also
141+ * carries its own marker shape. Only the lighter two-thirds of the
142+ * sequential ramp is used, because its dark end sinks into this page's
143+ * surface.
144+ */
145+export function familyColor(k: number, n: number): string {
146+ const t = n <= 1 ? 0.3 : 0.05 + (0.62 * k) / (n - 1)
147+ return seqColor(t)
148+}
149+
150+export const familyShapes = ['circle', 'square', 'triangle', 'diamond', 'star', 'plus', 'cross'] as const
src/plot/scales.tsadded+114−0View file
@@ -0,0 +1,114 @@
1+import type { Num } from '../engine/types.ts'
2+
3+export interface Frame {
4+ /** data x to pixel x, within the plotting area */
5+ sx: (v: number) => number
6+ /** data y to pixel y */
7+ sy: (v: number) => number
8+ /** pixel x back to data x, for hover */
9+ ix: (px: number) => number
10+ iw: number
11+ ih: number
12+ xDomain: [number, number]
13+ yDomain: [number, number]
14+}
15+
16+export function niceTicks(lo: number, hi: number, target = 6): number[] {
17+ if (!isFinite(lo) || !isFinite(hi) || hi <= lo) return []
18+ const raw = (hi - lo) / target
19+ const mag = Math.pow(10, Math.floor(Math.log10(raw)))
20+ const norm = raw / mag
21+ const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag
22+ const out: number[] = []
23+ for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) {
24+ out.push(Math.abs(v) < step * 1e-9 ? 0 : v)
25+ }
26+ return out
27+}
28+
29+/** Decade ticks, thinned so the labels do not collide over a wide range. */
30+export function decadeTicks(lo: number, hi: number, maxCount = 9): number[] {
31+ if (!(lo > 0) || !(hi > 0)) return []
32+ const a = Math.floor(Math.log10(lo))
33+ const b = Math.ceil(Math.log10(hi))
34+ const every = Math.max(1, Math.ceil((b - a + 1) / maxCount))
35+ const out: number[] = []
36+ for (let e = a; e <= b; e += every) {
37+ const v = Math.pow(10, e)
38+ if (v >= lo * 0.999 && v <= hi * 1.001) out.push(v)
39+ }
40+ return out
41+}
42+
43+export function formatTick(v: number): string {
44+ if (v === 0) return '0'
45+ const a = Math.abs(v)
46+ if (a >= 1e5 || a < 1e-3) {
47+ const e = Math.round(Math.log10(a))
48+ if (Math.abs(a - Math.pow(10, e)) < Math.pow(10, e) * 1e-6) return `1e${e}`
49+ return v.toExponential(0)
50+ }
51+ if (Number.isInteger(v)) return String(v)
52+ return String(Number(v.toPrecision(3)))
53+}
54+
55+export function padDomain([lo, hi]: [number, number], frac = 0.06): [number, number] {
56+ if (!isFinite(lo) || !isFinite(hi)) return [0, 1]
57+ if (hi === lo) return [lo - 0.5, hi + 0.5]
58+ const p = (hi - lo) * frac
59+ return [lo - p, hi + p]
60+}
61+
62+/** Range of the finite values in one or more series. */
63+export function extent(...series: (readonly Num[] | undefined)[]): [number, number] {
64+ let lo = Infinity
65+ let hi = -Infinity
66+ for (const s of series) {
67+ if (!s) continue
68+ for (const v of s) {
69+ if (v == null || !isFinite(v)) continue
70+ if (v < lo) lo = v
71+ if (v > hi) hi = v
72+ }
73+ }
74+ return isFinite(lo) ? [lo, hi] : [0, 1]
75+}
76+
77+/**
78+ * A polyline through (xs, ys). Runs of missing values break the path rather
79+ * than being bridged, and values far outside the frame are clamped to a band
80+ * just off-screen so that a curve running off to infinity still leaves the
81+ * frame in the right direction instead of producing unusable path data.
82+ */
83+export function linePath(xs: readonly number[], ys: readonly Num[], f: Frame): string {
84+ const bound = f.ih * 12
85+ let out = ''
86+ let pen = false
87+ for (let i = 0; i < xs.length && i < ys.length; i++) {
88+ const y = ys[i]
89+ if (y == null || !isFinite(y) || !isFinite(xs[i])) {
90+ pen = false
91+ continue
92+ }
93+ const px = f.sx(xs[i])
94+ const py = Math.min(bound, Math.max(-bound, f.sy(y)))
95+ out += `${pen ? 'L' : 'M'}${px.toFixed(2)} ${py.toFixed(2)}`
96+ pen = true
97+ }
98+ return out
99+}
100+
101+/** Index of the sample nearest a data-space x. */
102+export function nearestIndex(xs: readonly number[], v: number): number {
103+ if (xs.length === 0) return -1
104+ let lo = 0
105+ let hi = xs.length - 1
106+ if (v <= xs[lo]) return lo
107+ if (v >= xs[hi]) return hi
108+ while (hi - lo > 1) {
109+ const mid = (lo + hi) >> 1
110+ if (xs[mid] <= v) lo = mid
111+ else hi = mid
112+ }
113+ return v - xs[lo] <= xs[hi] - v ? lo : hi
114+}
src/ui/ChapterView.tsxadded+54−0View file
@@ -0,0 +1,54 @@
1+// A chapter: prose with its labs and figures dropped in where the text calls
2+// for them.
3+//
4+// The prose is one markdown file per chapter, with a line
5+//
6+// <!--lab:convergence-->
7+// <!--fig:five-point-->
8+//
9+// wherever a lab or a static figure belongs. Splitting on those markers keeps
10+// the writing in one readable file instead of scattered through JSX.
11+import { useMemo } from 'react'
12+import Markdown from './Markdown.tsx'
13+import Lab from './Lab.tsx'
14+import type { Chapter } from './types.ts'
15+import { FIGURES } from '../figures/index.ts'
16+
17+const MARKER = /<!--\s*(lab|fig):([a-z0-9_-]+)\s*-->/gi
18+
19+export default function ChapterView({ chapter }: { chapter: Chapter }) {
20+ const pieces = useMemo(() => {
21+ const out: ({ md: string } | { labId: string } | { figId: string })[] = []
22+ let last = 0
23+ MARKER.lastIndex = 0
24+ let m: RegExpExecArray | null
25+ while ((m = MARKER.exec(chapter.text)) !== null) {
26+ out.push({ md: chapter.text.slice(last, m.index) })
27+ out.push(m[1] === 'fig' ? { figId: m[2] } : { labId: m[2] })
28+ last = m.index + m[0].length
29+ }
30+ out.push({ md: chapter.text.slice(last) })
31+ return out
32+ }, [chapter.text])
33+
34+ return (
35+ <article className="chapter">
36+ <header className="chapter-head">
37+ <div className="chapter-part">{chapter.part}</div>
38+ <h2>{chapter.title}</h2>
39+ <p className="chapter-blurb">{chapter.blurb}</p>
40+ </header>
41+ {pieces.map((p, i) => {
42+ if ('md' in p) return p.md.trim() === '' ? null : <Markdown key={i} src={p.md} />
43+ if ('figId' in p) {
44+ const Fig = FIGURES[p.figId]
45+ if (!Fig) return <p key={i} className="lab-error">missing figure: {p.figId}</p>
46+ return <Fig key={`${chapter.id}-fig-${p.figId}`} />
47+ }
48+ const lab = chapter.labs.find((l) => l.id === p.labId)
49+ if (!lab) return <p key={i} className="lab-error">missing lab: {p.labId}</p>
50+ return <Lab key={`${chapter.id}-${lab.id}`} spec={lab} />
51+ })}
52+ </article>
53+ )
54+}
src/ui/Controls.tsxadded+93−0View file
@@ -0,0 +1,93 @@
1+// The controls for one lab, declared by the lab and laid out in a single row
2+// above its figures.
3+import type { Params, ParamValue } from '../engine/types.ts'
4+import type { Control } from './types.ts'
5+
6+export default function Controls({
7+ controls,
8+ params,
9+ onChange,
10+}: {
11+ controls: Control[]
12+ params: Params
13+ onChange: (key: string, value: ParamValue) => void
14+}) {
15+ if (controls.length === 0) return null
16+ return (
17+ <div className="controls">
18+ {controls.map((c) => {
19+ if (c.kind === 'select') {
20+ return (
21+ <label className="control" key={c.key}>
22+ <span className="control-label">{c.label}</span>
23+ <select
24+ value={String(params[c.key])}
25+ onChange={(e) => {
26+ const raw = e.target.value
27+ const opt = c.options.find((o) => String(o.value) === raw)
28+ onChange(c.key, opt ? opt.value : raw)
29+ }}
30+ >
31+ {c.options.map((o) => (
32+ <option key={String(o.value)} value={String(o.value)}>
33+ {o.label}
34+ </option>
35+ ))}
36+ </select>
37+ </label>
38+ )
39+ }
40+ if (c.kind === 'toggle') {
41+ return (
42+ <label className="control control-toggle" key={c.key}>
43+ <input
44+ type="checkbox"
45+ checked={Boolean(params[c.key])}
46+ onChange={(e) => onChange(c.key, e.target.checked)}
47+ />
48+ <span className="control-label">{c.label}</span>
49+ </label>
50+ )
51+ }
52+ const value = Number(params[c.key])
53+ const fmt = c.format ?? ((v: number) => String(v))
54+ if (c.values) {
55+ const idx = Math.max(
56+ 0,
57+ c.values.findIndex((v) => v === value),
58+ )
59+ return (
60+ <label className="control" key={c.key}>
61+ <span className="control-label">
62+ {c.label} <span className="control-value">{fmt(value)}</span>
63+ </span>
64+ <input
65+ type="range"
66+ min={0}
67+ max={c.values.length - 1}
68+ step={1}
69+ value={idx}
70+ onChange={(e) => onChange(c.key, c.values![Number(e.target.value)])}
71+ />
72+ </label>
73+ )
74+ }
75+ return (
76+ <label className="control" key={c.key}>
77+ <span className="control-label">
78+ {c.label} <span className="control-value">{fmt(value)}</span>
79+ </span>
80+ <input
81+ type="range"
82+ min={c.min}
83+ max={c.max}
84+ step={c.step ?? 1}
85+ value={value}
86+ onChange={(e) => onChange(c.key, Number(e.target.value))}
87+ />
88+ </label>
89+ )
90+ })}
91+ </div>
92+ )
93+}
src/ui/Lab.tsxadded+144−0View file
@@ -0,0 +1,144 @@
1+// One runnable section: controls, figures, and the MATLAB behind them.
2+//
3+// Each lab owns a numbl session for as long as it is on screen. Moving a
4+// control rewrites params.json and re-runs; editing the script reboots the
5+// session, which is why an edited lab waits for the Run button.
6+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7+import ScriptEditor from '../editor/ScriptEditor.tsx'
8+import Controls from './Controls.tsx'
9+import { Engine } from '../engine/runner.ts'
10+import type { Params, ParamValue, RunResult } from '../engine/types.ts'
11+import type { LabSpec } from './types.ts'
12+
13+export default function Lab({ spec }: { spec: LabSpec }) {
14+ const [script, setScript] = useState(spec.script)
15+ const [pending, setPending] = useState(spec.script)
16+ const [params, setParams] = useState<Params>(spec.defaults)
17+ const [result, setResult] = useState<RunResult<unknown> | null>(null)
18+ const [running, setRunning] = useState(false)
19+ const [ranOnce, setRanOnce] = useState(false)
20+ const [showEditor, setShowEditor] = useState(Boolean(spec.editorOpen))
21+ const [showLog, setShowLog] = useState(false)
22+ const engine = useMemo(() => new Engine(), [])
23+ const seq = useRef(0)
24+
25+ useEffect(() => () => engine.dispose(), [engine])
26+
27+ const run = useCallback(
28+ async (src: string, p: Params) => {
29+ const my = ++seq.current
30+ setRunning(true)
31+ const res = await engine.run(spec.driver, src, spec.derive ? spec.derive(p) : p)
32+ if (seq.current !== my) return
33+ setResult(res)
34+ setRunning(false)
35+ setRanOnce(true)
36+ },
37+ [engine, spec.driver, spec.derive],
38+ )
39+
40+ // first run, unless the lab is heavy enough to wait to be asked
41+ useEffect(() => {
42+ if (!spec.manual) void run(script, params)
43+ // eslint-disable-next-line react-hooks/exhaustive-deps
44+ }, [])
45+
46+ // a control moved: re-run, debounced, but only once the lab is live
47+ const paramsRef = useRef(params)
48+ paramsRef.current = params
49+ const onControl = useCallback(
50+ (key: string, value: ParamValue) => {
51+ const next = { ...paramsRef.current, [key]: value }
52+ paramsRef.current = next
53+ setParams(next)
54+ },
55+ [],
56+ )
57+ const firstParams = useRef(true)
58+ useEffect(() => {
59+ if (firstParams.current) {
60+ firstParams.current = false
61+ return
62+ }
63+ if (!ranOnce) return
64+ const t = setTimeout(() => void run(script, params), 120)
65+ return () => clearTimeout(t)
66+ // eslint-disable-next-line react-hooks/exhaustive-deps
67+ }, [params])
68+
69+ const dirty = pending !== script
70+ const onRun = useCallback(() => {
71+ setScript(pending)
72+ void run(pending, paramsRef.current)
73+ }, [pending, run])
74+
75+ const data = result && result.ok ? result.data : null
76+ const summary = data && spec.summary ? spec.summary(data, params) : []
77+
78+ return (
79+ <section className="lab" id={`lab-${spec.id}`}>
80+ <header className="lab-head">
81+ <h3>{spec.title}</h3>
82+ {spec.caption && <p className="lab-caption">{spec.caption}</p>}
83+ </header>
84+
85+ <Controls controls={spec.controls} params={params} onChange={onControl} />
86+
87+ <div className="lab-bar">
88+ <button className={dirty || !ranOnce ? 'primary' : ''} onClick={onRun} disabled={running}>
89+ {running ? 'running...' : dirty ? 'run edited script' : 'run'}
90+ </button>
91+ {result && (
92+ <span className="stat">
93+ <span className="stat-label">wall clock</span>
94+ <span className="stat-value">{result.ms < 1000 ? `${result.ms.toFixed(0)} ms` : `${(result.ms / 1000).toFixed(2)} s`}</span>
95+ </span>
96+ )}
97+ {summary.map((s) => (
98+ <span className="stat" key={s.label}>
99+ <span className="stat-label">{s.label}</span>
100+ <span className="stat-value">{s.value}</span>
101+ </span>
102+ ))}
103+ <span className="lab-bar-gap" />
104+ <button className="ghost" onClick={() => setShowEditor((v) => !v)}>
105+ {showEditor ? 'hide script' : 'show script'}
106+ </button>
107+ {result && result.output.trim() !== '' && (
108+ <button className="ghost" onClick={() => setShowLog((v) => !v)}>
109+ {showLog ? 'hide output' : 'output'}
110+ </button>
111+ )}
112+ </div>
113+
114+ {result && !result.ok && <pre className="lab-error">{result.error}</pre>}
115+
116+ <div className="lab-figures">
117+ {data ? spec.render(data, params) : <div className="lab-placeholder">{running ? 'running' : 'press run'}</div>}
118+ </div>
119+
120+ {showLog && result && <pre className="lab-log">{result.output}</pre>}
121+
122+ {showEditor && (
123+ <div className="lab-editor">
124+ <div className="lab-editor-bar">
125+ <span>the method, in MATLAB syntax</span>
126+ <span className="lab-bar-gap" />
127+ {dirty && <span className="warn">edited; press run</span>}
128+ <button
129+ className="ghost"
130+ onClick={() => {
131+ setPending(spec.script)
132+ setScript(spec.script)
133+ void run(spec.script, paramsRef.current)
134+ }}
135+ >
136+ reset
137+ </button>
138+ </div>
139+ <ScriptEditor value={pending} onChange={setPending} onRun={onRun} />
140+ </div>
141+ )}
142+ </section>
143+ )
144+}
src/ui/LibraryView.tsxadded+41−0View file
@@ -0,0 +1,41 @@
1+// The shared .m files, shown read-only. The editable script in each lab is
2+// the filter itself; these are the pieces every lab is allowed to assume.
3+import { useState } from 'react'
4+
5+const raw = import.meta.glob('../matlab/*.m', { query: '?raw', import: 'default', eager: true }) as Record<
6+ string,
7+ string
8+>
9+
10+const FILES = Object.entries(raw)
11+ .map(([path, content]) => ({ name: path.slice(path.lastIndexOf('/') + 1), content }))
12+ .sort((a, b) => a.name.localeCompare(b.name))
13+
14+export default function LibraryView() {
15+ const [open, setOpen] = useState<string | null>(FILES[0]?.name ?? null)
16+ return (
17+ <article className="chapter">
18+ <header className="chapter-head">
19+ <div className="chapter-part">Reference</div>
20+ <h2>The shared MATLAB library</h2>
21+ <p className="chapter-blurb">
22+ Every lab boots with these function files in its session, alongside the editable script and a driver that
23+ reads the controls, computes the exact answer, measures the method against it, and writes the figures and
24+ the sound. They are the pieces that would otherwise be repeated in every chapter: reading and writing the
25+ page's data, packing a signal for the audio player, the spectral estimates, the Bessel zeros, and the one
26+ routine that every measured frequency in the tutorial comes from.
27+ </p>
28+ </header>
29+ <div className="lib-list">
30+ {FILES.map((f) => (
31+ <div className="lib-file" key={f.name}>
32+ <button className="lib-name" onClick={() => setOpen(open === f.name ? null : f.name)}>
33+ {open === f.name ? '▾' : '▸'} {f.name}
34+ </button>
35+ {open === f.name && <pre className="lib-src">{f.content}</pre>}
36+ </div>
37+ ))}
38+ </div>
39+ </article>
40+ )
41+}
src/ui/Markdown.tsxadded+35−0View file
@@ -0,0 +1,35 @@
1+// Prose is written as markdown with TeX between dollar signs. KaTeX renders
2+// the maths first, into placeholders that marked will not touch, and the
3+// result is put in with dangerouslySetInnerHTML: the source is ours, written
4+// at build time, not anything a reader supplies.
5+import { useMemo } from 'react'
6+import { marked } from 'marked'
7+import katex from 'katex'
8+import 'katex/dist/katex.min.css'
9+
10+function renderMath(src: string): { text: string; slots: string[] } {
11+ const slots: string[] = []
12+ const keep = (html: string) => {
13+ slots.push(html)
14+ return `@@MATH${slots.length - 1}@@`
15+ }
16+ // display maths first, so that $$...$$ is not eaten by the inline rule
17+ let text = src.replace(/\$\$([\s\S]+?)\$\$/g, (_m, tex: string) =>
18+ keep(katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false })),
19+ )
20+ text = text.replace(/(^|[^\\$])\$([^$\n]+?)\$/g, (_m, pre: string, tex: string) =>
21+ pre + keep(katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false })),
22+ )
23+ return { text, slots }
24+}
25+
26+export function markdownToHtml(src: string): string {
27+ const { text, slots } = renderMath(src)
28+ const html = marked.parse(text, { async: false }) as string
29+ return html.replace(/@@MATH(\d+)@@/g, (_m, i: string) => slots[Number(i)])
30+}
31+
32+export default function Markdown({ src }: { src: string }) {
33+ const html = useMemo(() => markdownToHtml(src), [src])
34+ return <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
35+}
src/ui/types.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+import type { ReactNode } from 'react'
2+import type { Params } from '../engine/types.ts'
3+
4+export type Control =
5+ | {
6+ kind: 'slider'
7+ key: string
8+ label: string
9+ min: number
10+ max: number
11+ step?: number
12+ /** discrete choices; the slider then moves over their indices */
13+ values?: number[]
14+ format?: (v: number) => string
15+ }
16+ | {
17+ kind: 'select'
18+ key: string
19+ label: string
20+ options: { value: string | number; label: string }[]
21+ }
22+ | { kind: 'toggle'; key: string; label: string }
23+
24+export interface LabSpec {
25+ id: string
26+ title: string
27+ /** one line telling the reader what to watch */
28+ caption?: string
29+ /** the fixed part of the MATLAB layer, hidden from the reader */
30+ driver: string
31+ /** the editable part: the method itself */
32+ script: string
33+ controls: Control[]
34+ defaults: Params
35+ /** turns the control settings into what params.json should contain */
36+ derive?: (p: Params) => Params
37+ /** heavy labs wait for the Run button instead of running on sight */
38+ manual?: boolean
39+ /** the editor starts open when the script is the point of the section */
40+ editorOpen?: boolean
41+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42+ render: (data: any, params: Params) => ReactNode
43+ /** numbers for the status strip beside the timing */
44+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45+ summary?: (data: any, params: Params) => { label: string; value: string }[]
46+}
47+
48+export interface Chapter {
49+ id: string
50+ /** short name for the sidebar */
51+ nav: string
52+ title: string
53+ /** one sentence, shown under the title and in the contents */
54+ blurb: string
55+ part: Part
56+ /** the prose, with <!--lab:id--> lines marking where each lab goes */
57+ text: string
58+ labs: LabSpec[]
59+}
60+
61+export const PARTS = ['Introduction', 'Strings', 'Drums', 'Rooms'] as const
62+export type Part = (typeof PARTS)[number]
src/vite-env.d.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+/// <reference types="vite/client" />
2+
3+declare module '*.m?raw' {
4+ const src: string
5+ export default src
6+}
7+
8+declare module '*.md?raw' {
9+ const src: string
10+ export default src
11+}
tsconfig.app.jsonadded+25−0View file
@@ -0,0 +1,25 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM", "WebWorker"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ "moduleResolution": "bundler",
12+ "allowImportingTsExtensions": true,
13+ "verbatimModuleSyntax": true,
14+ "moduleDetection": "force",
15+ "noEmit": true,
16+ "jsx": "react-jsx",
17+
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "erasableSyntaxOnly": true,
22+ "noFallthroughCasesInSwitch": true
23+ },
24+ "include": ["src"]
25+}
tsconfig.jsonadded+4−0View file
@@ -0,0 +1,4 @@
1+{
2+ "files": [],
3+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
4+}
tsconfig.node.jsonadded+21−0View file
@@ -0,0 +1,21 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "module": "esnext",
7+ "types": ["node"],
8+ "skipLibCheck": true,
9+
10+ "moduleResolution": "bundler",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ "strict": true,
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true
19+ },
20+ "include": ["vite.config.ts"]
21+}
vite.config.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+export default defineConfig({
5+ plugins: [react()],
6+ // relative base so the same build works at the repository root and under a
7+ // sub-path on a static host
8+ base: './',
9+ // .m and .md files are pulled in with ?raw; tell vite they are assets
10+ assetsInclude: ['**/*.m'],
11+})