d8098edAn interactive tutorial on the wave equation in acoustics: strings, drums and rooms, measured by earJeremy Magland 1import type { Chapter } from '../../ui/types.ts'
2import type { Num, Params } from '../../engine/types.ts'
3import driver from './driver.m?raw'
4import script from './disk.m?raw'
5import text from './text.md?raw'
6import Listen, { type Clip } from '../../audio/Listen.tsx'
7import { NumTable, Row, SeriesPlot, fmtCents, fmtMs, fmtNum } from '../common/panels.tsx'
8import { faded, ink, method } from '../../plot/palette.ts'
10interface 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}
25interface 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}
36const 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}
41function 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}
94function 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}
156export 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}