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) }, ], }, ], }