1import type { Chapter } from '../../ui/types.ts'
2import type { Num, Params } from '../../engine/types.ts'
3import driver from './driver.m?raw'
4import script from './ks.m?raw'
5import text from './text.md?raw'
6import Listen, { type Clip } from '../../audio/Listen.tsx'
7import { SeriesPlot, fmtCents, fmtErr, fmtMs } from '../common/panels.tsx'
8import { method } from '../../plot/palette.ts'
9import { FS, noteName } from '../shared.ts'
11interface EquivOut {
12 Ns: number[]
13 tl: number[]
14 tw: number[]
15 diff: number[]
16 M: number
17}
19interface 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}
31const 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]
37function 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}
68function 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}
111export 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}