Interactive tutorial on the theory and the solver
A "How it works" button, also reachable as #how-it-works, opens a
tutorial that walks from the physics of Samejima (2021) down to the step
loop the app executes, keyed throughout to the paper's own equation
numbers so the two can be read side by side. Eleven sections: what a
render computes, why a linear model cannot make a crash, the von Karman
shell equations term by term, the washer and free-edge boundary
conditions, the mallet's head/stick/grip/hand chain, the grid and its
virtual nodes, the azimuthal block-diagonalization, the implicit time
step, the output stage and what its pickup signal is not, and a plain
accounting of every departure this implementation makes from the paper.
Eight figures are interactive, and four of the toy systems render audio
in the page rather than only plotting: a Duffing oscillator whose pitch
glide can be heard at any strike strength, a Fermi-Pasta-Ulam chain
retuned so its lowest mode sits at 220 Hz (quiet strikes give a plain
tone, hard ones audibly brighten after the onset), a mallet strike on a
five-mode target where felt thuds and a wood tip pings, and an A/B of a
mode's true pitch against the pitch the implicit scheme produces for it.
Samejima's paper is credited in the tutorial header, in its references
with the DOI, and in the parameter sidebar of the app itself, which had
carried no citation. The tutorial loads as its own chunk, so the KaTeX
it needs stays out of the main bundle.
17 changed files+2462−11
README.mdmodified+1−1View file
@@ -9,7 +9,7 @@ The sibling [cymbal](../cymbal) repository models the same instrument by an enti
99 | Path | What it is |
1010 |---|---|
1111 | `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
12-| `webapp/` | Interactive browser app that executes the MATLAB code client-side |
12+| `webapp/` | Interactive browser app that executes the MATLAB code client-side; its "How it works" button opens an interactive tutorial on the theory and the solver, with figures and in-page computed audio demos |
1313
1414 The MATLAB code runs from the command line with `cd matlab && numbl run cymbal2_demo.m`, or `cymbal2_validate.m` to run the checks. The web app is a Vite project; see `webapp/README.md`.
1515
webapp/README.mdmodified+2−0View file
@@ -4,6 +4,8 @@ Interactive browser implementation of the physical model described in `../README
44
55 The signal chain: a prescribed hand stroke drives an Euler-Bernoulli stick with a Hertz-contact mallet head against a von Karman shallow spherical shell on a washer support, integrated step by step in the time domain; the output is the shell's normal acceleration at a pickup point, decimated and normalized.
66
7+The "How it works" button (also linkable as `#how-it-works`) opens an interactive tutorial covering the theory of Samejima (2021) and the implementation: the von Karman equations, the washer and mallet subsystems, the grid, the per-wavenumber solves, and the implicit time stepping, with figures whose parameters can be dragged and several of whose toy systems render audio in the page (a Duffing pitch glide, a sonified Fermi-Pasta-Ulam cascade, a playable mallet strike on a modal target, and the scheme's frequency detuning). The tutorial cites the paper's equation numbers throughout; its implementation lives in `src/tutorial/`.
8+
79 ## Usage
810
911 ```
webapp/package-lock.jsonmodified+26−0View file
@@ -15,6 +15,7 @@
1515 "@codemirror/theme-one-dark": "^6.1.3",
1616 "@codemirror/view": "^6.43.9",
1717 "codemirror": "^6.0.2",
18+ "katex": "^0.18.5",
1819 "numbl": "^0.4.19",
1920 "react": "^18.3.1",
2021 "react-dom": "^18.3.1"
@@ -1678,6 +1679,15 @@
16781679 "url": "https://github.com/sponsors/wooorm"
16791680 }
16801681 },
1682+ "node_modules/commander": {
1683+ "version": "15.0.0",
1684+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
1685+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
1686+ "license": "MIT",
1687+ "engines": {
1688+ "node": ">=22.12.0"
1689+ }
1690+ },
16811691 "node_modules/convert-source-map": {
16821692 "version": "2.0.0",
16831693 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2083,6 +2093,22 @@
20832093 "node": ">=6"
20842094 }
20852095 },
2096+ "node_modules/katex": {
2097+ "version": "0.18.5",
2098+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.5.tgz",
2099+ "integrity": "sha512-1FU5H3RjGJVj6GT9fMjf2uMIXmPQp232aXS3UEeQzf0dxefHg/CKMvNB3DuOC46LFG/E5PNxrE8dOwB782FDGg==",
2100+ "funding": [
2101+ "https://opencollective.com/katex",
2102+ "https://github.com/sponsors/katex"
2103+ ],
2104+ "license": "MIT",
2105+ "dependencies": {
2106+ "commander": "^15.0.0"
2107+ },
2108+ "bin": {
2109+ "katex": "cli.js"
2110+ }
2111+ },
20862112 "node_modules/koffi": {
20872113 "version": "2.16.3",
20882114 "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz",
webapp/package.jsonmodified+1−0View file
@@ -17,6 +17,7 @@
1717 "@codemirror/theme-one-dark": "^6.1.3",
1818 "@codemirror/view": "^6.43.9",
1919 "codemirror": "^6.0.2",
20+ "katex": "^0.18.5",
2021 "numbl": "^0.4.19",
2122 "react": "^18.3.1",
2223 "react-dom": "^18.3.1"
webapp/src/App.tsxmodified+59−10View file
@@ -1,4 +1,4 @@
1-import { useCallback, useEffect, useRef, useState } from 'react';
1+import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
22 import { DEFAULTS, PRESETS, applyPreset, matchPresetId, loadParams, saveParams, type Cymbal2Params } from './params';
33 import { DEFAULT_MFILES, type MFile } from './mfiles';
44 import { useRenderer } from './hooks/useRenderer';
@@ -12,12 +12,31 @@ import SpectrumPlot from './components/plots/SpectrumPlot';
1212 import SpectrogramPlot from './components/plots/SpectrogramPlot';
1313 import ContactPlot from './components/plots/ContactPlot';
1414
15+// The tutorial carries KaTeX and its own figures; load it only when opened.
16+const Tutorial = lazy(() => import('./tutorial/Tutorial'));
17+
1518 export default function App() {
1619 const [params, setParams] = useState<Cymbal2Params>(() => loadParams());
1720 // Editable copy of the MATLAB implementation. Deliberately not persisted:
1821 // a reload restores the shipped code.
1922 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
2023 const [codeOpen, setCodeOpen] = useState(false);
24+ // The tutorial is linkable: #how-it-works opens it (and its internal
25+ // anchors keep it open across a reload).
26+ const [tutorialOpen, setTutorialOpenRaw] = useState(
27+ () => window.location.hash === '#how-it-works' || window.location.hash.startsWith('#tut-'),
28+ );
29+ const setTutorialOpen = useCallback((open: boolean) => {
30+ setTutorialOpenRaw(open);
31+ try {
32+ const url = open
33+ ? '#how-it-works'
34+ : window.location.pathname + window.location.search;
35+ window.history.replaceState(null, '', url);
36+ } catch {
37+ // sandboxed iframe or similar; the hash is a convenience only
38+ }
39+ }, []);
2140 const { result, error, busy, stage, elapsedS, renderPreview, renderFull, cancel } = useRenderer(
2241 params,
2342 mfiles,
@@ -61,6 +80,12 @@ export default function App() {
6180 const [looping, setLooping] = useState(false);
6281 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
6382
83+ // A failed render clears the result, so a loop left running has nothing to
84+ // play; drop out of loop mode rather than leaving the button lit.
85+ useEffect(() => {
86+ if (!result) setLooping(false);
87+ }, [result]);
88+
6489 const getAudioCtx = useCallback((sampleRate: number) => {
6590 let ctx = audioCtxRef.current;
6691 if (!ctx || ctx.state === 'closed') {
@@ -125,7 +150,7 @@ export default function App() {
125150 className="primary"
126151 onClick={renderPreview}
127152 disabled={busy}
128- title="Coarse azimuthal grid, no oversampling, short tail — tens of seconds"
153+ title="No oversampling, full duration and grid otherwise — about half the cost of a full render"
129154 >
130155 Render preview
131156 </button>
@@ -152,7 +177,7 @@ export default function App() {
152177 <button
153178 className={`loop ${looping ? 'active' : ''}`}
154179 onClick={() => setLooping((v) => !v)}
155- disabled={!result && !looping}
180+ disabled={!result}
156181 title="Repeat the last render continuously"
157182 >
158183 ⟳ Loop{looping ? ' on' : ''}
@@ -168,6 +193,13 @@ export default function App() {
168193 >
169194 {'{ }'} Code{codeEdited ? ' •' : ''}
170195 </button>
196+ <button
197+ className={tutorialOpen ? 'howto active' : 'howto'}
198+ onClick={() => setTutorialOpen(!tutorialOpen)}
199+ title="Interactive tutorial: the theory (Samejima 2021) and how the solver integrates it"
200+ >
201+ ? How it works
202+ </button>
171203 <div className="status">
172204 {error && <span className="badge err">code error</span>}
173205 {result && !busy && (
@@ -177,15 +209,19 @@ export default function App() {
177209 )}
178210 {busy ? (
179211 <span>
180- {stage}… {elapsedS.toFixed(0)} s (a full render is a real time-domain
181- integration; expect minutes)
212+ {stage}… {elapsedS.toFixed(0)} s
182213 </span>
183214 ) : (
184- <span>ready — renders are explicit, nothing runs on its own</span>
215+ <span>ready</span>
185216 )}
186217 </div>
187218 </div>
188- <div className="main">
219+ {tutorialOpen && (
220+ <Suspense fallback={<div className="tut-loading">Loading the tutorial…</div>}>
221+ <Tutorial onClose={() => setTutorialOpen(false)} />
222+ </Suspense>
223+ )}
224+ <div className="main" style={tutorialOpen ? { display: 'none' } : undefined}>
189225 <div className="sidebar">
190226 <div className="preset-row">
191227 <label htmlFor="preset-select">Preset</label>
@@ -209,6 +245,18 @@ export default function App() {
209245 </div>
210246 {preset && <div className="preset-note">{preset.note}</div>}
211247 <ParamPanel params={params} onChange={onChange} />
248+ <div className="sidebar-credit">
249+ Physical model: T. Samejima, “Nonlinear physical modeling sound synthesis
250+ of cymbals involving dynamics of washers and sticks/mallets,”{' '}
251+ <i>Acoust. Sci. & Tech.</i> <b>42</b>(6), 314–325 (2021).{' '}
252+ <a href="https://doi.org/10.1250/ast.42.314" target="_blank" rel="noreferrer">
253+ doi:10.1250/ast.42.314
254+ </a>
255+ <br />
256+ <button className="linklike" onClick={() => setTutorialOpen(true)}>
257+ How the solver works: an interactive tutorial →
258+ </button>
259+ </div>
212260 </div>
213261 <div className="content">
214262 {error && (
@@ -221,7 +269,8 @@ export default function App() {
221269 {error.message}
222270 </div>
223271 <div className="code-hint">
224- Showing the last successful render. Open Code to fix the source, or revert it.
272+ Nothing is playable until a render succeeds. Open Code to fix the source, or
273+ revert it.
225274 </div>
226275 </div>
227276 )}
@@ -258,11 +307,11 @@ export default function App() {
258307 </div>
259308 </>
260309 )}
261- {!result && !busy && (
310+ {!result && !busy && !error && (
262311 <div className="card wide">
263312 <h2>No render yet</h2>
264313 <div className="code-hint">
265- Press Render preview for a quick coarse pass, or Render full for the real thing.
314+ Press Render preview for a cheaper pass at the same duration, or Render full for the real thing.
266315 Unlike the sibling modal-cymbal app, every render here is a nonlinear time-domain
267316 integration of the shell, so nothing renders automatically.
268317 </div>
webapp/src/style.cssmodified+174−0View file
@@ -357,6 +357,180 @@ details.group[open] summary { border-bottom: 1px solid var(--border); }
357357 .content { grid-template-columns: 1fr; }
358358 }
359359
360+/* Sidebar credit */
361+.sidebar-credit {
362+ margin-top: 10px;
363+ padding: 8px 10px 12px;
364+ border-top: 1px solid var(--border);
365+ font-size: 11px;
366+ line-height: 1.5;
367+ color: var(--muted);
368+}
369+.sidebar-credit a { color: var(--accent2); }
370+button.linklike {
371+ background: none;
372+ border: none;
373+ padding: 0;
374+ margin-top: 4px;
375+ font-size: 11.5px;
376+ color: var(--accent);
377+ cursor: pointer;
378+}
379+button.linklike:hover { text-decoration: underline; border: none; }
380+
381+/* Tutorial */
382+.tut-loading {
383+ flex: 1;
384+ display: flex;
385+ align-items: center;
386+ justify-content: center;
387+ color: var(--muted);
388+}
389+
390+.tutorial {
391+ flex: 1;
392+ min-height: 0;
393+ display: flex;
394+ overflow-y: auto;
395+ scroll-behavior: smooth;
396+ background: var(--bg);
397+}
398+
399+.tut-toc {
400+ position: sticky;
401+ top: 0;
402+ align-self: flex-start;
403+ width: 230px;
404+ min-width: 230px;
405+ padding: 26px 8px 16px 16px;
406+ display: none;
407+ flex-direction: column;
408+ gap: 2px;
409+}
410+@media (min-width: 1050px) {
411+ .tut-toc { display: flex; }
412+}
413+.tut-toc-head {
414+ font-size: 12px;
415+ text-transform: uppercase;
416+ letter-spacing: 0.06em;
417+ color: var(--accent);
418+ font-weight: 600;
419+ margin-bottom: 8px;
420+}
421+.tut-toc a {
422+ color: var(--muted);
423+ text-decoration: none;
424+ font-size: 12.5px;
425+ padding: 3px 6px;
426+ border-radius: 4px;
427+}
428+.tut-toc a:hover { color: var(--text); background: var(--panel2); }
429+.tut-toc button { margin-top: 14px; font-size: 12px; }
430+
431+.tut-body {
432+ flex: 1;
433+ max-width: 860px;
434+ padding: 24px 28px 80px;
435+ font-size: 14.5px;
436+ line-height: 1.65;
437+}
438+.tut-body section { scroll-margin-top: 14px; }
439+.tut-header h1 {
440+ font-size: 26px;
441+ margin: 6px 0 12px;
442+ color: var(--accent);
443+}
444+.tut-lede {
445+ font-size: 15px;
446+ color: var(--text);
447+}
448+.tut-body h2 {
449+ font-size: 19px;
450+ margin: 40px 0 10px;
451+ color: var(--accent);
452+ border-bottom: 1px solid var(--border);
453+ padding-bottom: 6px;
454+}
455+.tut-body p { margin: 10px 0; }
456+.tut-body a { color: var(--accent2); }
457+.tut-body code {
458+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
459+ font-size: 0.92em;
460+ background: var(--panel2);
461+ border: 1px solid var(--border);
462+ border-radius: 4px;
463+ padding: 0 4px;
464+}
465+
466+.tut-eq {
467+ display: flex;
468+ align-items: center;
469+ gap: 10px;
470+ margin: 14px 0;
471+ overflow-x: auto;
472+ padding: 4px 2px;
473+}
474+.tut-eq-body { flex: 1; text-align: center; }
475+.tut-eq-tag { color: var(--muted); font-size: 13px; white-space: nowrap; }
476+.tut-eq .katex { font-size: 1.02em; }
477+
478+.tut-fig {
479+ margin: 18px 0;
480+ padding: 10px 12px 12px;
481+ background: var(--panel);
482+ border: 1px solid var(--border);
483+ border-radius: 8px;
484+}
485+.tut-fig canvas { display: block; width: 100%; border-radius: 4px; }
486+.tut-fig figcaption {
487+ font-size: 12.5px;
488+ line-height: 1.55;
489+ color: var(--muted);
490+ margin-top: 8px;
491+}
492+.tut-fig figcaption i, .tut-fig figcaption b { color: var(--text); }
493+
494+.tut-controls {
495+ display: flex;
496+ flex-wrap: wrap;
497+ gap: 12px 22px;
498+ align-items: center;
499+ margin-top: 8px;
500+}
501+.tut-controls button { font-size: 12px; padding: 4px 10px; }
502+.tut-controls button.tut-active { border-color: var(--accent2); color: var(--accent2); }
503+.tut-slider {
504+ display: flex;
505+ align-items: center;
506+ gap: 8px;
507+ font-size: 12px;
508+ color: var(--muted);
509+}
510+.tut-slider input[type='range'] { width: 150px; accent-color: var(--accent2); }
511+.tut-slider-name { white-space: nowrap; }
512+.tut-slider-val {
513+ color: var(--text);
514+ font-variant-numeric: tabular-nums;
515+ min-width: 62px;
516+}
517+
518+.tut-pipe {
519+ width: 100%;
520+ height: auto;
521+ margin: 10px 0;
522+ background: var(--panel);
523+ border: 1px solid var(--border);
524+ border-radius: 8px;
525+}
526+
527+.tut-steps { padding-left: 22px; }
528+.tut-steps li { margin: 7px 0; }
529+.tut-list { padding-left: 22px; }
530+.tut-list li { margin: 9px 0; }
531+.tut-refs { padding-left: 22px; font-size: 13.5px; }
532+.tut-refs li { margin: 8px 0; }
533+
360534 /* Strike picker */
361535 .strike-picker { position: relative; }
362536 .strike-picker canvas { display: block; width: 100%; }
webapp/src/tutorial/Tutorial.tsxadded+667−0View file
@@ -0,0 +1,667 @@
1+// Interactive tutorial: how the cymbal2 solver and renderer work, from the
2+// physics of Samejima (2021) down to the step loop the app executes. The
3+// prose cites the paper's equation numbers so it can be read side by side
4+// with the paper itself.
5+
6+import { M, Eq } from './math';
7+import GeometryFig from './figures/GeometryFig';
8+import PitchGlideFig from './figures/PitchGlideFig';
9+import CascadeFig from './figures/CascadeFig';
10+import GridFig from './figures/GridFig';
11+import WavenumberFig from './figures/WavenumberFig';
12+import StabilityFig from './figures/StabilityFig';
13+import HertzFig from './figures/HertzFig';
14+import WasherFig from './figures/WasherFig';
15+
16+const PAPER_DOI = 'https://doi.org/10.1250/ast.42.314';
17+
18+const SECTIONS = [
19+ { id: 'tut-overview', n: 1, title: 'What a render computes' },
20+ { id: 'tut-nonlinear', n: 2, title: 'Why the model must be nonlinear' },
21+ { id: 'tut-shell', n: 3, title: 'The shell and the von Kármán equations' },
22+ { id: 'tut-boundaries', n: 4, title: 'The washer and the free edge' },
23+ { id: 'tut-mallet', n: 5, title: 'The mallet: head, stick, grip, hand' },
24+ { id: 'tut-grid', n: 6, title: 'Discretizing space: the grid' },
25+ { id: 'tut-wavenumber', n: 7, title: 'One wavenumber at a time' },
26+ { id: 'tut-time', n: 8, title: 'Marching in time' },
27+ { id: 'tut-sound', n: 9, title: 'From motion to sound' },
28+ { id: 'tut-departures', n: 10, title: 'Where the implementation departs from the paper' },
29+ { id: 'tut-refs', n: 11, title: 'Credits and references' },
30+];
31+
32+function PipelineDiagram() {
33+ const box = (
34+ x: number, y: number, w: number, h: number, name: string, sub: string[],
35+ ) => (
36+ <g key={name}>
37+ <rect x={x} y={y} width={w} height={h} rx={6} fill="#23272f" stroke="#31363f" />
38+ <text x={x + w / 2} y={y + 17} textAnchor="middle" fontSize="11.5"
39+ fontFamily="ui-monospace, Menlo, monospace" fill="#e8a33d">{name}</text>
40+ {sub.map((s, i) => (
41+ <text key={i} x={x + w / 2} y={y + 32 + i * 13} textAnchor="middle" fontSize="10.5"
42+ fill="#8b93a1">{s}</text>
43+ ))}
44+ </g>
45+ );
46+ const arrow = (x1: number, y1: number, x2: number, y2: number) => (
47+ <g key={`${x1}-${y1}-${x2}-${y2}`}>
48+ <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#8b93a1" strokeWidth={1.3}
49+ markerEnd="url(#tutArrow)" />
50+ </g>
51+ );
52+ return (
53+ <svg viewBox="0 0 880 210" className="tut-pipe" role="img"
54+ aria-label="Render pipeline: grid, per-wavenumber operators, assembled system and mallet feed the time loop, whose pickup signal goes through the output stage">
55+ <defs>
56+ <marker id="tutArrow" markerWidth="8" markerHeight="8" refX="7" refY="3"
57+ orient="auto">
58+ <path d="M0,0 L7,3 L0,6 z" fill="#8b93a1" />
59+ </marker>
60+ </defs>
61+ {box(6, 24, 96, 42, 'parameters', ['shell, mallet,', 'stroke'])}
62+ {box(134, 18, 142, 54, 'cymbal2_grid.m', ['polar grid,', 'azimuthal transform'])}
63+ {box(308, 18, 150, 54, 'cymbal2_perm.m', ['radial operators', 'per wavenumber'])}
64+ {box(490, 12, 168, 66, 'cymbal2_system.m', ['implicit update +', 'boundary rows,', 'inverted'])}
65+ {box(134, 130, 168, 54, 'cymbal2_mallet.m', ['stick beam FEM,', 'grip, head mass'])}
66+ {box(490, 120, 168, 70, 'cymbal2_sim.m', ['time loop: hand,', 'contact, von Kármán', 'shell, step by step'])}
67+ {box(700, 126, 172, 58, 'cymbal2_output.m', ['DC block, decimate,', 'normalize → audio'])}
68+ {arrow(102, 45, 130, 45)}
69+ {arrow(276, 45, 304, 45)}
70+ {arrow(458, 45, 486, 45)}
71+ {arrow(574, 78, 574, 116)}
72+ {arrow(302, 157, 486, 157)}
73+ {arrow(658, 155, 696, 155)}
74+ </svg>
75+ );
76+}
77+
78+function MalletDiagram() {
79+ return (
80+ <svg viewBox="0 0 640 210" className="tut-pipe" role="img"
81+ aria-label="Mallet configuration: hand at a rotational spring drives an elastic beam whose tip carries the head, which meets the shell across a gap">
82+ {/* hand and rotational spring */}
83+ <circle cx={70} cy={70} r={16} fill="none" stroke="#e8a33d" strokeWidth={2}
84+ strokeDasharray="20 6" />
85+ <text x={70} y={34} textAnchor="middle" fontSize="11" fill="#e8a33d">rotational spring K_R</text>
86+ <text x={70} y={122} textAnchor="middle" fontSize="11" fill="#8b93a1">hand: u_H(t) prescribed</text>
87+ <line x1={70} y1={92} x2={70} y2={100} stroke="#8b93a1" markerEnd="none" />
88+ {/* beam */}
89+ <path d="M 86 70 C 220 62, 380 62, 500 78" fill="none" stroke="#d6dae2" strokeWidth={4} />
90+ <text x={300} y={48} textAnchor="middle" fontSize="11" fill="#d6dae2">
91+ stick: Euler–Bernoulli beam ζ(x, t)
92+ </text>
93+ {/* head */}
94+ <circle cx={512} cy={86} r={13} fill="#e8a33d" />
95+ <text x={512} y={62} textAnchor="middle" fontSize="11" fill="#e8a33d">head M_M, u_M</text>
96+ {/* gap and force */}
97+ <line x1={512} y1={101} x2={512} y2={124} stroke="#4dd0e1" strokeDasharray="3 3" />
98+ <text x={528} y={116} fontSize="11" fill="#4dd0e1">ξ (compression)</text>
99+ <text x={430} y={116} fontSize="11" fill="#4dd0e1">f = K_M [ξ]₊^α</text>
100+ {/* shell */}
101+ <path d="M 260 168 Q 420 118 630 158" fill="none" stroke="#c9a227" strokeWidth={4} />
102+ <text x={330} y={190} textAnchor="middle" fontSize="11" fill="#c9a227">shell u(r, θ, t)</text>
103+ </svg>
104+ );
105+}
106+
107+export default function Tutorial({ onClose }: { onClose: () => void }) {
108+ return (
109+ <div className="tutorial">
110+ <nav className="tut-toc">
111+ <div className="tut-toc-head">How it works</div>
112+ {SECTIONS.map((s) => (
113+ <a key={s.id} href={`#${s.id}`}>
114+ {s.n}. {s.title}
115+ </a>
116+ ))}
117+ <button onClick={onClose}>← Back to the simulator</button>
118+ </nav>
119+
120+ <div className="tut-body">
121+ <header className="tut-header">
122+ <h1>How the cymbal is computed</h1>
123+ <p className="tut-lede">
124+ An interactive walk through the theory and the implementation behind this
125+ simulator. The physical model is due to <strong>Toshiya Samejima</strong> (Faculty
126+ of Design, Kyushu University):{' '}
127+ <a href={PAPER_DOI} target="_blank" rel="noreferrer">
128+ “Nonlinear physical modeling sound synthesis of cymbals involving dynamics
129+ of washers and sticks/mallets,” <i>Acoustical Science and Technology</i>{' '}
130+ <b>42</b>(6), 314–325 (2021)
131+ </a>
132+ , which itself builds on Bilbao's nonlinear shell synthesis (2010). Equation
133+ numbers below, written (1), (20), and so on, are the paper's, so this page can be
134+ read beside it. Where the implementation departs from the paper in order to run
135+ stably or quickly, Section 10 says exactly how.
136+ </p>
137+ <button className="primary" onClick={onClose}>← Back to the simulator</button>
138+ </header>
139+
140+ {/* ---------------- 1 ---------------- */}
141+ <section id="tut-overview">
142+ <h2>1. What a render computes</h2>
143+ <p>
144+ Nothing in this app is a recording, an oscillator bank, or a filter tuned to sound
145+ like a cymbal. Pressing <i>Render</i> integrates the equations of motion of three
146+ coupled mechanical systems, sample by sample, several hundred thousand times: a
147+ thin bronze shell (the cymbal itself), the felt washer it is mounted on, and the
148+ mallet striking it, the mallet being a flexible stick held by a moving hand with
149+ an elastic head on its tip. The sound you hear is the vertical acceleration of one
150+ point on the shell's surface, read off the simulation like a contact pickup.
151+ </p>
152+ <p>
153+ The whole computation is written in MATLAB syntax, in the <code>.m</code> files
154+ you can open (and edit) with the app's <i>Code</i> button, and runs in your
155+ browser. Each stage lives in its own file:
156+ </p>
157+ <PipelineDiagram />
158+ <p>
159+ The first three boxes are precomputation: they build the grid and assemble, then
160+ invert, the small matrices the time loop applies at every step. The loop itself,
161+ in <code>cymbal2_sim.m</code>, is where the minutes go (or the seconds, when the
162+ browser exposes WebGPU and the loop runs on the GPU). The sections below follow
163+ this pipeline: first the physics being solved (Sections 2 to 5), then how it is
164+ discretized and integrated (Sections 6 to 8), then how the motion becomes audio
165+ (Section 9).
166+ </p>
167+ </section>
168+
169+ {/* ---------------- 2 ---------------- */}
170+ <section id="tut-nonlinear">
171+ <h2>2. Why the model must be nonlinear</h2>
172+ <p>
173+ A linear model of a struck plate is a fixed set of modes: the strike decides how
174+ much energy each mode receives, and from then on each rings at its own constant
175+ frequency and decays. Whatever spectrum exists at the moment of impact is the
176+ spectrum forever after, only quieter. A real cymbal does something a linear model
177+ cannot: struck hard, its sound <i>changes shape</i> after the strike. Pitches
178+ glide, and the bright, noise-like wash of a crash builds up over several hundred
179+ milliseconds, well after the stick has left. Chaigne, Touzé and Thomas
180+ called cymbals “nonlinear percussion instruments” for this reason, and
181+ Samejima's paper opens with the same observation.
182+ </p>
183+ <p>
184+ The mechanism is geometric. A flat plate can bend without stretching, but a plate
185+ deflected into a curve, or a shell deflected at all, must stretch its own
186+ mid-surface, and the membrane tension this creates acts back on the bending
187+ motion. The effect grows as the square of the deflection measured in plate
188+ thicknesses: negligible for a whisper-quiet tap, dominant for a crash, where the
189+ shell moves by tens of thicknesses. Two small experiments show what this coupling
190+ does, and both are audible: each play button integrates the system's equations
191+ right here in the page and renders the result. The first is the simplest system
192+ that has it, a single oscillator whose spring stiffens with displacement:
193+ </p>
194+ <PitchGlideFig />
195+ <p>
196+ The second experiment shows the other, more consequential effect: nonlinearity
197+ couples modes to each other, so energy placed low in the spectrum migrates upward.
198+ Here is a chain of masses with a quadratic geometric term in its springs (the
199+ Fermi–Pasta–Ulam system), started with all of its energy in its lowest
200+ mode:
201+ </p>
202+ <CascadeFig />
203+ <p>
204+ Both effects, the amplitude-dependent detuning and the upward cascade, emerge in
205+ the shell model of the next section from a single nonlinear term. Neither is put
206+ in by hand. This is the sense in which the crash is computed rather than modeled:
207+ hit the simulated shell gently and it rings like a bell; hit it hard and the
208+ cascade fills the spectrum, because the equations say so.
209+ </p>
210+ </section>
211+
212+ {/* ---------------- 3 ---------------- */}
213+ <section id="tut-shell">
214+ <h2>3. The shell and the von Kármán equations</h2>
215+ <p>
216+ A real cymbal has a raised cup and a gently tapered bow. Following Bilbao (2010),
217+ the paper replaces both by one <i>shallow spherical cap</i> of radius{' '}
218+ <M>a</M>, uniform thickness <M>h</M>, and a small rise at the center. The rise
219+ fixes the radius <M>R_s</M> of the sphere the cap sits on, and that single
220+ curvature is all the geometry the model carries:
221+ </p>
222+ <GeometryFig />
223+ <p>
224+ The price of this simplification is real and audible: the linear spectrum is that
225+ of an idealized cap, not of a hammered, lathed instrument, and no irregularity
226+ appears anywhere in the model. What it buys is a formulation in which the strong
227+ nonlinearity is actually computable. The transverse deflection{' '}
228+ <M>u(r, \theta, t)</M> obeys the von Kármán shallow-shell equations
229+ (the paper's Eqs. (1) and (2)):
230+ </p>
231+ <Eq tag="(1)">
232+ {String.raw`\rho h\,\frac{\partial^2 u}{\partial t^2}
233+ = -D\nabla^4 u \;+\; L(\phi, u)\;-\;\frac{1}{R_s}\nabla^2\phi
234+ \;-\;2\sigma_0\rho h\,\frac{\partial u}{\partial t}
235+ \;+\;2\sigma_1 a^2 \rho h\,\nabla^2\frac{\partial u}{\partial t}
236+ \;+\;\epsilon f`}
237+ </Eq>
238+ <Eq tag="(2)">
239+ {String.raw`\nabla^4\phi = -\frac{Eh}{2}\,L(u, u)\;+\;\frac{Eh}{R_s}\nabla^2 u`}
240+ </Eq>
241+ <p>
242+ Reading Eq. (1) term by term: <M>\rho h</M> is the mass per unit area;{' '}
243+ <M>-D\nabla^4 u</M> is ordinary linear plate bending, with{' '}
244+ <M>D = Eh^3/12(1-\nu^2)</M> the bending stiffness and <M>\nabla^4</M> the
245+ biharmonic operator (the Laplacian applied twice); the two damping terms carry the
246+ fitted loss constants <M>\sigma_0</M> (frequency-independent) and{' '}
247+ <M>\sigma_1</M> (growing with the squared wavenumber, so high frequencies die
248+ faster, as they do on real cymbals); and <M>\epsilon f</M> is the mallet's contact
249+ force, applied at the strike point.
250+ </p>
251+ <p>
252+ The remaining two terms are the shell physics. The function <M>\phi</M> is the{' '}
253+ <i>Airy stress function</i>: a single scalar field whose derivatives encode the
254+ membrane (in-plane) stress everywhere in the shell. Eq. (2) says where that stress
255+ comes from: deflecting the shell stretches it, through the curvature (the{' '}
256+ <M>\nabla^2 u / R_s</M> term) and through the quadratic operator{' '}
257+ <M>L(u,u)</M>. Eq. (1) says what the stress does: it feeds back on the bending
258+ motion, again through the curvature and through <M>L(\phi, u)</M>. The bracket{' '}
259+ <M>L</M> is a fixed bilinear form; in polar coordinates,
260+ </p>
261+ <Eq>
262+ {String.raw`L(\phi, u) = \frac{\partial^2\phi}{\partial r^2}
263+ \left(\frac{1}{r}\frac{\partial u}{\partial r} + \frac{1}{r^2}\frac{\partial^2 u}{\partial\theta^2}\right)
264+ + \frac{\partial^2 u}{\partial r^2}
265+ \left(\frac{1}{r}\frac{\partial\phi}{\partial r} + \frac{1}{r^2}\frac{\partial^2\phi}{\partial\theta^2}\right)
266+ - 2\left(\frac{1}{r}\frac{\partial^2\phi}{\partial r\,\partial\theta} - \frac{1}{r^2}\frac{\partial\phi}{\partial\theta}\right)
267+ \left(\frac{1}{r}\frac{\partial^2 u}{\partial r\,\partial\theta} - \frac{1}{r^2}\frac{\partial u}{\partial\theta}\right)`}
268+ </Eq>
269+ <p>
270+ This closed loop, deflection creates stress (2), stress deflects (1), is the
271+ engine of everything in Section 2. Because <M>L(u,u)</M> is quadratic in the
272+ deflection, the feedback is negligible at small amplitude and dominant at large,
273+ and because <M>L</M> mixes radial and azimuthal derivatives, it couples modes of
274+ different shape, which is what drives the cascade. The paper works with a
275+ dimensionless form of these equations (its Eqs. (3) to (5)); the app integrates
276+ the dimensional form above, which is the same content in physical units.
277+ </p>
278+ </section>
279+
280+ {/* ---------------- 4 ---------------- */}
281+ <section id="tut-boundaries">
282+ <h2>4. The washer and the free edge</h2>
283+ <p>
284+ Fourth-order equations need two boundary conditions at each edge. At the outer rim{' '}
285+ <M>r = a</M> the shell is free, so the bending moment and the (Kelvin–Kirchhoff)
286+ shear force both vanish (the paper's Eqs. (6) and (7)):
287+ </p>
288+ <Eq tag="(6), (7)">
289+ {String.raw`\left.\frac{\partial^2 u}{\partial r^2} + \nu\Bigl(\frac{1}{r}\frac{\partial u}{\partial r} + \frac{1}{r^2}\frac{\partial^2 u}{\partial\theta^2}\Bigr)\right|_{r=a} = 0,
290+ \qquad
291+ \left.\frac{\partial^3 u}{\partial r^3} + \frac{1}{r}\frac{\partial^2 u}{\partial r^2} - \frac{1}{r^2}\frac{\partial u}{\partial r}
292+ + \frac{\nu - 3}{r^3}\frac{\partial^2 u}{\partial\theta^2}
293+ + \frac{2-\nu}{r^2}\frac{\partial^3 u}{\partial r\,\partial\theta^2}\right|_{r=a} = 0`}
294+ </Eq>
295+ <p>
296+ The inner boundary is the paper's first main contribution. Earlier work (Bilbao
297+ 2010) clamped the shell rigidly at its center, but a mounted cymbal is nothing
298+ like clamped: it sits on a felt washer, and visibly bounces and rocks on it when
299+ struck. Samejima models the washer as a mass–damper–spring per unit
300+ length of the mounting circle <M>r = c</M>, and imposes it as a boundary
301+ condition: the bending moment vanishes there (the shell may tilt freely), and the
302+ shear force balances the washer's reaction (Eqs. (14) and (15)):
303+ </p>
304+ <Eq tag="(15)">
305+ {String.raw`-D\,[\text{shear expression}]_{\,r=c}
306+ \;=\; M_\mathrm{W}\frac{\partial^2 u}{\partial t^2}
307+ \;+\; R_\mathrm{W}\frac{\partial u}{\partial t}
308+ \;+\; K_\mathrm{W}\,u`}
309+ </Eq>
310+ <p>
311+ The paper measures the stiffness of an actual washer,{' '}
312+ <M>{String.raw`K_\mathrm{W} = 1.65\times 10^5\ \mathrm{N/m}`}</M> per meter, and sets the mass
313+ and damping to zero. The consequences are on two scales. The whole shell acquires
314+ rigid-body modes on its mount:
315+ </p>
316+ <WasherFig />
317+ <p>
318+ And the audible modes see a compliant inner edge instead of a rigid one, which
319+ shifts the low-order resonances; the paper's Fig. 8 shows exactly this between its
320+ clamped and impedance-supported calculations, and its Fig. 9 shows the mode shapes
321+ that appear only when the support may move, such as the (0,0) mode in which the
322+ cymbal moves as one piece. The app defaults to the washer support;{' '}
323+ <code>support_clamped</code> restores the rigid clamp of the earlier literature
324+ (Eqs. (12), (13)) for comparison.
325+ </p>
326+ </section>
327+
328+ {/* ---------------- 5 ---------------- */}
329+ <section id="tut-mallet">
330+ <h2>5. The mallet: head, stick, grip, hand</h2>
331+ <p>
332+ The paper's second main contribution is taking the striking mechanism seriously.
333+ Most synthesis work injects a prescribed force pulse (a raised cosine, say) at the
334+ strike point. Here the force is not prescribed at all: it is whatever comes out of
335+ a mallet, itself a dynamical system, colliding with the moving shell.
336+ </p>
337+ <MalletDiagram />
338+ <p>
339+ The <b>head</b> (mass <M>{String.raw`M_\mathrm{M}`}</M>, 28 g for the paper's measured mallet)
340+ is an elastic body whose compression <M>\xi</M> against the shell produces a
341+ Hertz-type force (Eq. (32)):
342+ </p>
343+ <Eq tag="(32)">
344+ {String.raw`f_{\mathrm{M}1} = K_\mathrm{M}\,\bigl[\,\xi\,\bigr]_+^{\,\alpha},
345+ \qquad \xi = u - (u_\mathrm{M} - d_\mathrm{M})`}
346+ </Eq>
347+ <p>
348+ where <M>[\cdot]_+</M> keeps only positive values: the head pushes when
349+ compressed and lets go when it separates, and the same force acts back on the
350+ shell (Eq. (33)). This one-sided power law is the standard model for
351+ hammer–string and mallet–membrane contact (Chaigne & Askenfelt
352+ 1994; Rhaouti et al. 1999), which the paper adopts for the cymbal:
353+ </p>
354+ <HertzFig />
355+ <p>
356+ The <b>stick</b> is an Euler–Bernoulli beam (Eq. (35)), discretized by the
357+ finite element method into cubic beam elements (Eq. (38)), with the head attached
358+ at its tip. The <b>grip</b> is the boundary condition at the other end, and it is
359+ the paper's third experiment: a tight grip is a clamp (displacement and rotation
360+ both follow the hand), a loose grip lets the stick pivot against a rotational
361+ spring <M>{String.raw`K_\mathrm{R}`}</M>, measured at 140 N·m/rad. Because the beam is
362+ compliant, the tip whips: it arrives faster than the hand that drives it (by a
363+ factor near 1.3 at the default stroke), flexural waves reflect along the stick
364+ during contact, and the contact force develops the multiple peaks of the paper's
365+ Fig. 12. All of this is audible in the attack, and the paper connects it to the
366+ measured effect of grip tightness on cymbal tone (Takashima et al. 2018).
367+ </p>
368+ <p>
369+ The <b>hand</b> closes the system: the grip end's displacement{' '}
370+ <M>{String.raw`u_\mathrm{H}(t)`}</M> is prescribed. The paper drives it at constant acceleration
371+ to a terminal speed; this app's hand is a small state machine that accelerates,
372+ coasts, holds briefly on contact, and retracts to rest, so that repeated strokes
373+ and rolls are possible (Section 10 has the details).
374+ </p>
375+ </section>
376+
377+ {/* ---------------- 6 ---------------- */}
378+ <section id="tut-grid">
379+ <h2>6. Discretizing space: the grid</h2>
380+ <p>
381+ To compute with the equations of Section 3, the shell's annulus (washer circle to
382+ rim) is sampled on a polar grid: <M>N_r</M> intervals in radius,{' '}
383+ <M>N_\theta</M> points around the circumference (the paper's Fig. 4, and{' '}
384+ <code>cymbal2_grid.m</code> here). Spatial derivatives become central differences:
385+ for example
386+ </p>
387+ <Eq>
388+ {String.raw`\left.\frac{\partial u}{\partial r}\right|_{l}\approx\frac{u_{l+1}-u_{l-1}}{2h_r},
389+ \qquad
390+ \left.\frac{\partial^2 u}{\partial r^2}\right|_{l}\approx\frac{u_{l+1}-2u_l+u_{l-1}}{h_r^2}`}
391+ </Eq>
392+ <p>
393+ The biharmonic <M>\nabla^4</M> reaches two neighbors deep, so evaluating it on the
394+ first real line inside a boundary needs values on lines that lie <i>outside</i>{' '}
395+ the shell. The grid therefore carries two <i>virtual</i> lines beyond each
396+ boundary, and the four boundary conditions of Section 4 (two per edge) are exactly
397+ the equations that determine the values there. Nothing is wasted and nothing is
398+ free: the boundary conditions are enforced by construction, at the cost of
399+ carrying the virtual unknowns in every solve.
400+ </p>
401+ <GridFig />
402+ <p>
403+ The grid also sets what the model can resolve. The paper estimates that with its
404+ production grid (24 by 60) the computation is reliable up to roughly the (6,5)
405+ mode near 2 kHz; the crash extends far above that, but its top octaves ride on
406+ grid modes that are dispersion-warped (Section 7 shows why), which is the main
407+ respect in which the simulated crash is duller than a real one. Raising both
408+ counts sharpens it, at a cost that grows with their product.
409+ </p>
410+ </section>
411+
412+ {/* ---------------- 7 ---------------- */}
413+ <section id="tut-wavenumber">
414+ <h2>7. One wavenumber at a time</h2>
415+ <p>
416+ The implicit time step of the next section needs two elliptic solves per sample:
417+ one to find the stress function <M>\phi</M> from Eq. (2), one to advance{' '}
418+ <M>u</M>. Done directly, each is a linear system over the whole grid, about 1,500
419+ unknowns at the default resolution, several hundred thousand times per render.
420+ The paper assembles exactly these full-grid operators (its Eqs. (18), (19) and
421+ Appendix, as Kronecker products). The implementation here exploits a symmetry
422+ instead, and this is its one structural departure from the paper (Section 10):
423+ every linear operator and boundary row in the model is axisymmetric, unchanged by
424+ rotating the cymbal. Such operators cannot mix azimuthal wavenumbers, so
425+ decomposing everything into <M>\cos m\theta</M> and <M>\sin m\theta</M> patterns
426+ splits each big solve into <M>N_\theta</M> independent radial solves of size{' '}
427+ <M>N_r + 2</M>, each of which is precomputed and inverted once before the loop
428+ starts. The result of each step is identical (the decomposition is exact, and the
429+ self-test verifies it); the cost falls by about two orders of magnitude.
430+ </p>
431+ <WavenumberFig />
432+ <p>
433+ One subtlety is worth naming. On the discrete ring, differentiating twice in{' '}
434+ <M>\theta</M> multiplies the wavenumber-<M>m</M> pattern by{' '}
435+ <M>{String.raw`\lambda_2(m) = (2\cos m h_\theta - 2)/h_\theta^2`}</M> rather than
436+ by <M>-m^2</M>. At low order the two agree; toward the grid cutoff{' '}
437+ <M>m = N_\theta/2</M> the discrete value falls short, so the highest
438+ nodal-diameter modes sit flat of where physics would put them. The same happens
439+ radially. This numerical dispersion is harmless for the low modes that carry the
440+ pitch and wobble, and increasingly wrong for the top of the crash, which is why
441+ the tutorial keeps saying the top octaves are approximate.
442+ </p>
443+ <p>
444+ The nonlinear terms do mix wavenumbers, deliberately: they are the cascade. They
445+ are therefore evaluated in physical space, on the grid, and transformed to the
446+ wavenumber basis only for the solves. Every time step crosses between the two
447+ representations twice.
448+ </p>
449+ </section>
450+
451+ {/* ---------------- 8 ---------------- */}
452+ <section id="tut-time">
453+ <h2>8. Marching in time</h2>
454+ <p>
455+ Time advances in steps of <M>{String.raw`h_t = 1/(f_s \cdot \text{oversample})`}</M>,
456+ about 10 μs at the default 96 kHz simulation rate. The scheme, the paper's Eq.
457+ (20), splits the physics by stiffness. The <i>linear</i> terms, bending, curvature
458+ coupling, damping, are integrated <i>implicitly</i>: the update solves
459+ </p>
460+ <Eq tag="(20)">
461+ {String.raw`\boldsymbol{A}\,\boldsymbol{u}_{k+1} = \boldsymbol{B}\,\boldsymbol{u}_k + \boldsymbol{C}\,\boldsymbol{u}_{k-1}
462+ \;+\; h_t^2\,[\,\text{nonlinear terms} + \text{contact force}\,]_k`}
463+ </Eq>
464+ <p>
465+ where <M>\boldsymbol A</M>, <M>\boldsymbol B</M>, <M>\boldsymbol C</M> contain the
466+ biharmonic and Laplacian operators weighted by a free parameter <M>g</M> (the
467+ paper uses <M>g = 0.2498</M>), and the boundary rows of Sections 4 replace the
468+ equation-of-motion rows on the boundary and virtual lines. The washer's own
469+ dynamics, Eq. (15), is folded into two of those rows, so the support costs
470+ nothing extra. Why implicit? Because the alternative fails:
471+ </p>
472+ <StabilityFig />
473+ <p>
474+ The <i>nonlinear</i> terms and the contact force are evaluated explicitly, at the
475+ current step, because <M>L(\phi, u)</M> changes every sample and cannot be
476+ pre-inverted. This is the standard compromise (Bilbao 2009), and it has a
477+ consequence you will meet if you push the hand speed slider: an explicit quadratic
478+ term is only conditionally stable, and the condition tightens with amplitude.
479+ Strokes whose tip arrives above roughly 10 m/s drive deflections past forty plate
480+ thicknesses and the integration diverges, at any rate we tried. This is also
481+ about where the shallow-shell expansion itself stops being valid, so the model
482+ refuses more or less where its physics runs out; the renderer detects the
483+ divergence and reports it rather than emitting garbage. Less intuitively, the
484+ azimuthal resolution matters more than the time step: the cascade climbs the
485+ nodal-diameter ladder, and if <code>n_theta</code> cuts the ladder short, energy
486+ piles up at the cutoff and blows up where a full grid rides it out.
487+ </p>
488+ <p>With the operators prebuilt, one pass through the loop in{' '}
489+ <code>cymbal2_sim.m</code> is:
490+ </p>
491+ <ol className="tut-steps">
492+ <li>
493+ <b>Contact.</b> Compression <M>\xi</M> from the shell and beam-tip positions;
494+ force <M>{String.raw`f = K_\mathrm{M}[\xi]_+^\alpha`}</M> if touching, zero if
495+ not.
496+ </li>
497+ <li>
498+ <b>Mallet.</b> Advance the stick beam one step (its own implicit two-step
499+ scheme), driven by the hand at one end and the contact force at the tip.
500+ </li>
501+ <li>
502+ <b>Hand.</b> Advance the stroke state machine (accelerate, coast, hold,
503+ retract).
504+ </li>
505+ <li>
506+ <b>Stress function.</b> Evaluate <M>L(u,u)</M> and <M>\nabla^2 u</M> on the
507+ grid, transform to wavenumbers, solve Eq. (2) for <M>\phi</M> (one small radial
508+ solve per wavenumber), transform back.
509+ </li>
510+ <li>
511+ <b>Nonlinear forcing.</b> Evaluate <M>L(\phi, u)</M> and the membrane term on
512+ the grid; inject the contact force at the strike node.
513+ </li>
514+ <li>
515+ <b>Shell.</b> Transform the total forcing, apply the prebuilt{' '}
516+ <M>{String.raw`\boldsymbol A^{-1}\boldsymbol B`}</M>,{' '}
517+ <M>{String.raw`\boldsymbol A^{-1}\boldsymbol C`}</M>,{' '}
518+ <M>{String.raw`\boldsymbol A^{-1}`}</M> per wavenumber, transform back:{' '}
519+ <M>{String.raw`\boldsymbol u_{k+1}`}</M>.
520+ </li>
521+ <li>
522+ <b>Pickup.</b> Record the second time difference of <M>u</M> at the pickup
523+ node: the acceleration sample.
524+ </li>
525+ </ol>
526+ <p>
527+ A full render at the defaults is about 580,000 such steps. In the browser the
528+ same loop runs either through the numbl interpreter or, when WebGPU is available,
529+ as a sequence of GPU compute passes per step, which is roughly twenty times
530+ faster; the diagnostics panel reports which path a render took.
531+ </p>
532+ </section>
533+
534+ {/* ---------------- 9 ---------------- */}
535+ <section id="tut-sound">
536+ <h2>9. From motion to sound</h2>
537+ <p>
538+ The rendered signal is the shell's normal acceleration at the pickup point,
539+ just as in the paper's numerical experiments (its output point is{' '}
540+ <M>\bar r = 0.6</M> on the meridian opposite the strike, and that is the app's
541+ default too). <code>cymbal2_output.m</code> conditions it: a gentle DC blocker
542+ removes the quasi-static offset the washer relaxation leaves; a windowed-sinc
543+ filter anti-aliases and decimates from the simulation rate down to the output
544+ rate; a short fade closes the tail; and the result is normalized to the requested
545+ peak level.
546+ </p>
547+ <p>
548+ It is worth being precise about what this signal is and is not. It is the motion
549+ of one point of the metal: a contact pickup, not a microphone. Radiating the
550+ sound into air, with the interference and directivity of a real plate source,
551+ would require the full surface velocity history and an acoustic model on top, and
552+ is not part of the paper's method. The audible consequence is that low-order
553+ modes which radiate poorly from a real cymbal are present here at full strength,
554+ so renders carry somewhat more low end than a room recording of the same
555+ instrument would. With that caveat, the paper's trial calculations, and this
556+ implementation against the demonstration recording published by the paper's
557+ authors, reproduce the features that define the instrument: the clear low-order
558+ peaks, the slow buildup of high-frequency energy after a hard stroke, the
559+ difference between a washer-mounted and a clamped cymbal, and the effect of the
560+ grip on the attack. The spectrogram panel of the simulator is the place to watch
561+ the first of these; render the <i>Paper demo</i> preset and look for the energy
562+ arriving above 5 kHz over the first half second.
563+ </p>
564+ </section>
565+
566+ {/* ---------------- 10 ---------------- */}
567+ <section id="tut-departures">
568+ <h2>10. Where the implementation departs from the paper</h2>
569+ <p>
570+ The physics above is Samejima's throughout. Turning a published scheme into code
571+ that must run stably for arbitrary knob settings forced a small number of
572+ departures, and honesty requires naming them; the repository README states each
573+ with its full justification.
574+ </p>
575+ <ul className="tut-list">
576+ <li>
577+ <b>The head is lumped onto the beam tip.</b> The paper integrates the head as
578+ its own one-degree body (Eq. (34)) coupled to the beam through a rigid
579+ connection (Eqs. (36), (37)). Coupling that explicit update to the implicit
580+ beam turns out to be unstable as a discrete pair. Since the connection is
581+ rigid, substituting it into Newton's law puts the head mass on the tip node
582+ exactly, which is the same physics and inherits the beam scheme's stability.
583+ </li>
584+ <li>
585+ <b>The implicit weighting of the washer and beam schemes.</b> The paper prints
586+ the split with weight <M>(1-\phi)/2</M> on the old and new steps; that choice
587+ is unstable whenever the mass term does not dominate (the massless washer
588+ included). Moving the implicit weight <M>\phi/2</M> onto the average of the new
589+ and old steps makes <M>\phi = 0.7</M> unconditionally stable, and that is what
590+ runs here.
591+ </li>
592+ <li>
593+ <b>The hand can retract.</b> The paper's hand accelerates to a terminal speed
594+ and never reverses, which is fine for one stroke and cannot play a second. The
595+ app's hand accelerates at an arm-like rate, coasts, and snaps back after
596+ contact, so figures and rolls are possible.
597+ </li>
598+ <li>
599+ <b>The solves are block-diagonalized over wavenumber</b> (Section 7). This
600+ changes the cost, not the result.
601+ </li>
602+ <li>
603+ <b>Guard rails.</b> The loop detects divergence and stops with a message rather
604+ than emitting garbage, and the system builder truncates azimuthal columns whose
605+ inner boundary layer the radial grid cannot resolve (none, at the default
606+ grid).
607+ </li>
608+ </ul>
609+ </section>
610+
611+ {/* ---------------- 11 ---------------- */}
612+ <section id="tut-refs">
613+ <h2>11. Credits and references</h2>
614+ <p>
615+ The physical model implemented by this app, the washer boundary condition, the
616+ stick/mallet system, and the numerical scheme, are the work of{' '}
617+ <b>Toshiya Samejima</b> (Faculty of Design, Kyushu University), published as the
618+ paper below. The defaults are the paper's measured instrument: an 18-inch medium
619+ crash on its measured washer, struck by its measured mallet. This tutorial and
620+ the implementation are the app's own; errors in either should be laid at the
621+ app's door, not the paper's.
622+ </p>
623+ <ol className="tut-refs">
624+ <li>
625+ T. Samejima, “Nonlinear physical modeling sound synthesis of cymbals
626+ involving dynamics of washers and sticks/mallets,” <i>Acoustical Science
627+ and Technology</i> <b>42</b>(6), 314–325 (2021).{' '}
628+ <a href={PAPER_DOI} target="_blank" rel="noreferrer">doi:10.1250/ast.42.314</a>
629+ </li>
630+ <li>
631+ S. Bilbao, “Percussion synthesis based on models of nonlinear shell
632+ vibration,” <i>IEEE Transactions on Audio, Speech, and Language
633+ Processing</i> <b>18</b>, 872–880 (2010).
634+ </li>
635+ <li>
636+ S. Bilbao, <i>Numerical Sound Synthesis: Finite Difference Schemes and
637+ Simulation in Musical Acoustics</i> (Wiley, 2009).
638+ </li>
639+ <li>
640+ A. Chaigne, C. Touzé and O. Thomas, “Nonlinear vibrations and
641+ chaos in gongs and cymbals,” <i>Acoustical Science and Technology</i>{' '}
642+ <b>26</b>, 403–409 (2005).
643+ </li>
644+ <li>
645+ A. Chaigne and A. Askenfelt, “Numerical simulations of piano strings. I.
646+ A physical model for a struck string using finite difference methods,”{' '}
647+ <i>Journal of the Acoustical Society of America</i> <b>95</b>, 1112–1118
648+ (1994).
649+ </li>
650+ <li>
651+ L. Rhaouti, A. Chaigne and P. Joly, “Time-domain modeling and numerical
652+ simulation of a kettledrum,” <i>Journal of the Acoustical Society of
653+ America</i> <b>105</b>, 3545–3562 (1999).
654+ </li>
655+ <li>
656+ S. Takashima, N. Wakatsuki and K. Mizutani, “Measurement of tone change
657+ of cymbal depending on grip tightness,” <i>Proceedings of Musical
658+ Acoustics, Acoustical Society of Japan</i> <b>37</b>(6), 71–74 (2018, in
659+ Japanese).
660+ </li>
661+ </ol>
662+ <button className="primary" onClick={onClose}>← Back to the simulator</button>
663+ </section>
664+ </div>
665+ </div>
666+ );
667+}
webapp/src/tutorial/figparts.tsxadded+76−0View file
@@ -0,0 +1,76 @@
1+// Shared scaffolding for the tutorial figures: a framed figure with a
2+// caption, a labeled slider row, and a one-shot audio player for the demos
3+// that make sound.
4+
5+import { useCallback, useRef, type ReactNode } from 'react';
6+
7+export function Figure({ caption, children }: { caption: ReactNode; children: ReactNode }) {
8+ return (
9+ <figure className="tut-fig">
10+ {children}
11+ <figcaption>{caption}</figcaption>
12+ </figure>
13+ );
14+}
15+
16+interface SliderProps {
17+ label: string;
18+ value: number;
19+ min: number;
20+ max: number;
21+ step?: number;
22+ log?: boolean;
23+ format?: (v: number) => string;
24+ onChange: (v: number) => void;
25+}
26+
27+export function FigSlider({ label, value, min, max, step, log, format, onChange }: SliderProps) {
28+ const fmt = format ?? ((v: number) => v.toPrecision(3));
29+ const toSlider = (v: number) => (log ? Math.log(v) : v);
30+ const fromSlider = (v: number) => (log ? Math.exp(v) : v);
31+ return (
32+ <label className="tut-slider">
33+ <span className="tut-slider-name">{label}</span>
34+ <input
35+ type="range"
36+ min={toSlider(min)}
37+ max={toSlider(max)}
38+ step={log ? (Math.log(max) - Math.log(min)) / 400 : (step ?? (max - min) / 400)}
39+ value={toSlider(value)}
40+ onChange={(e) => onChange(fromSlider(Number(e.target.value)))}
41+ />
42+ <span className="tut-slider-val">{fmt(value)}</span>
43+ </label>
44+ );
45+}
46+
47+export function FigControls({ children }: { children: ReactNode }) {
48+ return <div className="tut-controls">{children}</div>;
49+}
50+
51+// Play a mono Float32Array once. Each call replaces the previous source, so
52+// mashing the button restarts rather than stacks.
53+export function usePlayer() {
54+ const ctxRef = useRef<AudioContext | null>(null);
55+ const srcRef = useRef<AudioBufferSourceNode | null>(null);
56+ return useCallback((samples: Float32Array<ArrayBuffer>, sampleRate: number) => {
57+ let ctx = ctxRef.current;
58+ if (!ctx || ctx.state === 'closed') {
59+ ctx = new AudioContext();
60+ ctxRef.current = ctx;
61+ }
62+ void ctx.resume();
63+ try {
64+ srcRef.current?.stop();
65+ } catch {
66+ // already stopped
67+ }
68+ const buf = ctx.createBuffer(1, samples.length, sampleRate);
69+ buf.copyToChannel(samples, 0);
70+ const src = ctx.createBufferSource();
71+ src.buffer = buf;
72+ src.connect(ctx.destination);
73+ src.start();
74+ srcRef.current = src;
75+ }, []);
76+}
webapp/src/tutorial/figures/CascadeFig.tsxadded+221−0View file
@@ -0,0 +1,221 @@
1+// The crash in miniature: a Fermi-Pasta-Ulam chain, masses on springs whose
2+// force carries a quadratic (geometric) correction, started in its lowest
3+// mode. At small amplitude the energy stays where it was put; at large
4+// amplitude the nonlinearity couples the modes and energy migrates up the
5+// ladder. The shell's bending-stretching coupling does to its nodal-diameter
6+// modes what this chain does to its sine modes.
7+
8+import { useMemo, useState } from 'react';
9+import { useCanvas, drawFrame, PLOT_COLORS, colormap } from '../../components/plots/canvasUtils';
10+import { Figure, FigControls, FigSlider, usePlayer } from '../figparts';
11+
12+const HEIGHT = 260;
13+const N = 24; // interior masses
14+const ALPHA = 0.25; // quadratic coupling
15+const TSTEPS = 30000;
16+const DT = 0.06;
17+const FRAMES = 240; // energy snapshots across the run
18+
19+function simulate(amp: number) {
20+ const u = new Float64Array(N + 2);
21+ const v = new Float64Array(N + 2);
22+ for (let i = 1; i <= N; i++) u[i] = amp * Math.sin((Math.PI * i) / (N + 1));
23+
24+ const wk = new Float64Array(N);
25+ for (let k = 1; k <= N; k++) wk[k - 1] = 2 * Math.sin((Math.PI * k) / (2 * (N + 1)));
26+
27+ const energies = new Float32Array(FRAMES * N);
28+ const a = new Float64Array(N + 2);
29+ const accel = (uu: Float64Array) => {
30+ for (let i = 1; i <= N; i++) {
31+ const dr = uu[i + 1] - uu[i];
32+ const dl = uu[i] - uu[i - 1];
33+ a[i] = dr - dl + ALPHA * (dr * dr - dl * dl);
34+ }
35+ };
36+
37+ const snap = (frame: number) => {
38+ for (let k = 1; k <= N; k++) {
39+ let q = 0, p = 0;
40+ for (let i = 1; i <= N; i++) {
41+ const s = Math.sin((Math.PI * k * i) / (N + 1));
42+ q += u[i] * s;
43+ p += v[i] * s;
44+ }
45+ const c = Math.sqrt(2 / (N + 1));
46+ q *= c;
47+ p *= c;
48+ energies[frame * N + (k - 1)] = 0.5 * (p * p + wk[k - 1] * wk[k - 1] * q * q);
49+ }
50+ };
51+
52+ const perFrame = Math.floor(TSTEPS / FRAMES);
53+ // leapfrog: cheap and symplectic, so the ladder is not an artifact of decay
54+ accel(u);
55+ for (let f = 0; f < FRAMES; f++) {
56+ snap(f);
57+ for (let s = 0; s < perFrame; s++) {
58+ for (let i = 1; i <= N; i++) v[i] += 0.5 * DT * a[i];
59+ for (let i = 1; i <= N; i++) u[i] += DT * v[i];
60+ accel(u);
61+ for (let i = 1; i <= N; i++) v[i] += 0.5 * DT * a[i];
62+ }
63+ }
64+ return energies;
65+}
66+
67+// Sonification: the same chain, retuned so mode 1 sits at 220 Hz (its top
68+// mode then lands near 3.5 kHz), lightly damped, and integrated at audio
69+// rate; the output is the acceleration of one off-center mass. At small
70+// amplitude it is a plain 220 Hz tone; at large amplitude the cascade is
71+// audible as a brightening after the onset.
72+const AUDIO_SR = 24000;
73+const AUDIO_F1 = 220;
74+
75+export function renderChainAudio(amp: number): Float32Array<ArrayBuffer> {
76+ const dur = 2.5;
77+ const n = Math.round(AUDIO_SR * dur);
78+ const u = new Float64Array(N + 2);
79+ const v = new Float64Array(N + 2);
80+ const a = new Float64Array(N + 2);
81+ for (let i = 1; i <= N; i++) u[i] = amp * Math.sin((Math.PI * i) / (N + 1));
82+ const w1 = 2 * Math.sin(Math.PI / (2 * (N + 1)));
83+ const tauPerSec = (2 * Math.PI * AUDIO_F1) / w1; // natural time per real second
84+ const sub = 2;
85+ const dtau = tauPerSec / AUDIO_SR / sub;
86+ const cdamp = (2 * 2.76) / tauPerSec; // amplitude T60 near 2.5 s
87+
88+ const accel = () => {
89+ for (let i = 1; i <= N; i++) {
90+ const dr = u[i + 1] - u[i];
91+ const dl = u[i] - u[i - 1];
92+ a[i] = dr - dl + ALPHA * (dr * dr - dl * dl) - cdamp * v[i];
93+ }
94+ };
95+ const iOut = 5;
96+ const y = new Float32Array(n);
97+ accel();
98+ for (let k = 0; k < n; k++) {
99+ for (let s = 0; s < sub; s++) {
100+ for (let i = 1; i <= N; i++) v[i] += 0.5 * dtau * a[i];
101+ for (let i = 1; i <= N; i++) u[i] += dtau * v[i];
102+ accel();
103+ for (let i = 1; i <= N; i++) v[i] += 0.5 * dtau * a[i];
104+ }
105+ y[k] = a[iOut];
106+ }
107+ let pk = 0;
108+ for (let k = 0; k < n; k++) pk = Math.max(pk, Math.abs(y[k]));
109+ const fadeIn = Math.round(0.004 * AUDIO_SR);
110+ const fadeOut = Math.round(0.12 * AUDIO_SR);
111+ for (let k = 0; k < n; k++) {
112+ let g = 0.45 / (pk || 1);
113+ if (k < fadeIn) g *= k / fadeIn;
114+ if (k > n - fadeOut) g *= (n - k) / fadeOut;
115+ y[k] *= g;
116+ }
117+ return y;
118+}
119+
120+export default function CascadeFig() {
121+ const [amp, setAmp] = useState(4);
122+ const energies = useMemo(() => simulate(amp), [amp]);
123+ const play = usePlayer();
124+
125+ const ref = useCanvas(
126+ (ctx, w, h) => {
127+ drawFrame(ctx, w, h);
128+ const padL = 70;
129+ const padR = 14;
130+ const padT = 10;
131+ const padB = 28;
132+ const pw = w - padL - padR;
133+ const ph = h - padT - padB;
134+
135+ let e0 = 0;
136+ for (let k = 0; k < N; k++) e0 += energies[k];
137+ const floorDb = -55;
138+
139+ // paint at data resolution, then stretch (putImageData ignores the
140+ // dpr transform, so go through an offscreen canvas as the plots do)
141+ const off = document.createElement('canvas');
142+ off.width = FRAMES;
143+ off.height = N;
144+ const octx = off.getContext('2d')!;
145+ const img = octx.createImageData(FRAMES, N);
146+ for (let mode = 0; mode < N; mode++) {
147+ const row = N - 1 - mode; // mode 1 at the bottom
148+ for (let frame = 0; frame < FRAMES; frame++) {
149+ const e = energies[frame * N + mode] / (e0 || 1);
150+ const db = 10 * Math.log10(Math.max(1e-9, e));
151+ const t = Math.min(1, Math.max(0, (db - floorDb) / -floorDb));
152+ const [r, g, b] = colormap(t);
153+ const o = 4 * (row * FRAMES + frame);
154+ img.data[o] = r;
155+ img.data[o + 1] = g;
156+ img.data[o + 2] = b;
157+ img.data[o + 3] = 255;
158+ }
159+ }
160+ octx.putImageData(img, 0, 0);
161+ ctx.imageSmoothingEnabled = false;
162+ ctx.drawImage(off, padL, padT, pw, ph);
163+
164+ ctx.fillStyle = PLOT_COLORS.axis;
165+ ctx.font = '10px system-ui';
166+ ctx.textAlign = 'right';
167+ for (const k of [1, 6, 12, 18, 24]) {
168+ const yy = padT + ph - ((k - 0.5) / N) * ph;
169+ ctx.fillText(`${k}`, padL - 6, yy + 3);
170+ }
171+ ctx.save();
172+ ctx.translate(14, padT + ph / 2);
173+ ctx.rotate(-Math.PI / 2);
174+ ctx.textAlign = 'center';
175+ ctx.fillText('mode number (energy, dB re start)', 0, 0);
176+ ctx.restore();
177+ ctx.textAlign = 'center';
178+ ctx.fillText('time →', padL + pw / 2, h - 8);
179+ ctx.textAlign = 'left';
180+ ctx.fillText('all energy starts in mode 1 (bottom row)', padL + 6, h - 8);
181+ },
182+ [energies],
183+ HEIGHT,
184+ );
185+
186+ return (
187+ <Figure
188+ caption={
189+ <>
190+ Modal energy (color, log scale) against time for a chain of {N} masses whose spring
191+ forces carry a quadratic geometric term, started with all of its energy in mode 1.
192+ Below an amplitude near 1 the system is effectively linear and the energy stays in
193+ the bottom row for the whole run. Push the amplitude up and the modes cease to be
194+ independent: energy climbs the ladder, and at the top of the range the spectrum
195+ fills in. This, plus damping, is the crash: the strike puts energy in at the bottom,
196+ and the nonlinearity spends the next several hundred milliseconds carrying it
197+ upward, which is why a crash brightens after the attack rather than at it. The play
198+ button integrates the same chain retuned so its lowest mode sits at 220 Hz and
199+ lightly damped: quiet strikes give a plain tone, and somewhere past an amplitude of
200+ 2 the brightening-after-onset becomes plainly audible.
201+ </>
202+ }
203+ >
204+ <canvas ref={ref} />
205+ <FigControls>
206+ <FigSlider
207+ label="strike amplitude"
208+ value={amp}
209+ min={0.2}
210+ max={8}
211+ step={0.1}
212+ format={(v) => v.toFixed(1)}
213+ onChange={setAmp}
214+ />
215+ <button onClick={() => play(renderChainAudio(amp), AUDIO_SR)}>
216+ ▶ Hear this chain (mode 1 tuned to 220 Hz)
217+ </button>
218+ </FigControls>
219+ </Figure>
220+ );
221+}
webapp/src/tutorial/figures/GeometryFig.tsxadded+142−0View file
@@ -0,0 +1,142 @@
1+// Interactive cross-section of the shallow spherical cap: radius, rise, and
2+// thickness sliders, with the osculating sphere drawn dashed and the derived
3+// quantities (curvature radius Rs, bending stiffness D) reported. Drawn at
4+// true aspect ratio, because the point of the figure is how shallow the
5+// shell actually is.
6+
7+import { useState } from 'react';
8+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
9+import { Figure, FigControls, FigSlider } from '../figparts';
10+
11+const HEIGHT = 250;
12+
13+export default function GeometryFig() {
14+ const [a, setA] = useState(0.23);
15+ const [rise, setRise] = useState(0.04);
16+ const [hmm, setHmm] = useState(1.0); // thickness in mm
17+
18+ const Rs = (a * a) / (2 * rise);
19+ const E = 1.1e11;
20+ const nu = 0.33;
21+ const h = hmm * 1e-3;
22+ const D = (E * h * h * h) / (12 * (1 - nu * nu));
23+
24+ const ref = useCanvas(
25+ (ctx, w, hpx) => {
26+ drawFrame(ctx, w, hpx);
27+ const scale = (w - 60) / (2 * 0.36); // fixed scale so resizing the shell reads
28+ const cx = w / 2;
29+ const baseY = hpx - 70;
30+ const px = (x: number) => cx + x * scale;
31+ const py = (z: number) => baseY - z * scale;
32+
33+ // osculating sphere, dashed: center on the axis at z = rise - Rs
34+ ctx.strokeStyle = 'rgba(139, 147, 161, 0.55)';
35+ ctx.setLineDash([5, 5]);
36+ ctx.beginPath();
37+ ctx.arc(px(0), py(rise - Rs), Rs * scale, 0, 2 * Math.PI);
38+ ctx.stroke();
39+ ctx.setLineDash([]);
40+
41+ // the cap itself: z(r) = rise (1 - r^2/a^2), the shallow form the model uses
42+ ctx.strokeStyle = '#c9a227';
43+ ctx.lineWidth = Math.max(2, h * scale);
44+ ctx.beginPath();
45+ const N = 90;
46+ for (let i = 0; i <= N; i++) {
47+ const r = -a + (2 * a * i) / N;
48+ const z = rise * (1 - (r * r) / (a * a));
49+ if (i === 0) ctx.moveTo(px(r), py(z));
50+ else ctx.lineTo(px(r), py(z));
51+ }
52+ ctx.stroke();
53+ ctx.lineWidth = 1;
54+
55+ // rise marker
56+ ctx.strokeStyle = PLOT_COLORS.trace;
57+ ctx.beginPath();
58+ ctx.moveTo(px(0), py(0));
59+ ctx.lineTo(px(0), py(rise));
60+ ctx.stroke();
61+ ctx.fillStyle = PLOT_COLORS.trace;
62+ ctx.font = '11px system-ui';
63+ ctx.textAlign = 'left';
64+ ctx.fillText(`rise ${(rise * 100).toFixed(1)} cm`, px(0) + 6, py(rise / 2));
65+
66+ // radius marker
67+ ctx.strokeStyle = PLOT_COLORS.axis;
68+ ctx.beginPath();
69+ ctx.moveTo(px(0), baseY + 14);
70+ ctx.lineTo(px(a), baseY + 14);
71+ ctx.stroke();
72+ ctx.fillStyle = PLOT_COLORS.axis;
73+ ctx.textAlign = 'center';
74+ ctx.fillText(`a = ${(a * 100).toFixed(1)} cm`, px(a / 2), baseY + 28);
75+
76+ // readouts
77+ ctx.textAlign = 'left';
78+ ctx.fillStyle = '#d6dae2';
79+ ctx.fillText(
80+ `curvature radius Rs = a² / (2·rise) = ${Rs.toFixed(2)} m`,
81+ 14,
82+ 20,
83+ );
84+ ctx.fillText(
85+ `bending stiffness D = E h³ / 12(1−ν²) = ${D.toFixed(3)} N·m`,
86+ 14,
87+ 38,
88+ );
89+ ctx.fillStyle = PLOT_COLORS.axis;
90+ ctx.fillText('drawn to true scale; dashes: the sphere the cap sits on', 14, 56);
91+ },
92+ [a, rise, hmm, Rs, D, h],
93+ HEIGHT,
94+ );
95+
96+ return (
97+ <Figure
98+ caption={
99+ <>
100+ The whole cymbal, cup and bow together, is replaced by one spherical cap: radius{' '}
101+ <i>a</i>, a small rise at the center, uniform thickness <i>h</i>. The rise fixes a
102+ single radius of curvature <i>R</i>
103+ <sub>s</sub>, and that one number carries all of the shell's “shellness”:
104+ flatten the rise toward zero and the model degenerates to a flat plate, whose low
105+ modes are much softer. Note how little curvature is involved; this shallowness is
106+ what licenses the von Kármán equations of the next section.
107+ </>
108+ }
109+ >
110+ <canvas ref={ref} />
111+ <FigControls>
112+ <FigSlider
113+ label="radius a"
114+ value={a}
115+ min={0.12}
116+ max={0.35}
117+ step={0.005}
118+ format={(v) => `${(v * 100).toFixed(1)} cm`}
119+ onChange={setA}
120+ />
121+ <FigSlider
122+ label="rise"
123+ value={rise}
124+ min={0.005}
125+ max={0.09}
126+ step={0.001}
127+ format={(v) => `${(v * 100).toFixed(1)} cm`}
128+ onChange={setRise}
129+ />
130+ <FigSlider
131+ label="thickness h"
132+ value={hmm}
133+ min={0.4}
134+ max={3}
135+ step={0.05}
136+ format={(v) => `${v.toFixed(2)} mm`}
137+ onChange={setHmm}
138+ />
139+ </FigControls>
140+ </Figure>
141+ );
142+}
webapp/src/tutorial/figures/GridFig.tsxadded+131−0View file
@@ -0,0 +1,131 @@
1+// The polar grid of cymbal2_grid.m, seen from above: real radial lines from
2+// the washer circle (l = 3) to the free edge (l = Nr), virtual lines beyond
3+// both boundaries drawn dashed, and the azimuthal spokes. Sliders reproduce
4+// the n_radial / n_theta parameters of the app.
5+
6+import { useState } from 'react';
7+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
8+import { Figure, FigControls, FigSlider } from '../figparts';
9+
10+const HEIGHT = 340;
11+const A = 0.23;
12+const C = 0.029;
13+
14+export default function GridFig() {
15+ const [nr, setNr] = useState(24);
16+ const [nth, setNth] = useState(60);
17+
18+ const ref = useCanvas(
19+ (ctx, w, h) => {
20+ drawFrame(ctx, w, h);
21+ const cx = w / 2;
22+ const cy = h / 2 + 6;
23+ const hr = (A - C) / (nr - 3);
24+ const rOuterVirtual = C + (nr - 1) * hr; // l = Nr + 2
25+ const scale = (Math.min(w, h) / 2 - 26) / rOuterVirtual;
26+ const rl = (l: number) => Math.max(0, C + (l - 3) * hr) * scale;
27+
28+ // spokes
29+ ctx.strokeStyle = 'rgba(42, 47, 56, 0.9)';
30+ for (let j = 0; j < nth; j++) {
31+ const th = (2 * Math.PI * j) / nth;
32+ ctx.beginPath();
33+ ctx.moveTo(cx + rl(3) * Math.cos(th), cy + rl(3) * Math.sin(th));
34+ ctx.lineTo(cx + rl(nr) * Math.cos(th), cy + rl(nr) * Math.sin(th));
35+ ctx.stroke();
36+ }
37+
38+ // real circles
39+ for (let l = 3; l <= nr; l++) {
40+ ctx.strokeStyle = l === 3 || l === nr ? PLOT_COLORS.accent : 'rgba(139, 147, 161, 0.35)';
41+ ctx.lineWidth = l === 3 || l === nr ? 2 : 1;
42+ ctx.beginPath();
43+ ctx.arc(cx, cy, rl(l), 0, 2 * Math.PI);
44+ ctx.stroke();
45+ }
46+ ctx.lineWidth = 1;
47+
48+ // virtual circles, dashed
49+ ctx.setLineDash([4, 4]);
50+ ctx.strokeStyle = 'rgba(229, 115, 115, 0.55)';
51+ for (const l of [1, 2, nr + 1, nr + 2]) {
52+ const r = rl(l);
53+ if (r <= 1) continue;
54+ ctx.beginPath();
55+ ctx.arc(cx, cy, r, 0, 2 * Math.PI);
56+ ctx.stroke();
57+ }
58+ ctx.setLineDash([]);
59+
60+ // node dots when sparse enough to read
61+ const totalNodes = (nr - 2) * nth;
62+ if (totalNodes <= 2600) {
63+ ctx.fillStyle = 'rgba(214, 218, 226, 0.7)';
64+ for (let l = 3; l <= nr; l++) {
65+ for (let j = 0; j < nth; j++) {
66+ const th = (2 * Math.PI * j) / nth;
67+ ctx.beginPath();
68+ ctx.arc(cx + rl(l) * Math.cos(th), cy + rl(l) * Math.sin(th), 1.4, 0, 2 * Math.PI);
69+ ctx.fill();
70+ }
71+ }
72+ }
73+
74+ // legend and readouts, kept off the disk where they collide
75+ ctx.font = '11px system-ui';
76+ ctx.textAlign = 'left';
77+ ctx.fillStyle = PLOT_COLORS.accent;
78+ ctx.fillText('amber: washer circle (l = 3) and free edge (l = Nr)', 12, 20);
79+ ctx.fillStyle = 'rgba(229, 115, 115, 0.9)';
80+ ctx.fillText('dashed: virtual lines', 12, 38);
81+ ctx.fillStyle = PLOT_COLORS.axis;
82+ const stepMm = (hr * 1000).toFixed(1);
83+ ctx.fillText(`radial spacing h_r = ${stepMm} mm`, 12, h - 26);
84+ ctx.fillText(
85+ `unknowns per step: ${(nr + 2) * nth} displacement + ${(nr + 2) * nth} stress function`,
86+ 12,
87+ h - 8,
88+ );
89+ },
90+ [nr, nth],
91+ HEIGHT,
92+ );
93+
94+ return (
95+ <Figure
96+ caption={
97+ <>
98+ The grid of Samejima's Fig. 4 for the paper's shell (23 cm radius, 2.9 cm washer
99+ circle). Real nodes live on the radial lines l = 3 … N<sub>r</sub>, between the
100+ washer circle and the free edge, both drawn amber. The two dashed lines beyond each
101+ boundary carry virtual nodes: the fourth-order operators need neighbors two lines
102+ deep on each side, and the boundary-condition equations are what determine the
103+ values there. The paper's production grid is N<sub>r</sub> = 24, N<sub>θ</sub>{' '}
104+ = 60, drawn here as the default.
105+ </>
106+ }
107+ >
108+ <canvas ref={ref} />
109+ <FigControls>
110+ <FigSlider
111+ label="n_radial"
112+ value={nr}
113+ min={8}
114+ max={40}
115+ step={1}
116+ format={(v) => `${Math.round(v)}`}
117+ onChange={(v) => setNr(Math.round(v))}
118+ />
119+ <FigSlider
120+ label="n_theta"
121+ value={nth}
122+ min={12}
123+ max={96}
124+ step={2}
125+ format={(v) => `${Math.round(v)}`}
126+ onChange={(v) => setNth(2 * Math.round(v / 2))}
127+ />
128+ </FigControls>
129+ </Figure>
130+ );
131+}
webapp/src/tutorial/figures/HertzFig.tsxadded+247−0View file
@@ -0,0 +1,247 @@
1+// The contact law and what it does on impact. Left: f = K [xi]+^alpha, the
2+// Hertz-type compression law of the mallet head. Right: the head thrown at a
3+// small plate-like target, five modal oscillators sharing the contact point,
4+// integrated through and past the contact. The same simulation renders a
5+// second of the plate's response as audio, so mallet hardness can be heard,
6+// not just plotted: a soft head excites only the low modes, a hard head all
7+// of them.
8+
9+import { useMemo, useState } from 'react';
10+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
11+import { Figure, FigControls, FigSlider, usePlayer } from '../figparts';
12+
13+const HEIGHT = 260;
14+const M_HEAD = 0.028;
15+// A plate-like ladder of point-coupled modes: frequency (Hz), modal mass
16+// (kg), and amplitude decay rate (1/s).
17+const MODES = [
18+ { f: 180, m: 0.35, g: 2.5 },
19+ { f: 476, m: 0.22, g: 3.5 },
20+ { f: 894, m: 0.16, g: 5.0 },
21+ { f: 1520, m: 0.12, g: 7.0 },
22+ { f: 2350, m: 0.10, g: 10.0 },
23+];
24+const PLOT_T = 0.012;
25+const AUDIO_SR = 25000;
26+const DT = 1e-6; // 40 substeps per audio sample
27+
28+interface StrikeState {
29+ x1: number; v1: number;
30+ q: Float64Array; qd: Float64Array;
31+}
32+
33+function makeState(v0: number): StrikeState {
34+ return { x1: -1e-5, v1: v0, q: new Float64Array(MODES.length), qd: new Float64Array(MODES.length) };
35+}
36+
37+// One symplectic-Euler substep; returns the contact force and the summed
38+// modal acceleration (the collocated pickup).
39+function step(s: StrikeState, K: number, alpha: number): { fc: number; acc: number } {
40+ let ub = 0;
41+ for (let i = 0; i < MODES.length; i++) ub += s.q[i];
42+ const xi = s.x1 - ub;
43+ const fc = xi > 0 ? K * Math.pow(xi, alpha) : 0;
44+ s.v1 += (-fc / M_HEAD) * DT;
45+ s.x1 += s.v1 * DT;
46+ let acc = 0;
47+ for (let i = 0; i < MODES.length; i++) {
48+ const md = MODES[i];
49+ const w = 2 * Math.PI * md.f;
50+ const ai = (fc - md.m * w * w * s.q[i] - 2 * md.m * md.g * s.qd[i]) / md.m;
51+ s.qd[i] += ai * DT;
52+ s.q[i] += s.qd[i] * DT;
53+ acc += ai;
54+ }
55+ return { fc, acc };
56+}
57+
58+function simulate(K: number, alpha: number, v0: number) {
59+ const s = makeState(v0);
60+ const n = Math.round(PLOT_T / DT);
61+ const keep = 600;
62+ const stride = Math.max(1, Math.floor(n / keep));
63+ const t: number[] = [];
64+ const f: number[] = [];
65+ let peakF = 0;
66+ let peakXi = 0;
67+ let contacts = 0;
68+ let inC = false;
69+ for (let k = 0; k < n; k++) {
70+ let ub = 0;
71+ for (let i = 0; i < MODES.length; i++) ub += s.q[i];
72+ const xi = s.x1 - ub;
73+ if (xi > peakXi) peakXi = xi;
74+ const { fc } = step(s, K, alpha);
75+ if (fc > 0 && !inC) contacts++;
76+ inC = fc > 0;
77+ if (fc > peakF) peakF = fc;
78+ if (k % stride === 0) {
79+ t.push(k * DT);
80+ f.push(fc);
81+ }
82+ }
83+ return { t, f, peakF, peakXi, contacts };
84+}
85+
86+export function renderStrikeAudio(K: number, alpha: number, v0: number): Float32Array<ArrayBuffer> {
87+ const dur = 1.2;
88+ const nOut = Math.round(AUDIO_SR * dur);
89+ const sub = Math.round(1 / (AUDIO_SR * DT));
90+ const s = makeState(v0);
91+ const y = new Float32Array(nOut);
92+ for (let k = 0; k < nOut; k++) {
93+ let acc = 0;
94+ for (let j = 0; j < sub; j++) acc = step(s, K, alpha).acc;
95+ y[k] = acc;
96+ }
97+ let pk = 0;
98+ for (let k = 0; k < nOut; k++) pk = Math.max(pk, Math.abs(y[k]));
99+ const fadeOut = Math.round(0.1 * AUDIO_SR);
100+ for (let k = 0; k < nOut; k++) {
101+ let g = 0.5 / (pk || 1);
102+ if (k > nOut - fadeOut) g *= (nOut - k) / fadeOut;
103+ y[k] *= g;
104+ }
105+ return y;
106+}
107+
108+export default function HertzFig() {
109+ const [alpha, setAlpha] = useState(2.54);
110+ const [K, setK] = useState(1.6e8);
111+ const [v0, setV0] = useState(5);
112+ const sim = useMemo(() => simulate(K, alpha, v0), [K, alpha, v0]);
113+ const play = usePlayer();
114+
115+ const ref = useCanvas(
116+ (ctx, w, h) => {
117+ drawFrame(ctx, w, h);
118+ const pw = (w - 54) / 2;
119+ const ph = h - 62;
120+ const y0 = 14;
121+
122+ // ---- left: force vs compression ----
123+ const xiMax = Math.max(1.5 * sim.peakXi, 1e-4);
124+ const fAtXiMax = K * Math.pow(xiMax, alpha);
125+ const x0 = 34;
126+ ctx.strokeStyle = PLOT_COLORS.grid;
127+ ctx.strokeRect(x0, y0, pw, ph);
128+ ctx.strokeStyle = PLOT_COLORS.trace;
129+ ctx.lineWidth = 2;
130+ ctx.beginPath();
131+ for (let i = 0; i <= 120; i++) {
132+ const xi = (i / 120) * xiMax;
133+ const fv = K * Math.pow(xi, alpha);
134+ const x = x0 + (xi / xiMax) * pw;
135+ const y = y0 + ph - (fv / fAtXiMax) * ph;
136+ if (i === 0) ctx.moveTo(x, y);
137+ else ctx.lineTo(x, y);
138+ }
139+ ctx.stroke();
140+ ctx.lineWidth = 1;
141+ // operating point
142+ if (sim.peakXi > 0) {
143+ ctx.fillStyle = PLOT_COLORS.accent;
144+ ctx.beginPath();
145+ ctx.arc(
146+ x0 + (sim.peakXi / xiMax) * pw,
147+ y0 + ph - (sim.peakF / fAtXiMax) * ph,
148+ 4, 0, 2 * Math.PI,
149+ );
150+ ctx.fill();
151+ }
152+ ctx.fillStyle = PLOT_COLORS.axis;
153+ ctx.font = '11px system-ui';
154+ ctx.textAlign = 'center';
155+ ctx.fillText(
156+ `f = K·ξ^${alpha.toFixed(2)} (compression 0…${(xiMax * 1000).toFixed(2)} mm)`,
157+ x0 + pw / 2,
158+ y0 + ph + 18,
159+ );
160+ ctx.textAlign = 'left';
161+ ctx.fillText('force vs compression', x0 + 6, y0 + 14);
162+
163+ // ---- right: force vs time ----
164+ const x1p = x0 + pw + 20;
165+ const fMax = Math.max(1, sim.peakF) * 1.08;
166+ ctx.strokeStyle = PLOT_COLORS.grid;
167+ ctx.strokeRect(x1p, y0, pw, ph);
168+ ctx.strokeStyle = PLOT_COLORS.accent;
169+ ctx.lineWidth = 2;
170+ ctx.beginPath();
171+ for (let i = 0; i < sim.t.length; i++) {
172+ const x = x1p + (sim.t[i] / PLOT_T) * pw;
173+ const y = y0 + ph - (sim.f[i] / fMax) * ph;
174+ if (i === 0) ctx.moveTo(x, y);
175+ else ctx.lineTo(x, y);
176+ }
177+ ctx.stroke();
178+ ctx.lineWidth = 1;
179+ ctx.fillStyle = PLOT_COLORS.axis;
180+ ctx.textAlign = 'center';
181+ ctx.fillText(`force vs time (0…${(PLOT_T * 1000).toFixed(0)} ms)`, x1p + pw / 2, y0 + ph + 18);
182+ ctx.textAlign = 'left';
183+ ctx.fillText(
184+ `peak ${sim.peakF.toFixed(0)} N, ${sim.contacts} contact${sim.contacts === 1 ? '' : 's'}`,
185+ x1p + 6,
186+ y0 + 14,
187+ );
188+ },
189+ [sim, K, alpha],
190+ HEIGHT,
191+ );
192+
193+ return (
194+ <Figure
195+ caption={
196+ <>
197+ Left: the contact law, a power law in the compression with exponent α. Hertz
198+ theory gives α = 1.5 for a hard sphere on a plate; the paper's measured felt
199+ mallet is much more strongly hardening, α = 2.54, so the felt is soft at
200+ grazing contact and stiffens as it packs. Right: the force the head actually
201+ delivers when thrown at a small plate-like target (five modes sharing the contact
202+ point). Soften the head and the pulse lengthens; stiffen it and the strike becomes
203+ a brief spike; at some settings the target rebounds into the head and the pulse
204+ splits, the double peak Samejima's Fig. 12 shows for the full stick model. The play
205+ button renders a second of the target's response to this exact strike: a long soft
206+ pulse cannot excite the upper modes (the thud of felt), a short hard one excites
207+ them all (the ping of a wood tip). The excitation spectrum is the pulse's Fourier
208+ transform, and you are hearing its width change.
209+ </>
210+ }
211+ >
212+ <canvas ref={ref} />
213+ <FigControls>
214+ <FigSlider
215+ label="exponent α"
216+ value={alpha}
217+ min={1.2}
218+ max={3.2}
219+ step={0.02}
220+ format={(v) => v.toFixed(2)}
221+ onChange={setAlpha}
222+ />
223+ <FigSlider
224+ label="stiffness K"
225+ value={K}
226+ min={1e6}
227+ max={1e10}
228+ log
229+ format={(v) => v.toExponential(1)}
230+ onChange={setK}
231+ />
232+ <FigSlider
233+ label="impact speed"
234+ value={v0}
235+ min={0.5}
236+ max={8}
237+ step={0.1}
238+ format={(v) => `${v.toFixed(1)} m/s`}
239+ onChange={setV0}
240+ />
241+ <button onClick={() => play(renderStrikeAudio(K, alpha, v0), AUDIO_SR)}>
242+ ▶ Hear this strike
243+ </button>
244+ </FigControls>
245+ </Figure>
246+ );
247+}
webapp/src/tutorial/figures/PitchGlideFig.tsxadded+183−0View file
@@ -0,0 +1,183 @@
1+// The simplest system with the cymbal's kind of nonlinearity: one oscillator
2+// with a hardening (Duffing) spring, u'' = -w0^2 (1 + b u^2) u - 2 s u'.
3+// Struck softly it rings at its linear pitch; struck hard it starts sharp
4+// and glides down as the amplitude decays. The figure integrates it, plots
5+// the waveform and the instantaneous frequency from zero crossings, and can
6+// play the result.
7+
8+import { useMemo, useState } from 'react';
9+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
10+import { Figure, FigControls, FigSlider, usePlayer } from '../figparts';
11+
12+const HEIGHT = 240;
13+const SR = 24000;
14+const DUR = 1.4;
15+const F0 = 380;
16+const BETA = 5.0;
17+const SIGMA = 3.5;
18+
19+function simulate(amp: number) {
20+ const n = Math.round(SR * DUR);
21+ const y = new Float32Array(n);
22+ const w0 = 2 * Math.PI * F0;
23+ const dt = 1 / SR;
24+ let u = amp;
25+ let v = 0;
26+ const acc = (uu: number, vv: number) => -w0 * w0 * (1 + BETA * uu * uu) * uu - 2 * SIGMA * vv;
27+ for (let k = 0; k < n; k++) {
28+ y[k] = u;
29+ // RK4
30+ const k1u = v, k1v = acc(u, v);
31+ const k2u = v + 0.5 * dt * k1v, k2v = acc(u + 0.5 * dt * k1u, v + 0.5 * dt * k1v);
32+ const k3u = v + 0.5 * dt * k2v, k3v = acc(u + 0.5 * dt * k2u, v + 0.5 * dt * k2v);
33+ const k4u = v + dt * k3v, k4v = acc(u + dt * k3u, v + dt * k3v);
34+ u += (dt / 6) * (k1u + 2 * k2u + 2 * k3u + k4u);
35+ v += (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
36+ }
37+ // instantaneous frequency from upward zero crossings
38+ const tf: number[] = [];
39+ const ff: number[] = [];
40+ let prevT = -1;
41+ for (let k = 1; k < n; k++) {
42+ if (y[k - 1] < 0 && y[k] >= 0) {
43+ const t = (k - y[k] / (y[k] - y[k - 1])) / SR;
44+ if (prevT >= 0) {
45+ tf.push((t + prevT) / 2);
46+ ff.push(1 / (t - prevT));
47+ }
48+ prevT = t;
49+ }
50+ }
51+ return { y, tf, ff };
52+}
53+
54+export default function PitchGlideFig() {
55+ const [amp, setAmp] = useState(0.85);
56+ const play = usePlayer();
57+ const sim = useMemo(() => simulate(amp), [amp]);
58+
59+ const ref = useCanvas(
60+ (ctx, w, h) => {
61+ drawFrame(ctx, w, h);
62+ const padL = 44;
63+ const padR = 52;
64+ const padT = 10;
65+ const padB = 26;
66+ const pw = w - padL - padR;
67+ const ph = h - padT - padB;
68+ const { y, tf, ff } = sim;
69+
70+ // waveform, min-max per pixel column
71+ ctx.strokeStyle = PLOT_COLORS.trace;
72+ ctx.globalAlpha = 0.85;
73+ ctx.beginPath();
74+ const n = y.length;
75+ for (let px = 0; px < pw; px++) {
76+ const i0 = Math.floor((px / pw) * n);
77+ const i1 = Math.floor(((px + 1) / pw) * n);
78+ let mn = Infinity, mx = -Infinity;
79+ for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
80+ if (y[i] < mn) mn = y[i];
81+ if (y[i] > mx) mx = y[i];
82+ }
83+ const ymid = padT + ph / 2;
84+ ctx.moveTo(padL + px, ymid - (mx / 1.05) * (ph / 2));
85+ ctx.lineTo(padL + px, ymid - (mn / 1.05) * (ph / 2));
86+ }
87+ ctx.stroke();
88+ ctx.globalAlpha = 1;
89+
90+ // frequency track on the right axis, 300..900 Hz
91+ const fMin = 300, fMax = 900;
92+ const fy = (f: number) => padT + ph - ((f - fMin) / (fMax - fMin)) * ph;
93+ ctx.strokeStyle = PLOT_COLORS.grid;
94+ ctx.fillStyle = PLOT_COLORS.axis;
95+ ctx.font = '10px system-ui';
96+ ctx.textAlign = 'left';
97+ for (let f = fMin; f <= fMax; f += 150) {
98+ ctx.beginPath();
99+ ctx.moveTo(padL, fy(f));
100+ ctx.lineTo(w - padR, fy(f));
101+ ctx.stroke();
102+ ctx.fillText(`${f} Hz`, w - padR + 5, fy(f) + 3);
103+ }
104+ ctx.strokeStyle = PLOT_COLORS.accent;
105+ ctx.lineWidth = 2;
106+ ctx.beginPath();
107+ for (let i = 0; i < tf.length; i++) {
108+ const x = padL + (tf[i] / DUR) * pw;
109+ const yy = fy(Math.min(fMax, Math.max(fMin, ff[i])));
110+ if (i === 0) ctx.moveTo(x, yy);
111+ else ctx.lineTo(x, yy);
112+ }
113+ ctx.stroke();
114+ ctx.lineWidth = 1;
115+
116+ // linear pitch reference
117+ ctx.strokeStyle = 'rgba(232, 163, 61, 0.4)';
118+ ctx.setLineDash([4, 4]);
119+ ctx.beginPath();
120+ ctx.moveTo(padL, fy(F0));
121+ ctx.lineTo(w - padR, fy(F0));
122+ ctx.stroke();
123+ ctx.setLineDash([]);
124+ ctx.fillStyle = PLOT_COLORS.accent;
125+ ctx.fillText(`linear pitch ${F0} Hz`, padL + 4, fy(F0) - 5);
126+
127+ // time axis
128+ ctx.fillStyle = PLOT_COLORS.axis;
129+ ctx.textAlign = 'center';
130+ for (let t = 0.25; t <= DUR; t += 0.25) {
131+ ctx.fillText(`${t.toFixed(2)}`, padL + (t / DUR) * pw, h - 8);
132+ }
133+ ctx.textAlign = 'left';
134+ ctx.fillText('time (s)', 4, h - 8);
135+ ctx.fillStyle = PLOT_COLORS.trace;
136+ ctx.fillText('waveform', padL + 4, padT + 12);
137+ const fpk = ff.length > 0 ? Math.max(...ff.slice(0, 12)) : F0;
138+ ctx.fillStyle = PLOT_COLORS.accent;
139+ ctx.fillText(`starts near ${fpk.toFixed(0)} Hz`, padL + 80, padT + 12);
140+ },
141+ [sim],
142+ HEIGHT,
143+ );
144+
145+ return (
146+ <Figure
147+ caption={
148+ <>
149+ A single oscillator with a hardening spring (a Duffing oscillator): its restoring
150+ force stiffens with displacement, so its frequency rises with amplitude. Struck
151+ hard, it starts sharp and glides down toward its linear pitch as the ring decays,
152+ which is the pitch glide heard in a struck cymbal or gong. Each mode of the shell
153+ sees a hardening of exactly this kind through the membrane stretching, with the
154+ strength growing as the squared displacement measured in plate thicknesses.
155+ </>
156+ }
157+ >
158+ <canvas ref={ref} />
159+ <FigControls>
160+ <FigSlider
161+ label="strike strength"
162+ value={amp}
163+ min={0.02}
164+ max={1}
165+ step={0.01}
166+ format={(v) => v.toFixed(2)}
167+ onChange={setAmp}
168+ />
169+ <button
170+ onClick={() => {
171+ const out = new Float32Array(sim.y.length);
172+ let pk = 0;
173+ for (let i = 0; i < out.length; i++) pk = Math.max(pk, Math.abs(sim.y[i]));
174+ for (let i = 0; i < out.length; i++) out[i] = (0.5 * sim.y[i]) / (pk || 1);
175+ play(out, SR);
176+ }}
177+ >
178+ ▶ Play this tone
179+ </button>
180+ </FigControls>
181+ </Figure>
182+ );
183+}
webapp/src/tutorial/figures/StabilityFig.tsxadded+179−0View file
@@ -0,0 +1,179 @@
1+// Why the stiff terms are integrated implicitly. The test problem is one
2+// undamped mode, u'' = -w^2 u, stepped by the paper's scheme
3+// (1 + g z) u+ = (2 - (1 - 2g) z) u - (1 + g z) u-, z = (w ht)^2,
4+// which is the explicit central difference at g = 0. The slider pushes the
5+// mode frequency past the explicit ceiling w ht = 2 while the implicit
6+// scheme stays on the unit circle.
7+
8+import { useState } from 'react';
9+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
10+import { Figure, FigControls, FigSlider, usePlayer } from '../figparts';
11+
12+const HEIGHT = 250;
13+const G = 0.2498;
14+const NSTEPS = 90;
15+
16+function stepScheme(z: number, g: number): { u: number[]; rho: number } {
17+ const A = 1 + g * z;
18+ const B = 2 - (1 - 2 * g) * z;
19+ // exact start: u(-dt) = u(0) cos(w dt) with w dt = sqrt(z)
20+ const u: number[] = [1, Math.cos(Math.sqrt(z))];
21+ // recurrence runs forward from (u[1], u[0])
22+ let um = u[0];
23+ let uc = u[1];
24+ for (let k = 2; k < NSTEPS; k++) {
25+ const up = (B * uc - A * um) / A;
26+ u.push(up);
27+ um = uc;
28+ uc = up;
29+ }
30+ // spectral radius of the two-step recurrence
31+ const disc = B * B - 4 * A * A;
32+ let rho: number;
33+ if (disc <= 0) rho = 1;
34+ else rho = (Math.abs(B) + Math.sqrt(disc)) / (2 * A);
35+ return { u, rho };
36+}
37+
38+// For the audio comparison: the frequency the implicit scheme actually
39+// produces for this mode, from the phase angle of its recurrence roots.
40+function schemeFreq(z: number, g: number, stepRate: number): number {
41+ const A = 1 + g * z;
42+ const B = 2 - (1 - 2 * g) * z;
43+ const theta = Math.atan2(Math.sqrt(Math.max(0, 4 * A * A - B * B)), B);
44+ return (theta / (2 * Math.PI)) * stepRate;
45+}
46+
47+const AUDIO_SR = 24000;
48+// A pretend step rate low enough that the slider's whole range is audible.
49+const DEMO_STEP_RATE = 8000;
50+
51+function renderDetuneAudio(fTrue: number, fNum: number): Float32Array<ArrayBuffer> {
52+ const seg = 0.4;
53+ const gap = 0.1;
54+ const n = Math.round(AUDIO_SR * (2 * seg + gap));
55+ const y = new Float32Array(n);
56+ const tone = (f: number, start: number) => {
57+ const n0 = Math.round(start * AUDIO_SR);
58+ const n1 = n0 + Math.round(seg * AUDIO_SR);
59+ const fade = Math.round(0.012 * AUDIO_SR);
60+ for (let k = n0; k < n1; k++) {
61+ let g2 = 0.35;
62+ if (k - n0 < fade) g2 *= (k - n0) / fade;
63+ if (n1 - k < fade) g2 *= (n1 - k) / fade;
64+ y[k] = g2 * Math.sin((2 * Math.PI * f * (k - n0)) / AUDIO_SR);
65+ }
66+ };
67+ tone(fTrue, 0);
68+ tone(fNum, seg + gap);
69+ return y;
70+}
71+
72+export default function StabilityFig() {
73+ const [wht, setWht] = useState(2.2);
74+ const play = usePlayer();
75+
76+ const z = wht * wht;
77+ const ex = stepScheme(z, 0);
78+ const im = stepScheme(z, G);
79+ const fTrue = (wht / (2 * Math.PI)) * DEMO_STEP_RATE;
80+ const fNum = schemeFreq(z, G, DEMO_STEP_RATE);
81+
82+ const ref = useCanvas(
83+ (ctx, w, h) => {
84+ drawFrame(ctx, w, h);
85+ const panels: { u: number[]; rho: number; name: string; color: string }[] = [
86+ { ...ex, name: 'explicit (g = 0)', color: PLOT_COLORS.warn },
87+ { ...im, name: `implicit (g = ${G})`, color: PLOT_COLORS.trace },
88+ ];
89+ const pw = (w - 40) / 2;
90+ panels.forEach((p, pi) => {
91+ const x0 = 14 + pi * (pw + 12);
92+ const y0 = 12;
93+ const ph = h - 50;
94+ ctx.strokeStyle = PLOT_COLORS.grid;
95+ ctx.strokeRect(x0, y0, pw, ph);
96+ ctx.beginPath();
97+ ctx.moveTo(x0, y0 + ph / 2);
98+ ctx.lineTo(x0 + pw, y0 + ph / 2);
99+ ctx.stroke();
100+
101+ // clip to the panel so divergence does not spray over the figure
102+ ctx.save();
103+ ctx.beginPath();
104+ ctx.rect(x0, y0, pw, ph);
105+ ctx.clip();
106+ ctx.strokeStyle = p.color;
107+ ctx.lineWidth = 1.6;
108+ ctx.beginPath();
109+ for (let k = 0; k < NSTEPS; k++) {
110+ const x = x0 + (k / (NSTEPS - 1)) * pw;
111+ const y = y0 + ph / 2 - Math.max(-1e6, Math.min(1e6, p.u[k])) * (ph / 2.4);
112+ if (k === 0) ctx.moveTo(x, y);
113+ else ctx.lineTo(x, y);
114+ }
115+ ctx.stroke();
116+ ctx.lineWidth = 1;
117+ ctx.restore();
118+
119+ ctx.fillStyle = p.color;
120+ ctx.font = '11px system-ui';
121+ ctx.textAlign = 'left';
122+ const growth =
123+ p.rho <= 1 + 1e-12 ? 'bounded (|amplification| = 1)' : `grows ×${p.rho.toFixed(3)} per step`;
124+ ctx.fillText(`${p.name}: ${growth}`, x0 + 6, y0 + ph + 16);
125+ });
126+
127+ ctx.fillStyle = PLOT_COLORS.axis;
128+ ctx.textAlign = 'center';
129+ ctx.fillText(
130+ `one mode at ω·ht = ${wht.toFixed(2)} (explicit ceiling: 2)`,
131+ w / 2,
132+ h - 8,
133+ );
134+ },
135+ [wht, z],
136+ HEIGHT,
137+ );
138+
139+ return (
140+ <Figure
141+ caption={
142+ <>
143+ Ninety steps of one undamped mode under both schemes, at the frequency set by the
144+ slider. The explicit central difference is exact in cost and cheap in memory, but
145+ the moment ω·h<sub>t</sub> passes 2 its recurrence has a root outside
146+ the unit circle and the mode explodes; a plate's biharmonic stiffness puts its
147+ highest grid modes far past that ceiling at any audio time step. With the implicit
148+ weighting at g ≥ 1/4 the roots stay on the unit circle at every frequency (the
149+ paper's g = 0.2498 sits just below 1/4, and is stable to ω·h
150+ <sub>t</sub> ≈ 71, far beyond anything the grid carries). The price of the
151+ implicit form is a linear solve per step, which Section 7 makes affordable, and a
152+ detuning that grows with frequency: the play button compares the mode's true pitch
153+ against the pitch the scheme actually produces, scaled into the audible range by
154+ pretending the step rate were 8 kHz. Near the left end of the slider the two tones
155+ are indistinguishable, which is the regime the audible modes live in; the real
156+ simulation steps at 96 kHz, so a mode this badly flattened would sit far above the
157+ spectrum that matters.
158+ </>
159+ }
160+ >
161+ <canvas ref={ref} />
162+ <FigControls>
163+ <FigSlider
164+ label="ω·ht"
165+ value={wht}
166+ min={0.3}
167+ max={4.5}
168+ step={0.01}
169+ format={(v) => v.toFixed(2)}
170+ onChange={setWht}
171+ />
172+ <button onClick={() => play(renderDetuneAudio(fTrue, fNum), AUDIO_SR)}>
173+ ▶ Hear the detuning: {fTrue.toFixed(0)} Hz true, then {fNum.toFixed(0)} Hz as the
174+ scheme plays it
175+ </button>
176+ </FigControls>
177+ </Figure>
178+ );
179+}
webapp/src/tutorial/figures/WasherFig.tsxadded+161−0View file
@@ -0,0 +1,161 @@
1+// The washer as a boundary condition. The shell rests on a felt sleeve at
2+// its mounting circle; modeled as a spring per unit length, it gives the
3+// whole shell two rigid-body ways to move that a clamp forbids: a bounce
4+// (m = 0) and a tilt (m = 1). The figure animates either one, slowed far
5+// down, with the closed-form rigid-shell frequencies for the stiffness set
6+// by the slider.
7+
8+import { useEffect, useRef, useState } from 'react';
9+import { drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
10+import { Figure, FigControls, FigSlider } from '../figparts';
11+
12+const HEIGHT = 230;
13+const A = 0.23;
14+const C = 0.029;
15+const RISE = 0.04;
16+const RHOH = 8.86; // kg/m^2
17+const SLOW = 8; // animation runs this many times slower than real
18+
19+export default function WasherFig() {
20+ const [kw, setKw] = useState(1.65e5);
21+ const [mode, setMode] = useState<'bounce' | 'tilt'>('bounce');
22+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
23+
24+ const fBounce = Math.sqrt((kw * 2 * Math.PI * C) / (RHOH * Math.PI * (A * A - C * C))) / (2 * Math.PI);
25+ const fTilt = Math.sqrt((4 * kw * C * C * C) / (RHOH * (A ** 4 - C ** 4))) / (2 * Math.PI);
26+
27+ useEffect(() => {
28+ const canvas = canvasRef.current;
29+ if (!canvas) return;
30+ let raf = 0;
31+ const t0 = performance.now();
32+ const draw = () => {
33+ const dpr = window.devicePixelRatio || 1;
34+ const w = canvas.clientWidth;
35+ const bw = Math.max(1, Math.round(w * dpr));
36+ const bh = Math.max(1, Math.round(HEIGHT * dpr));
37+ if (canvas.width !== bw || canvas.height !== bh) {
38+ canvas.width = bw;
39+ canvas.height = bh;
40+ canvas.style.height = `${HEIGHT}px`;
41+ }
42+ const ctx = canvas.getContext('2d')!;
43+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
44+ drawFrame(ctx, w, HEIGHT);
45+
46+ const f = mode === 'bounce' ? fBounce : fTilt;
47+ const tSec = (performance.now() - t0) / 1000;
48+ const phase = Math.sin(2 * Math.PI * (f / SLOW) * tSec);
49+
50+ const cx = w / 2;
51+ const baseY = HEIGHT - 56;
52+ const scale = (w - 80) / (2 * A);
53+ const zScale = (HEIGHT - 130) / RISE;
54+ const ampPx = 13;
55+
56+ const dz = (r: number, side: number) => {
57+ // vertical offset of the shell at signed radius, in px
58+ if (mode === 'bounce') return -phase * ampPx;
59+ return -phase * ampPx * ((side * r) / A);
60+ };
61+ const px = (r: number, side: number) => cx + side * r * scale;
62+ const py = (z: number) => baseY - z * zScale;
63+
64+ // ground
65+ ctx.strokeStyle = PLOT_COLORS.grid;
66+ ctx.beginPath();
67+ ctx.moveTo(20, baseY + 26);
68+ ctx.lineTo(w - 20, baseY + 26);
69+ ctx.stroke();
70+
71+ // springs at +-c
72+ const zc = RISE * (1 - (C * C) / (A * A));
73+ for (const side of [-1, 1]) {
74+ const x = px(C, side);
75+ const topY = py(zc) + dz(C, side) + 4;
76+ const botY = baseY + 26;
77+ ctx.strokeStyle = PLOT_COLORS.accent;
78+ ctx.beginPath();
79+ ctx.moveTo(x, topY);
80+ const nzig = 6;
81+ for (let i = 1; i <= nzig; i++) {
82+ const yy = topY + ((botY - topY) * i) / (nzig + 1);
83+ ctx.lineTo(x + (i % 2 === 0 ? 7 : -7), yy);
84+ }
85+ ctx.lineTo(x, botY);
86+ ctx.stroke();
87+ }
88+
89+ // shell cross-section, displaced rigidly
90+ ctx.strokeStyle = '#c9a227';
91+ ctx.lineWidth = 3;
92+ ctx.beginPath();
93+ const N = 80;
94+ for (let i = 0; i <= N; i++) {
95+ const rr = -A + (2 * A * i) / N;
96+ const side = rr < 0 ? -1 : 1;
97+ const r = Math.abs(rr);
98+ const z = RISE * (1 - (r * r) / (A * A));
99+ const x = px(r, side);
100+ const y = py(z) + dz(r, side);
101+ if (i === 0) ctx.moveTo(x, y);
102+ else ctx.lineTo(x, y);
103+ }
104+ ctx.stroke();
105+ ctx.lineWidth = 1;
106+
107+ ctx.fillStyle = PLOT_COLORS.axis;
108+ ctx.font = '11px system-ui';
109+ ctx.textAlign = 'left';
110+ ctx.fillText(
111+ `${mode === 'bounce' ? 'bounce (m = 0)' : 'tilt (m = 1)'} at ${f.toFixed(1)} Hz, shown ${SLOW}× slower`,
112+ 14, 20,
113+ );
114+ ctx.fillText(
115+ `rigid-shell closed forms: bounce ${fBounce.toFixed(1)} Hz, tilt ${fTilt.toFixed(1)} Hz`,
116+ 14, 38,
117+ );
118+
119+ raf = requestAnimationFrame(draw);
120+ };
121+ raf = requestAnimationFrame(draw);
122+ return () => cancelAnimationFrame(raf);
123+ }, [mode, fBounce, fTilt]);
124+
125+ return (
126+ <Figure
127+ caption={
128+ <>
129+ The whole shell riding on its felt washer, with the mounting circle drawn as the
130+ springs it is. Treating the shell as rigid gives closed forms for the two
131+ rigid-body modes; at the paper's measured stiffness (1.65×10<sup>5</sup>{' '}
132+ N/m per meter of circumference) they land at 22.9 Hz and 4.1 Hz, and letting the
133+ shell flex lowers the bounce to about 13 Hz. Both are below hearing, but they set
134+ the boundary the audible modes see, and under a hard stroke the shell swings away
135+ from the stick by centimeters, which changes what a follow-through stroke does.
136+ The <i>Rigid clamp</i> preset in the simulator removes all of this, as the earlier
137+ literature did, and is worth an A/B listen.
138+ </>
139+ }
140+ >
141+ <canvas ref={canvasRef} />
142+ <FigControls>
143+ <FigSlider
144+ label="washer stiffness"
145+ value={kw}
146+ min={1e4}
147+ max={1e7}
148+ log
149+ format={(v) => `${v.toExponential(2)} N/m/m`}
150+ onChange={setKw}
151+ />
152+ <button className={mode === 'bounce' ? 'tut-active' : ''} onClick={() => setMode('bounce')}>
153+ bounce (m = 0)
154+ </button>
155+ <button className={mode === 'tilt' ? 'tut-active' : ''} onClick={() => setMode('tilt')}>
156+ tilt (m = 1)
157+ </button>
158+ </FigControls>
159+ </Figure>
160+ );
161+}
webapp/src/tutorial/figures/WavenumberFig.tsxadded+164−0View file
@@ -0,0 +1,164 @@
1+// One azimuthal wavenumber at a time. Left: the shape cos(m theta) on the
2+// disk, the pattern every quantity is decomposed into. Right: the eigenvalue
3+// of the discrete azimuthal second difference against the continuum value
4+// m^2, showing where the grid stops being faithful.
5+
6+import { useState } from 'react';
7+import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
8+import { Figure, FigControls, FigSlider } from '../figparts';
9+
10+const HEIGHT = 270;
11+const NTH = 60;
12+
13+export default function WavenumberFig() {
14+ const [m, setM] = useState(4);
15+
16+ const ref = useCanvas(
17+ (ctx, w, h) => {
18+ drawFrame(ctx, w, h);
19+ const half = Math.min(w * 0.42, h);
20+ const cx = half / 2 + 14;
21+ const cy = h / 2;
22+ const R = Math.min(half, h) / 2 - 18;
23+ const Rin = R * 0.14;
24+
25+ // disk shaded by cos(m theta) with a radial envelope, via offscreen bitmap
26+ const px = 160;
27+ const off = document.createElement('canvas');
28+ off.width = px;
29+ off.height = px;
30+ const octx = off.getContext('2d')!;
31+ const img = octx.createImageData(px, px);
32+ for (let yy = 0; yy < px; yy++) {
33+ for (let xx = 0; xx < px; xx++) {
34+ const dx = (xx - px / 2) / (px / 2);
35+ const dy = (yy - px / 2) / (px / 2);
36+ const r = Math.hypot(dx, dy);
37+ const o = 4 * (yy * px + xx);
38+ if (r > 1 || r < Rin / R) {
39+ img.data[o + 3] = 0;
40+ continue;
41+ }
42+ const th = Math.atan2(dy, dx);
43+ const env = (r - Rin / R) / (1 - Rin / R);
44+ const v = Math.cos(m * th) * env; // -1..1
45+ // cyan for +, amber for -
46+ const t = Math.abs(v);
47+ const rr = v >= 0 ? 77 : 232;
48+ const gg = v >= 0 ? 208 : 163;
49+ const bb = v >= 0 ? 225 : 61;
50+ img.data[o] = Math.round(24 + (rr - 24) * t);
51+ img.data[o + 1] = Math.round(27 + (gg - 27) * t);
52+ img.data[o + 2] = Math.round(32 + (bb - 32) * t);
53+ img.data[o + 3] = 255;
54+ }
55+ }
56+ octx.putImageData(img, 0, 0);
57+ ctx.drawImage(off, cx - R, cy - R, 2 * R, 2 * R);
58+ ctx.strokeStyle = PLOT_COLORS.axis;
59+ ctx.beginPath();
60+ ctx.arc(cx, cy, R, 0, 2 * Math.PI);
61+ ctx.stroke();
62+ ctx.fillStyle = PLOT_COLORS.axis;
63+ ctx.font = '11px system-ui';
64+ ctx.textAlign = 'center';
65+ ctx.fillText(`cos(${m}θ): ${m} nodal diameter${m === 1 ? '' : 's'}`, cx, h - 8);
66+
67+ // right panel: -lam2 vs m^2
68+ const padL = half + 60;
69+ const padR = 16;
70+ const padT = 16;
71+ const padB = 34;
72+ const pw = w - padL - padR;
73+ const ph = h - padT - padB;
74+ const mMax = NTH / 2;
75+ const hth = (2 * Math.PI) / NTH;
76+ const yMax = mMax * mMax;
77+ const X = (mm: number) => padL + (mm / mMax) * pw;
78+ const Y = (v: number) => padT + ph - (v / yMax) * ph;
79+
80+ ctx.strokeStyle = PLOT_COLORS.grid;
81+ ctx.strokeRect(padL, padT, pw, ph);
82+
83+ // continuum m^2, dashed
84+ ctx.strokeStyle = 'rgba(139, 147, 161, 0.8)';
85+ ctx.setLineDash([4, 4]);
86+ ctx.beginPath();
87+ for (let mm = 0; mm <= mMax; mm += 0.5) {
88+ const x = X(mm);
89+ const y = Y(mm * mm);
90+ if (mm === 0) ctx.moveTo(x, y);
91+ else ctx.lineTo(x, y);
92+ }
93+ ctx.stroke();
94+ ctx.setLineDash([]);
95+
96+ // discrete eigenvalue
97+ ctx.strokeStyle = PLOT_COLORS.trace;
98+ ctx.lineWidth = 2;
99+ ctx.beginPath();
100+ for (let mm = 0; mm <= mMax; mm += 0.5) {
101+ const lam = (2 - 2 * Math.cos(mm * hth)) / (hth * hth);
102+ const x = X(mm);
103+ const y = Y(lam);
104+ if (mm === 0) ctx.moveTo(x, y);
105+ else ctx.lineTo(x, y);
106+ }
107+ ctx.stroke();
108+ ctx.lineWidth = 1;
109+
110+ // current m marker
111+ const lamM = (2 - 2 * Math.cos(m * hth)) / (hth * hth);
112+ ctx.fillStyle = PLOT_COLORS.accent;
113+ ctx.beginPath();
114+ ctx.arc(X(m), Y(lamM), 4, 0, 2 * Math.PI);
115+ ctx.fill();
116+
117+ ctx.fillStyle = PLOT_COLORS.axis;
118+ ctx.textAlign = 'center';
119+ ctx.fillText('wavenumber m', padL + pw / 2, h - 8);
120+ for (const mm of [0, 10, 20, 30]) {
121+ ctx.fillText(`${mm}`, X(mm), padT + ph + 13);
122+ }
123+ ctx.textAlign = 'left';
124+ ctx.fillStyle = PLOT_COLORS.trace;
125+ ctx.fillText('discrete −λ₂(m)', padL + 8, padT + 14);
126+ ctx.fillStyle = PLOT_COLORS.axis;
127+ ctx.fillText('continuum m²', padL + 8, padT + 28);
128+ ctx.fillText(`grid cutoff m = ${mMax}`, X(mMax) - 100, padT + ph - 8);
129+ },
130+ [m],
131+ HEIGHT,
132+ );
133+
134+ return (
135+ <Figure
136+ caption={
137+ <>
138+ Left: everything on the grid is decomposed azimuthally into patterns cos(mθ)
139+ and sin(mθ); m counts nodal diameters. Right: on the discrete ring the second
140+ derivative in θ acts on such a pattern as multiplication by a single number,
141+ −λ<sub>2</sub>(m) (cyan), which follows the continuum m² (dashed)
142+ at low order and falls away toward the grid cutoff at m = N<sub>θ</sub>/2 = 30.
143+ The two consequences: every linear solve splits into independent small radial
144+ problems, one per wavenumber; and the highest orders the grid carries are
145+ progressively detuned, which is one reason the model's top octaves are duller than
146+ a real crash.
147+ </>
148+ }
149+ >
150+ <canvas ref={ref} />
151+ <FigControls>
152+ <FigSlider
153+ label="wavenumber m"
154+ value={m}
155+ min={0}
156+ max={30}
157+ step={1}
158+ format={(v) => `${Math.round(v)}`}
159+ onChange={(v) => setM(Math.round(v))}
160+ />
161+ </FigControls>
162+ </Figure>
163+ );
164+}
webapp/src/tutorial/math.tsxadded+28−0View file
@@ -0,0 +1,28 @@
1+// KaTeX wrappers for the tutorial. M renders inline math, Eq a display
2+// equation with an optional right-aligned tag, conventionally the equation
3+// number of Samejima (2021) so the tutorial can be read beside the paper.
4+
5+import katex from 'katex';
6+import 'katex/dist/katex.min.css';
7+import { useMemo } from 'react';
8+
9+export function M({ children }: { children: string }) {
10+ const html = useMemo(
11+ () => katex.renderToString(children, { throwOnError: false }),
12+ [children],
13+ );
14+ return <span dangerouslySetInnerHTML={{ __html: html }} />;
15+}
16+
17+export function Eq({ children, tag }: { children: string; tag?: string }) {
18+ const html = useMemo(
19+ () => katex.renderToString(children, { throwOnError: false, displayMode: true }),
20+ [children],
21+ );
22+ return (
23+ <div className="tut-eq">
24+ <div className="tut-eq-body" dangerouslySetInnerHTML={{ __html: html }} />
25+ {tag && <div className="tut-eq-tag">{tag}</div>}
26+ </div>
27+ );
28+}