import type { Chapter } from '../../ui/types.ts'
import type { Num, Params } from '../../engine/types.ts'
import driver from './driver.m?raw'
import script from './leapfrog.m?raw'
import text from './text.md?raw'
import Listen, { type Clip } from '../../audio/Listen.tsx'
import Field from '../../plot/Field.tsx'
import { Row, SeriesPlot, fmtCents, fmtErr, fmtNum } from '../common/panels.tsx'
import { familyColor, familyShapes, ink, method } from '../../plot/palette.ts'
import { FS } from '../shared.ts'
interface RunOut {
ms: number
lam: number
N: number
unstable: boolean
blowup: Num
tp: number[]
err: Num[]
relerr: number
tw: number[]
yw: Num[]
yew: number[]
pred1: number
pred10: number
clipExact: Clip
clipFd: Clip
clipDiff: Clip
}
interface CentsOut {
N: number
f0: number
lams: number[]
sel: number[]
nall: number[]
meas: Num[][]
pred: Num[][]
ms: number[]
agree: number
}
interface ClickOut {
ms: number
N: number
lam: number
fcut: number
rel: number
tw1: number[]
y1: number[]
ye1: number[]
tw20: number[]
y20: number[]
ye20: number[]
spec: Num[][]
specF: [number, number]
specT: [number, number]
clipExact: Clip
clipFd: Clip
}
const F0 = FS / 400 // 110.25 Hz: the pitch at which N = 200 is exactly lam = 1
function RunPanels({ d }: { d: RunOut }) {
return (
<>
(v != null && v > 1e-16 ? v : 1e-16)), color: method.fd2, width: 1.6 }]}
legend={false}
/>
>
)
}
function CentsPanels({ d }: { d: CentsOut }) {
const n = d.lams.length
const col = (k: number) => familyColor(k, n)
const ppwAll = d.nall.map((m) => (2 * d.N) / m)
const ppwSel = d.sel.map((m) => (2 * d.N) / m)
const measured = (k: number, abs: boolean) => ({
y: d.meas[k].map((v) => (v == null ? null : abs ? Math.abs(v) : v)),
color: col(k),
shape: familyShapes[k],
line: false,
label: `λ = ${d.lams[k]}`,
tip: (i: number) => [
`partial ${d.sel[i]}, ${(d.sel[i] * d.f0).toFixed(0)} Hz`,
`${fmtNum(ppwSel[i], 3)} points per wavelength`,
`measured ${fmtCents(d.meas[k][i])}`,
],
})
return (
<>
[
{ x: d.nall.map((m) => m * d.f0), y: d.pred[k], color: col(k), width: 1.2, noLegend: true },
{ x: d.sel.map((m) => m * d.f0), ...measured(k, false) },
])}
/>
l === 1
? []
: [
{ x: ppwAll, y: d.pred[k].map((v) => (v == null ? null : Math.abs(v))), color: col(k), width: 1.2, noLegend: true },
{ x: ppwSel, ...measured(k, true) },
],
)}
/>
>
)
}
function ClickPanels({ d }: { d: ClickOut }) {
return (
<>
>
)
}
export const ch02: Chapter = {
id: 'leapfrog',
nav: 'Leapfrog',
part: 'Strings',
title: 'Leapfrog, and the magic time step',
blurb:
'The standard explicit scheme, its stability limit, the one Courant number at which it is exact, and what it sounds like everywhere else.',
text,
labs: [
{
id: 'run',
title: 'A plucked string at the audio rate',
caption:
'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.',
driver,
script,
controls: [
{
kind: 'slider',
key: 'N',
label: 'grid intervals N',
min: 0,
max: 0,
values: [25, 50, 100, 150, 200, 201],
format: (v: number) => `${v} (λ = ${(v / 200).toFixed(3)})`,
},
{
kind: 'select',
key: 'where',
label: 'listen to',
options: [
{ value: 'bridge', label: 'the first node off the bridge' },
{ value: 'pickup', label: 'a pickup at x = 0.12' },
],
},
],
defaults: { N: 100, where: 'bridge' },
derive: (p: Params) => ({
mode: 'run',
fs: FS,
f0: F0,
N: p.N,
T: 2.5,
where: p.where,
beta: 0.2,
xp: 0.12,
}),
editorOpen: true,
render: (d: RunOut) => ,
summary: (d: RunOut) =>
d.unstable
? [
{ label: 'Courant number λ', value: fmtNum(d.lam, 4) },
{ label: 'blew up after', value: `${fmtNum(d.blowup, 3)} ms` },
]
: [
{ label: 'Courant number λ', value: fmtNum(d.lam, 4) },
{ label: 'rms error, last second', value: fmtErr(d.relerr) },
{ label: 'partial 1, predicted', value: fmtCents(d.pred1) },
{ label: 'partial 10, predicted', value: fmtCents(d.pred10) },
],
},
{
id: 'cents',
title: 'The pitch of every partial, measured',
caption:
'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.',
driver,
script,
controls: [{ kind: 'slider', key: 'N', label: 'grid intervals N', min: 0, max: 0, values: [25, 50, 100, 200] }],
defaults: { N: 100 },
derive: (p: Params) => ({ mode: 'cents', f0: F0, N: p.N, lams: [0.25, 0.5, 0.75, 0.9, 1] }),
render: (d: CentsOut) => ,
summary: (d: CentsOut) => [
{ label: 'measured minus predicted, largest', value: fmtCents(d.agree) },
{ label: 'error at λ = 1, largest', value: fmtCents(Math.max(...d.meas[d.meas.length - 1].map((v) => Math.abs(v ?? 0)))) },
],
},
{
id: 'click',
title: 'The sound of numerical dispersion',
caption:
'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.',
driver,
script,
controls: [
{
kind: 'select',
key: 'lam',
label: 'Courant number λ',
options: [
{ value: 1, label: '1 (the magic time step)' },
{ value: 0.75, label: '0.75' },
{ value: 0.5, label: '0.5' },
{ value: 0.25, label: '0.25' },
],
},
],
defaults: { lam: 0.5 },
derive: (p: Params) => ({ mode: 'click', fs: FS, c: 343, L: 17.15, lam: p.lam, T: 1.2, sigt: 3e-5 }),
render: (d: ClickOut) => ,
summary: (d: ClickOut) => [
{ label: 'grid intervals', value: String(d.N) },
{ label: 'highest frequency the grid carries', value: `${(d.fcut / 1000).toFixed(2)} kHz` },
{ label: 'relative rms error', value: fmtErr(d.rel) },
],
},
],
}