/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / tutorial / steps.ts
787 lines · 68.3 KBBlameHistoryRaw
1// The tutorial's content: a sequence of steps, each a short lesson on one
2// piece of the physics or the mathematics of the simulator. The text of a
3// step reads the state of the application (the design in the editor, its
4// modes, the last strike) so that the numbers on the page are those of the
5// plate in the view, and the actions of a step drive the application (load
6// a preset, change a parameter, compute the modes, strike, play) so that
7// every formula can be checked against the model. This module has no DOM
8// code, so that the steps can be rendered and checked in Node; the panel
9// that shows them is tutorial.ts. Mathematics is written in TeX between $
10// and $$ delimiters and typeset by the panel.
12import { type Design, profileStats } from "../design.ts";
13import { type Plate, type SimParams, bendingConstant, bendingStiffness, inPlaneSpeed, massProperties, meanThickness, nodeX, nodeY, standRates, strikePoint, thicknessRange, dispersionFrequency, CFL, listenerPosition, GRAVITY } from "../params.ts";
14import { LN1000, plateGeometry, estimatedRate, thermoelasticRelaxation, rateText } from "../loss.ts";
15import { type LinearSystem } from "../model/assemble.ts";
16import { maxAngularFrequency } from "../model/reference.ts";
17import { type ModeSet, defaultSliceSize } from "../modal/modes.ts";
18import { type ContactResult, type ModalModel, listenerWeights, modalRates, strikeGains } from "../modal/synth.ts";
20// ---------- The state the steps read and the host they drive ----------
22/** A mode list remembered by an action for a later comparison. */
23export interface MemoModes {
24 label: string;
25 spacing: number;
26 freqs: number[];
27 count: number;
30export type Memo = Record<string, MemoModes | undefined>;
32export interface TutorialContext {
33 plate: Plate | null;
34 design: Design | null;
35 params: SimParams;
36 /** The spacing at which the plate was evaluated [m]. */
37 spacing: number;
38 sys: LinearSystem | null;
39 modes: ModeSet | null;
40 model: ModalModel | null;
41 lastContact: ContactResult | null;
42 recording: { source: "linear" | "full"; seconds: number } | null;
43 full: { peakForce: number; wmax: number; t: number; done: boolean } | null;
44 gpu: boolean;
45 runMode: "linear" | "full";
46 spectrogram: { window: number; logFreq: boolean };
47 memo: Memo;
50export interface TutorialHost {
51 ctx(): TutorialContext;
52 loadPreset(id: string): Promise<void>;
53 loadScript(source: string): Promise<void>;
54 setParam<K extends keyof SimParams>(key: K, value: SimParams[K]): void;
55 computeModes(): Promise<void>;
56 showMode(m: number | null): void;
57 synthesize(): Promise<void>;
58 play(): void;
59 setRunMode(mode: "linear" | "full"): void;
60 runFull(): Promise<void>;
61 setSpectrogram(opts: { window?: number; logFreq?: boolean }): void;
62 setViewOptions(opts: { color?: "height" | "thickness"; exaggeration?: number }): void;
65export interface TutorialAction {
66 label: string;
67 run: (host: TutorialHost) => Promise<void> | void;
68 needsGpu?: boolean;
71export interface TutorialStep {
72 id: string;
73 chapter: string;
74 title: string;
75 body: (ctx: TutorialContext) => string;
76 actions?: TutorialAction[];
79// ---------- Formatting ----------
81/** A number to `n` significant digits, in plain decimal or as a power of ten. */
82export function sig(x: number, n = 3): string {
83 if (!isFinite(x)) return "?";
84 if (x === 0) return "0";
85 const a = Math.abs(x);
86 if (a >= 1e6 || a < 1e-3) {
87 const e = Math.floor(Math.log10(a));
88 const m = x / 10 ** e;
89 return `${+m.toPrecision(n)}&times;10<sup>${e}</sup>`;
90 }
91 return String(+a.toPrecision(n) * Math.sign(x));
94const hz = (f: number): string => (f < 1000 ? `${f.toFixed(1)} Hz` : `${(f / 1000).toFixed(2)} kHz`);
95const mm = (x: number, d = 2): string => `${(x * 1e3).toFixed(d)} mm`;
97function live(html: string): string {
98 return `<div class="tut-live">${html}</div>`;
100function need(text: string): string {
101 return `<div class="tut-need">${text}</div>`;
103function table(head: string[], rows: string[][]): string {
104 const h = head.map((c) => `<th>${c}</th>`).join("");
105 const b = rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("");
106 return `<table class="tut-table"><thead><tr>${h}</tr></thead><tbody>${b}</tbody></table>`;
108function bar(fraction: number): string {
109 const w = Math.max(0, Math.min(1, fraction)) * 100;
110 return `<span class="tut-bar"><span style="width:${w.toFixed(0)}%"></span></span>`;
113// ---------- Quantities derived from the state ----------
115interface PlateNumbers {
116 E: number; nu: number; rho: number; h: number; D: number; rhoH: number; kappa: number; area: number; mass: number; Z: number;
119function plateNumbers(pl: Plate): PlateNumbers {
120 const h = meanThickness(pl);
121 const D = bendingStiffness(pl.mat, h);
122 const rhoH = pl.mat.rho * h;
123 const { mass, area } = massProperties(pl);
124 return { E: pl.mat.E, nu: pl.mat.nu, rho: pl.mat.rho, h, D, rhoH, kappa: bendingConstant(pl.mat, h), area, mass, Z: 8 * Math.sqrt(D * rhoH) };
127/** The frequencies of the first `n` modes as an array. */
128function firstFreqs(modes: ModeSet | null, n: number): number[] {
129 return modes ? Array.from(modes.freqs.slice(0, n)) : [];
132/** How much of each of the first `count` modes is a rigid translation or a rotation of the plate: sum_k m_k phi_m(k), sum_k m_k x_k phi_m(k), sum_k m_k y_k phi_m(k), each relative to the value of the exact rigid motion. */
133function participation(pl: Plate, model: ModalModel, count: number): { trans: number[]; rotX: number[]; rotY: number[] } {
134 const g = pl.grid;
135 const { mass, cx, cy, Ix, Iy } = massProperties(pl);
136 const n = Math.min(count, model.count);
137 const trans = new Array<number>(n).fill(0), rotX = new Array<number>(n).fill(0), rotY = new Array<number>(n).fill(0);
138 for (let r = 0; r < model.nRows; r++) {
139 const k = model.rowNode[r];
140 const m = model.masses[k];
141 const x = nodeX(g, k % g.nx) - cx, y = nodeY(g, Math.floor(k / g.nx)) - cy;
142 for (let j = 0; j < n; j++) {
143 const phi = model.wShapes[r * model.count + j];
144 trans[j] += m * phi; rotX[j] += m * y * phi; rotY[j] += m * x * phi;
145 }
146 }
147 // A rigid translation, mass-normalized, has sum m phi = sqrt(M); a rotation about x has sum m y phi = sqrt(Ix).
148 return {
149 trans: trans.map((v) => Math.abs(v) / Math.sqrt(mass)),
150 rotX: rotX.map((v) => Math.abs(v) / Math.sqrt(Ix)),
151 rotY: rotY.map((v) => Math.abs(v) / Math.sqrt(Iy)),
152 };
155/** A label for each of the first modes of a plate on a stand (rock, bounce, or plate) with the share of the mode's kinetic energy in that rigid motion. */
156function modeKinds(pl: Plate, model: ModalModel, count: number): { label: string; share: number }[] {
157 const p = participation(pl, model, count);
158 return p.trans.map((t, m) => {
159 const r = Math.max(p.rotX[m], p.rotY[m]);
160 if (t > 0.5) return { label: "bounce", share: t * t };
161 if (r > 0.5) return { label: "rock", share: r * r };
162 return { label: "plate", share: Math.max(t, r) ** 2 };
163 });
165function modeLabels(pl: Plate, model: ModalModel, count: number): string[] {
166 return modeKinds(pl, model, count).map((k) => k.label);
169/** Magnitude of the radiation gain of each of the first `count` modes for the current listener, times omega^2 (a pressure per unit modal amplitude). */
170function pressureGains(pl: Plate, model: ModalModel, p: SimParams, count: number): number[] {
171 const sigma = modalRates(model);
172 const { weight, delay } = listenerWeights(pl, p);
173 const n = Math.min(count, model.count);
174 const re = new Array<number>(n).fill(0), im = new Array<number>(n).fill(0);
175 for (let r = 0; r < model.nRows; r++) {
176 const k = model.rowNode[r];
177 const wk = weight[k];
178 if (wk === 0) continue;
179 const tau = delay[k];
180 for (let m = 0; m < n; m++) {
181 const w = 2 * Math.PI * model.freqs[m];
182 const wd = Math.sqrt(w * w - sigma[m] * sigma[m]);
183 const a = wk * model.wShapes[r * model.count + m] * Math.exp(sigma[m] * tau);
184 re[m] += a * Math.cos(wd * tau); im[m] -= a * Math.sin(wd * tau);
185 }
186 }
187 return re.map((x, m) => Math.hypot(x, im[m]) * (2 * Math.PI * model.freqs[m]) ** 2);
190/** A table of the first modes of two remembered lists side by side with their ratio. */
191function compareTable(a: MemoModes, b: MemoModes, n: number, expected?: number): string {
192 const rows: string[][] = [];
193 for (let m = 0; m < Math.min(n, a.freqs.length, b.freqs.length); m++) {
194 rows.push([String(m + 1), a.freqs[m].toFixed(1), b.freqs[m].toFixed(1), (b.freqs[m] / a.freqs[m]).toFixed(3)]);
195 }
196 return table(["Mode", `${a.label} (Hz)`, `${b.label} (Hz)`, expected !== undefined ? `Ratio (expected ${expected})` : "Ratio"], rows);
199// ---------- Scripts used by the actions ----------
201/** The flat steel rectangle of the preset with some of its numbers changed. */
202export function rectangleScript(o: { thicknessMm?: number; widthIn?: number; heightIn?: number; eta?: number; thermal?: boolean } = {}): string {
203 const t = o.thicknessMm ?? 0.79, w = o.widthIn ?? 12, h = o.heightIn ?? 18;
204 const eta = o.eta ?? 3e-4;
205 const thermal = o.thermal ?? true;
206 return `% The flat steel rectangle of the tutorial (a variant of the preset).
207inch = 0.0254; mm = 1e-3;
209design.name = 'Steel rectangle ${w} x ${h} in, ${t} mm';
210design.material.E = 170e9;
211design.material.nu = 0.29;
212design.material.rho = 7900;
213design.bounds.width = ${w}*inch;
214design.bounds.height = ${h}*inch;
215design.material.lossFactor = ${eta};
216${thermal ? "design.material.alpha = 17e-6;\ndesign.material.cp = 500;\ndesign.material.k = 16;\n" : ""}design.thickness = ${t}*mm;
217`;
220const REFERENCE_NAME = "Flat steel rectangle";
222/** Loads a design, evaluates it at a spacing, computes its modes, and remembers the result under a key. */
223async function remember(host: TutorialHost, key: string, label: string, load: () => Promise<void>, spacing: number): Promise<void> {
224 host.setParam("spacing", spacing);
225 host.setParam("standOn", false);
226 await load();
227 await host.computeModes();
228 const c = host.ctx();
229 if (c.modes) c.memo[key] = { label, spacing, freqs: Array.from(c.modes.freqs.slice(0, 400)), count: c.modes.count };
232// ---------- The steps ----------
234export const STEPS: TutorialStep[] = [
235 // ----- Start -----
236 {
237 id: "intro",
238 chapter: "Start",
239 title: "What the simulator computes",
240 body: (c) => `
241<p>This tutorial goes through the physics and the mathematics behind cymbalsim one idea per page, using the application itself as the laboratory. Each page reads the design in the editor and shows its numbers, and the buttons under <i>Try it</i> load presets, change parameters, compute modes and strike the plate, so that every formula on a page can be checked against the model. The pages build on one another, so a first pass is best taken in order; afterwards the list at the top jumps to any page.</p>
242<p>The object of study is a thin metal plate, described by the script in the sidebar: its material, its outline, its thickness, an optional curved rest shape, the stand it sits on, and how fast its vibrations die away. A drumstick hits it once. The sound is computed in two ways. The <i>linear model</i> assumes the motion is small, finds the natural modes of the plate, and writes the sound as a sum of decaying sine waves, one per mode; this takes a few seconds. The <i>full model</i> keeps the terms that matter when the motion is not small and steps the equations of motion in time on the graphics processor; this takes tens of seconds per second of sound. Both use the same grid, so for a light hit they agree, which the last chapter checks.</p>
243<p>We start with the flat steel rectangle, the simplest design: a sheet hanging freely. The stand is a setting of its own, in the Stand parameters beside the script, and the button below turns it off.</p>
244${c.plate ? live(`In the editor: <b>${c.plate.name}</b>, ${mm(c.plate.Lx, 0)} by ${mm(c.plate.Ly, 0)}, ${c.plate.stand ? "on a stand" : "free"}, on a grid of ${mm(c.spacing, 0)}.`) : need("No design has compiled yet.")}`,
245 actions: [
246 { label: "Load the flat steel rectangle, free", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
247 ],
248 },
250 // ----- Plates -----
251 {
252 id: "stiffness",
253 chapter: "Plates",
254 title: "Bending stiffness",
255 body: (c) => {
256 const n = c.plate ? plateNumbers(c.plate) : null;
257 return `
258<p>A thin plate resists being bent, and the measure of that resistance is the bending stiffness</p>
259$$ D = \\frac{E h^3}{12(1-\\nu^2)}, $$
260<p>where $E$ is Young's modulus, the stress that produces a unit strain, $\\nu$ is the Poisson ratio, the sideways contraction that accompanies a stretch, and $h$ is the thickness. The cube of the thickness is the important part. Bending stretches the outer half of the plate and compresses the inner half; both the stress and its lever arm grow with $h$, so a plate twice as thick is eight times stiffer in bending, at only twice the mass per unit area $\\rho h$.</p>
261<p>The transverse displacement $w(x,y,t)$ of a thin flat plate, small compared with its thickness, obeys the Kirchhoff plate equation</p>
262$$ \\rho h\\, \\frac{\\partial^2 w}{\\partial t^2} + D\\, \\nabla^4 w = f(x,y,t), $$
263<p>with $\\nabla^4 w = w_{xxxx} + 2 w_{xxyy} + w_{yyyy}$ and $f$ the applied pressure. It is Newton's second law for a small piece of the plate: mass per area times acceleration equals the applied pressure minus the restoring pressure from bending. The restoring term has four derivatives where a string or a membrane has two, and that difference shapes everything that follows. The same equation is the condition that the bending energy</p>
264$$ U_b = \\frac12 \\int D\\left[w_{xx}^2 + w_{yy}^2 + 2\\nu\\, w_{xx} w_{yy} + 2(1-\\nu)\\, w_{xy}^2\\right] dA $$
265<p>is stationary, and this is the form the simulator uses: it discretizes the energy and takes its gradient, which handles a free edge and a varying thickness without any extra formula (see <i>The grid</i>).</p>
266${n ? live(`For the design in the editor: $E$ = ${sig(n.E / 1e9)} GPa, $\\nu$ = ${n.nu}, $\\rho$ = ${n.rho} kg/m³, mean thickness $h$ = ${mm(n.h)}.<br>$D$ = ${sig(n.D)} N m, and $\\rho h$ = ${sig(n.rhoH)} kg/m². A plate twice as thick would have $D$ = ${sig(8 * n.D)} N m.`) : need("Load a design to see its numbers.")}`;
267 },
268 actions: [
269 { label: "Twice as thick (1.58 mm)", run: (h) => h.loadScript(rectangleScript({ thicknessMm: 1.58 })) },
270 { label: "Back to 0.79 mm", run: (h) => h.loadPreset("flat-rectangle") },
271 ],
272 },
273 {
274 id: "waves",
275 chapter: "Plates",
276 title: "Bending waves are dispersive",
277 body: (c) => {
278 const n = c.plate ? plateNumbers(c.plate) : null;
279 const at = (f: number) => { const k = Math.sqrt((2 * Math.PI * f) / n!.kappa); return { k, lambda: (2 * Math.PI) / k, speed: n!.kappa * k }; };
280 return `
281<p>Try a traveling wave $w = \\cos(kx - \\omega t)$ in the plate equation with $f = 0$: $\\nabla^4 w = k^4 w$ and $w_{tt} = -\\omega^2 w$, so</p>
282$$ \\omega = \\kappa k^2, \\qquad \\kappa = \\sqrt{\\frac{D}{\\rho h}} = h\\sqrt{\\frac{E}{12\\rho(1-\\nu^2)}} . $$
283<p>The frequency grows with the square of the wavenumber $k = 2\\pi/\\lambda$. A string or a column of air has $\\omega = ck$ with a fixed speed $c$, so the wavelengths that fit on it give frequencies in the ratio 1 : 2 : 3, a harmonic series. On a plate the same wavelengths give 1 : 4 : 9, and the modes of a real plate, whose wavelengths are not so simple, land at frequencies with no common factor. That is why a plate or a cymbal has no definite pitch: its overtones are inharmonic. The phase speed $\\omega/k = \\kappa k = \\sqrt{\\kappa\\omega}$ grows with frequency, so short waves outrun long ones. This is what <i>dispersive</i> means, and it is why a struck plate sounds a brief high tick before the low ring.</p>
284<p>The constant $\\kappa$, in m²/s, collects everything that the material and the thickness contribute, and it is proportional to the thickness. Note that $E/\\rho$ is nearly the same for steel, aluminum and bronze, so the thickness and the size of a plate matter more to its frequencies than what it is made of.</p>
285${n ? live(`$\\kappa$ = ${sig(n.kappa)} m²/s for this design; $E/\\rho$ = ${sig(n.E / n.rho / 1e6)} MN m/kg.<br>At 100 Hz: wavelength ${mm(at(100).lambda, 0)}, phase speed ${sig(at(100).speed)} m/s.<br>At 1 kHz: wavelength ${mm(at(1000).lambda, 0)}, phase speed ${sig(at(1000).speed)} m/s.<br>At 10 kHz: wavelength ${mm(at(10000).lambda, 0)}, phase speed ${sig(at(10000).speed)} m/s, against 343 m/s for sound in air.`) : need("Load a design to see its numbers.")}`;
286 },
287 },
289 // ----- Modes -----
290 {
291 id: "modes",
292 chapter: "Modes",
293 title: "Modes: the standing waves of the plate",
294 body: (c) => `
295<p>A plate of finite size cannot carry a single traveling wave: the waves reflect from the edges and interfere, and what survives are the standing waves that fit the outline, the <i>modes</i>. A mode is a shape $\\phi(x,y)$ that vibrates as a whole, $w = \\phi(x,y)\\cos(\\omega t)$, with its own frequency; every point moves in phase or in antiphase, and the lines where $\\phi = 0$, the <i>nodal lines</i>, do not move at all. Sand sprinkled on a vibrating plate collects on them, which is how Chladni first drew them.</p>
296<p>On the grid the plate is a system of $n$ mass points connected by the discretized bending energy, and a mode is a solution of the generalized eigenvalue problem</p>
297$$ K\\phi = \\omega^2 M\\phi, $$
298<p>where $K$ is the stiffness matrix, the second derivative of the energy with respect to the displacements, and $M$ is the diagonal matrix of the node masses. There are as many modes as unknowns, and their shapes are orthogonal through the mass, $\\phi_i^T M \\phi_j = 0$ for $i \\ne j$; they are scaled so that $\\phi^T M\\phi = 1$, which makes the later formulas simple. A free plate has three modes at zero frequency, the rigid translation and the two rigid rotations, which cost no bending energy; they are computed and discarded, so the first mode in the list is the first elastic one.</p>
299<p>For a free rectangle the first mode is the twist, with nodal lines along the two center lines; the next are the bending modes along the length and across the width. Count the nodal lines: each higher mode adds one, and the wavelength shortens as the frequency rises.</p>
300${c.modes && c.sys ? live(`${c.sys.n} unknowns; ${c.modes.count} modes below ${hz(Math.min(c.params.modeMaxFreq, c.params.fs / 2))}.<br>The first six: ${firstFreqs(c.modes, 6).map((f) => f.toFixed(1)).join(", ")} Hz.`) : need("The modes of this design have not been computed. Press <i>Compute the modes</i>.")}`,
301 actions: [
302 { label: "Compute the modes", run: (h) => h.computeModes() },
303 { label: "Show mode 1", run: (h) => h.showMode(0) },
304 { label: "Mode 2", run: (h) => h.showMode(1) },
305 { label: "Mode 3", run: (h) => h.showMode(2) },
306 { label: "Mode 6", run: (h) => h.showMode(5) },
307 { label: "Mode 20", run: (h) => h.showMode(19) },
308 { label: "Deselect", run: (h) => h.showMode(null) },
309 ],
310 },
311 {
312 id: "scaling",
313 chapter: "Modes",
314 title: "Scaling laws",
315 body: (c) => {
316 const ref = c.memo.ref, thick = c.memo.thick, half4 = c.memo.half4, half8 = c.memo.half8;
317 let tables = "";
318 if (ref && thick) tables += compareTable(ref, thick, 6, 2);
319 if (ref && half4) tables += compareTable(ref, half4, 6, 4);
320 if (ref && half8) tables += compareTable(ref, half8, 6, 4);
321 return `
322<p>The plate equation has a single constant, $\\kappa$, and the only other lengths are those of the outline. Measuring lengths in units of a size $L$ of the plate, every mode's frequency has the form</p>
323$$ f = \\frac{\\lambda^2}{2\\pi}\\,\\frac{\\kappa}{L^2}, $$
324<p>with a number $\\lambda^2$ that depends only on the shape of the outline, on the boundary conditions, and on which mode it is (about 13.5 for the first mode of a free square with $L$ its side, about 5.25 for the first mode of a free disc with $L$ its radius). Therefore doubling the thickness doubles every frequency, since $\\kappa \\propto h$; halving the size, width and height together, quadruples them; and changing the material scales them by $\\sqrt{E/\\rho(1-\\nu^2)}$. The mode shapes do not change at all. This is why the thin edge of a crash and the size of a ride matter as they do, and it is a quick check of any computed mode list.</p>
325<p>Try it: compute the modes of the reference plate, then of a plate twice as thick, then of a plate half as large. For the half-size plate the grid spacing is halved as well, so that the grid has the same number of cells; the discrete problem is then an exact scaled copy of the reference, and the ratio should be 4 to rounding. With the spacing left at 8 mm the ratio comes out a little off, and the difference measures the error of the coarser grid (next chapter).</p>
326${tables || need("Compute the reference plate and one of the variants to see the comparison.")}`;
327 },
328 actions: [
329 { label: "Reference: 0.79 mm, 12 by 18 in, 8 mm grid", run: (h) => remember(h, "ref", REFERENCE_NAME, () => h.loadPreset("flat-rectangle"), 0.008) },
330 { label: "Twice as thick", run: (h) => remember(h, "thick", "1.58 mm", () => h.loadScript(rectangleScript({ thicknessMm: 1.58 })), 0.008) },
331 { label: "Half the size, 4 mm grid", run: (h) => remember(h, "half4", "6 by 9 in at 4 mm", () => h.loadScript(rectangleScript({ widthIn: 6, heightIn: 9 })), 0.004) },
332 { label: "Half the size, 8 mm grid", run: (h) => remember(h, "half8", "6 by 9 in at 8 mm", () => h.loadScript(rectangleScript({ widthIn: 6, heightIn: 9 })), 0.008) },
333 ],
334 },
335 {
336 id: "count",
337 chapter: "Modes",
338 title: "How many modes",
339 body: (c) => {
340 const n = c.plate ? plateNumbers(c.plate) : null;
341 let tab = "";
342 if (n && c.modes) {
343 const rows = [250, 500, 1000, 2000, 4000, 8000].filter((f) => f <= Math.min(c.params.modeMaxFreq, c.params.fs / 2)).map((f) => {
344 let count = 0;
345 for (const v of c.modes!.freqs) if (v < f) count++;
346 return [hz(f), ((n.area * f) / (2 * n.kappa)).toFixed(0), String(count)];
347 });
348 tab = table(["Below", "Estimate $A f / 2\\kappa$", "Computed"], rows);
349 }
350 return `
351<p>Since $\\omega = \\kappa k^2$, the modes below a frequency $f$ are those whose wavenumber lies inside the circle of radius $K = \\sqrt{2\\pi f/\\kappa}$ in the plane of the wavenumbers $(k_x, k_y)$. For a rectangle $a \\times b$ the standing waves have $k_x = m\\pi/a$ and $k_y = n\\pi/b$, one mode per cell of area $\\pi^2/(ab)$ in the quarter plane $k_x, k_y \\gt 0$, so the count below $K$ is the area of the quarter disc over the area of a cell, $N = (\\pi K^2/4)/(\\pi^2/A) = AK^2/(4\\pi)$, and with $K^2 = 2\\pi f/\\kappa$</p>
352$$ N(f) \\approx \\frac{A f}{2\\kappa}. $$
353<p>The count grows linearly with frequency, so the modes of a plate are evenly spaced on average, $2\\kappa/A$ hertz apart, and the estimate holds for any outline and any edge condition once the wavelength is small compared with the plate (this is Weyl's law for the plate). A 16 inch cymbal 1 mm thick has a mode every 1.6 Hz on average and several hundred below 1 kHz; that density is what makes a cymbal noise-like rather than bell-like.</p>
354<p>At the top of the range the computed count exceeds the estimate. The grid lowers the frequencies of the short waves (next page), so more of its modes fall below the cutoff than the continuum would put there, and the number of grid modes is finite, one per node; at the default cutoff of 16 kHz nearly all of the grid's modes are included.</p>
355${n && c.modes ? live(`Area $A$ = ${sig(n.area)} m², $\\kappa$ = ${sig(n.kappa)} m²/s: one mode every ${sig((2 * n.kappa) / n.area)} Hz on average.${tab}`) : need("Compute the modes of a design to compare the estimate with the count.")}`;
356 },
357 actions: [{ label: "Compute the modes", run: (h) => h.computeModes() }],
358 },
360 // ----- Numerics -----
361 {
362 id: "grid",
363 chapter: "Numerics",
364 title: "The grid",
365 body: (c) => {
366 const pl = c.plate;
367 let liveText = "";
368 if (pl) {
369 const hm = meanThickness(pl);
370 const f3 = dispersionFrequency(pl.mat, hm, pl.grid.hx, 0.03), f10 = dispersionFrequency(pl.mat, hm, pl.grid.hx, 0.1);
371 liveText = `Grid ${pl.grid.nx} by ${pl.grid.ny} at ${mm(pl.grid.hx, 1)}. At the mean thickness the grid lowers a partial by 3 percent near ${hz(f3)} and by 10 percent near ${hz(f10)}.`;
372 const ref = c.memo.ref;
373 if (ref && c.modes && pl.name === REFERENCE_NAME && Math.abs(c.spacing - ref.spacing) > 1e-9) {
374 const rows = [1, 3, 10, 30, 100, 300].filter((m) => m <= ref.freqs.length && m <= c.modes!.count).map((m) => [String(m), ref.freqs[m - 1].toFixed(1), c.modes!.freqs[m - 1].toFixed(1), (c.modes!.freqs[m - 1] / ref.freqs[m - 1]).toFixed(3)]);
375 liveText += table(["Mode", `At ${mm(ref.spacing, 0)} (Hz)`, `At ${mm(c.spacing, 0)} (Hz)`, "Ratio"], rows);
376 } else if (ref && pl.name === REFERENCE_NAME && Math.abs(c.spacing - ref.spacing) < 1e-9) {
377 liveText += "<br>This is the reference grid; choose a coarser or a finer one to compare.";
378 }
379 }
380 return `
381<p>The plate is covered by a square grid of spacing $h_g$ (8 mm by default), and the displacement is known at the nodes only. A derivative becomes a difference of neighboring values: the second derivative at node $i$ is</p>
382$$ w_{xx} \\approx \\frac{w_{i+1} - 2w_i + w_{i-1}}{h_g^2}, $$
383<p>and the mixed derivative $w_{xy}$ is taken at the center of each cell from its four corners. Putting these into the bending energy and summing over the nodes gives a discrete energy, a quadratic function of the $n$ nodal displacements; its gradient is the force on each node and its second derivative is the stiffness matrix $K$. In the interior this gradient is the classical 13-point stencil of $\\nabla^4$; at a free edge, where some differences do not exist, the energy simply has fewer terms, and the force comes out right without any boundary formula. The mass of a node is the mass of the plate around it, $m_k = \\rho h_k h_g^2 \\omega_k$, with $\\omega_k$ the fraction of the four surrounding cells that belong to the plate (1 inside, 1/2 on an edge, 1/4 at a corner).</p>
384<p>The price of the grid is that it misrepresents short waves. A wave $\\cos kx$ sampled at the nodes has the discrete second difference $-(2 - 2\\cos kh_g)/h_g^2$ instead of $-k^2$, so the grid's frequency is low by the factor</p>
385$$ \\frac{\\omega_{\\mathrm{grid}}}{\\omega} = \\frac{2 - 2\\cos(kh_g)}{(kh_g)^2}, $$
386<p>which is 0.97 at $kh_g = 0.6$, about ten nodes per wavelength, and 0.90 at $kh_g = 1.1$, about six. Halving the spacing moves these frequencies up fourfold, at four times the nodes and, for the modes, about sixteen times the work. The high partials are therefore flattened by the default grid, in both models alike, and a finer grid is a matter of patience rather than of method.</p>
387${liveText ? live(liveText) : need("Load a design to see its grid.")}
388<p>Try it on the reference plate of the previous page: at 12 mm the low modes barely move and the high ones drop; at 6 mm they rise toward their converged values.</p>`;
389 },
390 actions: [
391 { label: "Reference at 8 mm", run: (h) => remember(h, "ref", REFERENCE_NAME, () => h.loadPreset("flat-rectangle"), 0.008) },
392 { label: "Coarser: 12 mm", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
393 { label: "Finer: 6 mm", run: async (h) => { h.setParam("spacing", 0.006); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.computeModes(); } },
394 { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
395 ],
396 },
397 {
398 id: "eigen",
399 chapter: "Numerics",
400 title: "Computing the modes",
401 body: (c) => {
402 const s = c.sys, m = c.modes;
403 let liveText = "";
404 if (s) {
405 const flops = s.n * s.bw * s.bw;
406 const slice = defaultSliceSize(s.bw);
407 liveText = `$n$ = ${s.n} unknowns (${s.fields} per node), half-bandwidth $b$ = ${s.bw}: the band holds ${sig(s.n * (s.bw + 1))} numbers, and one factorization costs about ${sig(flops / 1e6)} million operations.`;
408 if (m) liveText += `<br>${m.expected} modes below the cutoff by the inertia count, ${m.count} delivered${m.missing ? `, ${m.missing} missing` : ""}, in slices of about ${slice} (about ${Math.ceil(m.expected / slice)} slices), in ${m.seconds.toFixed(1)} s.`;
409 }
410 return `
411<p>The stiffness matrix of a plate with $n$ nodes has $n^2$ entries, but nearly all of them are zero: the force on a node depends only on its neighbors within two cells. If the nodes are numbered row by row, the nonzero entries lie within a band of half-width $b$ of about twice the number of nodes across the shorter side of the grid, and only that band is stored. The $LDL^T$ factorization of a banded symmetric matrix, Gaussian elimination arranged for such matrices, costs about $nb^2$ operations and keeps the band, so it is cheap.</p>
412<p>Two facts make the factorization the tool for the modes. First, by Sylvester's law of inertia, the number of negative entries of $D$ in $K - \\sigma M = LDL^T$ equals the number of modes with $\\omega^2 \\lt \\sigma$, so one factorization counts exactly how many modes lie below any frequency, and no mode can be missed unnoticed. Second, the factorization solves $(K - \\sigma M)x = My$, and the operator $(K - \\sigma M)^{-1}M$ has the same modes as the plate with the eigenvalues $1/(\\omega^2 - \\sigma)$, largest for the modes nearest $\\sigma$. The Lanczos method finds the largest eigenvalues of a symmetric operator from a few dozen applications of it, by building an orthogonal basis of the vectors $y, Ay, A^2y, \\ldots$ in which the operator is tridiagonal and its eigenvalues are easy. The range up to the cutoff is therefore cut into slices, the inertia count fixes how many modes each slice holds, each slice is factorized at its center and solved by Lanczos until that many modes have converged, and the slices run in parallel in Web Workers.</p>
413<p>A flat plate has one unknown per node. A curved one has three, the two in-plane displacements and the transverse one, so $n$ and $b$ are both three times larger and a factorization costs 27 times as much; such a design takes tens of seconds rather than seconds.</p>
414${liveText ? live(liveText) : need("Compute the modes of a design to see the sizes.")}`;
415 },
416 actions: [{ label: "Compute the modes", run: (h) => h.computeModes() }],
417 },
419 // ----- Sound -----
420 {
421 id: "decay",
422 chapter: "Sound",
423 title: "Decay",
424 body: (c) => {
425 let tab = "";
426 if (c.plate && c.model) {
427 const sigma = modalRates(c.model);
428 const t60 = rateText;
429 const rows: string[][] = [];
430 for (let m = 0; m < Math.min(8, c.model.count); m++) {
431 rows.push([String(m + 1), c.model.freqs[m].toFixed(1), t60(sigma[m]), t60(c.model.sigmaRad[m]), t60(c.model.sigmaMat[m]), t60(c.model.sigmaTe[m]), t60(c.model.padSigma[m])]);
432 }
433 tab = table(["Mode", "f (Hz)", "T60 (s)", "air alone", "metal alone", "thermal alone", "felt alone"], rows);
434 }
435 let est = "";
436 if (c.plate) {
437 const pl = c.plate, geom = plateGeometry(pl);
438 const th = pl.mat.thermal;
439 const rel = thermoelasticRelaxation(pl.mat, geom.hMean, pl.temperature);
440 const t60 = rateText;
441 const e = [100, 1000, 5000].map((f) => estimatedRate(pl, geom, f));
442 est = `This design: loss factor $\\eta$ = ${sig(pl.mat.eta, 2)}, so internal friction alone gives $T_{60}$ = ${t60(e[0].material)} / ${t60(e[1].material)} / ${t60(e[2].material)} s at 100 Hz / 1 kHz / 5 kHz.<br>` +
443 (th ? `Thermoelastic: $\\Delta$ = ${sig(rel.delta, 2)}, $\\tau$ = ${sig(rel.tau * 1e3, 2)} ms at the mean thickness, so the loss peaks near ${(1 / (2 * Math.PI * rel.tau)).toFixed(0)} Hz at $\\eta_{te}$ = ${sig(rel.delta / 2, 2)}; alone it gives $T_{60}$ = ${t60(e[0].thermoelastic)} / ${t60(e[1].thermoelastic)} / ${t60(e[2].thermoelastic)} s.<br>` : "No thermal constants in the script, so no thermoelastic damping.<br>") +
444 `Radiation (average efficiency, coincidence at ${(geom.fc / 1e3).toFixed(1)} kHz): alone $T_{60}$ = ${t60(e[0].radiation)} / ${t60(e[1].radiation)} / ${t60(e[2].radiation)} s. All together: ${t60(e[0].total)} / ${t60(e[1].total)} / ${t60(e[2].total)} s.` +
445 (tab ? tab : "<br>Compute the modes to see the exact values per mode.");
446 }
447 return `
448<p>A real plate loses energy. In the linear model each mode is a damped oscillator,</p>
449$$ \\ddot q + 2\\sigma\\dot q + \\omega^2 q = 0, \\qquad q(t) = e^{-\\sigma t}\\cos(\\omega_d t), \\quad \\omega_d = \\sqrt{\\omega^2 - \\sigma^2}, $$
450<p>whose amplitude falls by the factor $e$ in the time $1/\\sigma$. Musicians and acousticians state a decay by the time $T_{60}$ over which the level drops by 60 decibels, a factor of 1000 in amplitude: $e^{-\\sigma T_{60}} = 10^{-3}$, so $\\sigma = \\ln(1000)/T_{60}$. The quality factor $Q = \\omega/(2\\sigma)$ counts, roughly, the cycles before the amplitude has fallen by a factor of 20; the modes of a plate have $Q$ in the hundreds to thousands, which is why each partial is a sharp line in the spectrogram.</p>
451<p>Nothing in the script prescribes the decay. The rate of each mode is the sum of four mechanisms, each with its own physics.</p>
452<p><i>Internal friction of the metal.</i> A hysteretic loss: each cycle dissipates the fraction $2\\pi\\eta$ of the stored energy, so a mode decays at $\\sigma = \\eta\\omega/2$. The loss factor $\\eta$ is a material constant like $E$ and $\\rho$, measured rather than derived, of order $10^{-4}$ for bronze, steel and aluminum; hammering and alloying change it. Alone it would let the low modes ring for minutes.</p>
453<p><i>Thermoelastic damping.</i> Bending warms the compressed face and cools the stretched one, and the heat that flows across the thickness is lost. Zener's analysis gives the loss factor</p>
454$$ \\eta_{te} = \\Delta\\,\\frac{\\omega\\tau}{1 + (\\omega\\tau)^2}, \\qquad \\Delta = \\frac{E\\alpha^2 T}{\\rho c_p}\\,\\frac{1+\\nu}{1-\\nu}, \\qquad \\tau = \\frac{h^2 \\rho c_p}{\\pi^2 k}, $$
455<p>from the thermal expansion $\\alpha$, the specific heat $c_p$, the conductivity $k$ and the temperature $T$; $\\tau$ is the time heat takes to cross the thickness, and the loss peaks at $\\omega\\tau = 1$. The model applies it to the bending energy of each mode with the local thickness. For a millimeter of metal the peak lies below 100 Hz and the effect is small; it matters for thin aluminum.</p>
456<p><i>Radiation.</i> The sound itself carries energy away. The power a mode radiates follows from its shape through the same Rayleigh integral that gives the listener's pressure, summed over all directions (the page <i>From the plate to the ear</i>), and the mode decays at $\\sigma = P/(2E)$. A plate with free edges radiates surprisingly little below its coincidence frequency, where the bending wavelength equals the wavelength of sound: its modes have no net volume velocity, since they are orthogonal to the rigid motions, and a free edge, where the moment and the shear vanish, lacks the uncancelled strip that makes a simply supported edge radiate. The efficiency rises steeply toward coincidence, so the radiation overtakes the metal only in the top octaves, and it is there that the high partials die first. The column <i>air alone</i> below shows how weak it is for the low modes.</p>
457<p><i>The felt of a stand</i> adds its own loss to the modes that move the center (the page on the stand).</p>
458${est ? live(est) : need("Load a design to see its losses.")}
459<p>Try it: raise the loss factor to $10^{-3}$, the value of a heavily worked alloy, and hear the ring shorten; remove it and hear the low modes ring on with only the air to stop them.</p>`;
460 },
461 actions: [
462 { label: "Compute the modes", run: (h) => h.computeModes() },
463 { label: "Loss factor 1e-3", run: async (h) => { await h.loadScript(rectangleScript({ eta: 1e-3 })); await h.synthesize(); } },
464 { label: "No internal friction", run: async (h) => { await h.loadScript(rectangleScript({ eta: 0 })); await h.synthesize(); } },
465 { label: "The preset", run: async (h) => { h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); await h.synthesize(); } },
466 { label: "Play", run: (h) => h.play() },
467 ],
468 },
469 {
470 id: "strike",
471 chapter: "Sound",
472 title: "The strike",
473 body: (c) => {
474 const n = c.plate ? plateNumbers(c.plate) : null;
475 const p = c.params;
476 let liveText = "";
477 if (n && c.plate) {
478 const sp = strikePoint(c.plate, p);
479 liveText = `Driving-point impedance $Z$ = ${sig(n.Z)} N s/m; $M_s/Z$ = ${sig((p.stickMass / n.Z) * 1e3)} ms; $Zv_0$ = ${sig(n.Z * p.strikeVelocity)} N for a stick of ${(p.stickMass * 1e3).toFixed(0)} g at ${p.strikeVelocity} m/s, striking at (${(sp.x * 1e3).toFixed(0)}, ${(sp.y * 1e3).toFixed(0)}) mm from the center.`;
480 if (c.lastContact) liveText += `<br>Last synthesized strike: peak force ${c.lastContact.peakForce.toFixed(0)} N, contact ${(c.lastContact.contactTime * 1e3).toFixed(2)} ms, so energy up to about ${hz(1 / c.lastContact.contactTime)}.`;
481 else liveText += "<br>Synthesize a strike to see its peak force and duration.";
482 }
483 return `
484<p>The stick tip is a free mass $M_s$ (30 g by default, the effective mass of a stick in a wrist stroke) arriving at the speed $v_0$. While it is pressed into the plate by a penetration $\\delta$, the wood and the metal deform locally and push back with the Hertz contact force</p>
485$$ F = K\\delta^{\\alpha}, \\qquad \\alpha = \\tfrac32, $$
486<p>the law for two curved elastic bodies pressed together, with $K$ set by the tip radius and the elastic constants of wood and metal; the default of $10^7$ N/m<sup>1.5</sup> is softer than a hard wooden tip on metal would give, and stands for the compliance of a real tip. The stick obeys $M_s\\ddot z = F$, the plate is pushed by $-F$ over the small patch of nodes under the tip, and the force is zero once the two separate. The stick may bounce and hit again within a millisecond; after 5 ms of free flight it is withdrawn, as a drummer lifts it.</p>
487<p>How long does the contact last? A large thin plate is a soft target. When a force $F$ is applied at a point, the point starts moving at once at the velocity $F/Z$, where</p>
488$$ Z = 8\\sqrt{D\\rho h} $$
489<p>is the driving-point impedance of an infinite plate, a real constant that does not depend on frequency, one of the few exact results of plate theory. The stick, decelerated by the reaction, gives up its momentum in a time of order $M_s/Z$, and the peak force is at most about $Zv_0$; the Hertz compliance rounds off the start of the pulse and the finite plate's modes ring under it. A pulse of duration $\\tau$ carries energy up to about $1/\\tau$ in frequency, so a millisecond of contact excites the modes below a kilohertz strongly and the higher ones progressively less; a harder tip or a lighter stick shortens the contact and brightens the sound. In the linear model a harder hit is only a louder one, since nothing in the plate stiffens; the full model knows better (last chapter).</p>
490${liveText ? live(liveText) : need("Load a design to see its impedance.")}`;
491 },
492 actions: [
493 { label: "Synthesize the strike", run: (h) => h.synthesize() },
494 { label: "Soft: 1 m/s", run: async (h) => { h.setParam("strikeVelocity", 1); await h.synthesize(); } },
495 { label: "Hard: 10 m/s", run: async (h) => { h.setParam("strikeVelocity", 10); await h.synthesize(); } },
496 { label: "Light stick: 10 g", run: async (h) => { h.setParam("stickMass", 0.01); await h.synthesize(); } },
497 { label: "Defaults: 3 m/s, 30 g", run: async (h) => { h.setParam("strikeVelocity", 3); h.setParam("stickMass", 0.03); await h.synthesize(); } },
498 { label: "Play", run: (h) => h.play() },
499 ],
500 },
501 {
502 id: "excitation",
503 chapter: "Sound",
504 title: "Which modes the strike excites",
505 body: (c) => {
506 let tab = "";
507 if (c.plate && c.model) {
508 const g = strikeGains(c.model, c.plate, c.params);
509 const n = Math.min(8, c.model.count);
510 let max = 0;
511 for (let m = 0; m < n; m++) max = Math.max(max, Math.abs(g[m]));
512 const rows: string[][] = [];
513 for (let m = 0; m < n; m++) {
514 const rel = max > 0 ? Math.abs(g[m]) / max : 0;
515 rows.push([String(m + 1), c.model.freqs[m].toFixed(1), rel.toFixed(2), bar(rel)]);
516 }
517 const sp = strikePoint(c.plate, c.params);
518 tab = `Strike at (${(sp.x * 1e3).toFixed(0)}, ${(sp.y * 1e3).toFixed(0)}) mm from the center, fractions (${c.params.strikeFracX}, ${c.params.strikeFracY}) of the box.` + table(["Mode", "f (Hz)", "|g| relative", ""], rows);
519 }
520 return `
521<p>Expanding the motion in the modes, $w = \\sum_m \\phi_m q_m(t)$, turns the plate equation into one independent oscillator per mode,</p>
522$$ \\ddot q_m + 2\\sigma_m\\dot q_m + \\omega_m^2 q_m = \\phi_m^T f(t), $$
523<p>because the modes are orthogonal through the mass: multiplying the plate equation by $\\phi_m^T$ picks out mode $m$ alone. The right side is the <i>modal force</i>, the applied force weighted by the shape of the mode where the force is applied; for the stick it is $-Fg_m$ with $g_m$ the value of the shape at the strike point, averaged over the contact patch. A mode struck on one of its nodal lines has $g_m = 0$ and stays silent however hard the hit; a mode struck at an antinode receives the most. The energy given to a mode is proportional to $g_m^2$, so the strike point is a filter on the spectrum. At the center of a free rectangle the twist mode and every other mode with a nodal line through the center are missing and the sound is dull; near an edge every mode is present and the sound is bright. Drummers use exactly this, playing a cymbal at the edge for a crash and near the bell for a ping.</p>
524${tab ? live(tab) : need("Compute the modes to see the gains of the first modes at the strike point.")}
525<p>Try it: strike the center, then a corner, and show mode 1 to see where its nodal lines are.</p>`;
526 },
527 actions: [
528 { label: "Compute the modes", run: (h) => h.computeModes() },
529 { label: "Strike the center", run: async (h) => { h.setParam("strikeFracX", 0.5); h.setParam("strikeFracY", 0.5); } },
530 { label: "Strike a corner", run: async (h) => { h.setParam("strikeFracX", 0.1); h.setParam("strikeFracY", 0.1); } },
531 { label: "Strike the edge (default)", run: async (h) => { h.setParam("strikeFracX", 0.9); h.setParam("strikeFracY", 0.5); } },
532 { label: "Show mode 1", run: (h) => h.showMode(0) },
533 { label: "Synthesize and play", run: async (h) => { await h.synthesize(); h.play(); } },
534 ],
535 },
536 {
537 id: "synthesis",
538 chapter: "Sound",
539 title: "Modal synthesis",
540 body: (c) => {
541 const m = c.model, p = c.params;
542 return `
543<p>Once the stick has left, no force acts and every oscillator rings on its own: $q_m(t) = \\mathrm{Re}[A_m e^{s_m t}]$ with $s_m = -\\sigma_m + i\\omega_{d,m}$ and a complex amplitude $A_m$ that holds the displacement and the velocity at the moment of release. The sound is then a sum of decaying sinusoids, which the computer generates exactly with a two-term recurrence. With $T$ the sample period,</p>
544$$ x_{n+1} = 2e^{-\\sigma T}\\cos(\\omega_d T)\\, x_n - e^{-2\\sigma T} x_{n-1}, $$
545<p>two multiplications and one addition per mode per sample; a plate of two thousand modes costs a few thousand operations per sample and a fraction of a second for several seconds of sound. During the contact itself, while the force is changing, each oscillator is advanced over steps of 2 microseconds with the force held constant over the step, using the exact solution of the oscillator under a constant force; this is accurate for the plate at any step and leaves only the stick to be integrated explicitly.</p>
546<p>The movie is the same sum evaluated at every node at the frame times, $w_k(t) = \\sum_m \\phi_m(k)\\, q_m(t)$, the most expensive part of the synthesis: modes times nodes times frames. In the spectrogram the modal frequencies are drawn as ticks at the left edge, and each horizontal line of the sound sits on one of them.</p>
547${m ? live(`${m.count} modes, ${p.duration} s at ${p.fs / 1000} kHz: ${sig(m.count * p.duration * p.fs / 1e6)} million recurrence steps for the sound, ${sig(m.count * m.nRows * p.duration * p.fps / 1e6)} million products for the movie.${c.recording && c.recording.source === "linear" ? `<br>A recording of ${c.recording.seconds.toFixed(2)} s is loaded; press Play.` : ""}`) : need("Compute the modes and synthesize to hear the result.")}`;
548 },
549 actions: [
550 { label: "Synthesize", run: (h) => h.synthesize() },
551 { label: "Play", run: (h) => h.play() },
552 ],
553 },
554 {
555 id: "radiation",
556 chapter: "Sound",
557 title: "From the plate to the ear",
558 body: (c) => {
559 let tab = "";
560 if (c.plate && c.model) {
561 const gains = pressureGains(c.plate, c.model, c.params, 8);
562 const max = Math.max(...gains, 1e-30);
563 const rows = gains.map((g, m) => [String(m + 1), c.model!.freqs[m].toFixed(1), (g / max).toFixed(2), bar(g / max)]);
564 const L = listenerPosition(c.params);
565 tab = `Listener at ${c.params.listenerDist} m, ${c.params.listenerAngle}° from the normal, azimuth ${c.params.listenerAzimuth}° (${(L.x * 1e3).toFixed(0)}, ${(L.y * 1e3).toFixed(0)}, ${(L.z * 1e3).toFixed(0)} mm).` + table(["Mode", "f (Hz)", "Pressure per unit amplitude, relative", ""], rows);
566 }
567 return `
568<p>A vibrating surface pushes on the air, and the pressure it sends to a listener is, for a plate set in a large rigid wall, the Rayleigh integral</p>
569$$ p(t) = \\frac{\\rho_{\\mathrm{air}}}{2\\pi}\\sum_k \\frac{\\Delta A_k}{r_k}\\, \\ddot w_k\\!\\left(t - \\frac{r_k}{c}\\right), $$
570<p>a sum over the plate of the acceleration of each patch of area $\\Delta A_k$, delayed by its travel time at the speed of sound $c$ and weakened by its distance $r_k$. Each patch is a small source whose pressure is proportional to its acceleration, so a mode's contribution scales as $\\omega^2$ times its displacement, and the high modes radiate strongly for their size.</p>
571<p>For a mode in free decay the delay is a phase shift, and the sum over the plate collapses into one complex gain per mode,</p>
572$$ G_m = \\frac{\\rho_{\\mathrm{air}}}{2\\pi}\\sum_k \\frac{\\Delta A_k}{r_k}\\,\\phi_m(k)\\, e^{-s_m r_k/c}, $$
573<p>and the pressure is</p>
574$$ p(t) = \\sum_m \\mathrm{Re}\\left[G_m s_m^2 A_m e^{s_m t}\\right], $$
575<p>the same recurrence as before with different amplitudes. The gain contains the directivity of the mode: its positive and negative regions partly cancel, most completely on the axis in front of the plate for a mode with equal lobes, and less so from an angle, where the delays from the lobes differ. Moving the listener recomputes only the gains, which is why the sound follows a move at once. The same sum taken over every direction gives the power the mode radiates, and with it the radiation part of the mode's decay on the page <i>Decay</i>.</p>
576<p>This is the one place where the model steps outside the plate, and it is approximate: the real plate has no wall, so at low frequencies the sound from its back partly cancels that from its front, and the Rayleigh integral overstates the bass.</p>
577${tab ? live(tab) : need("Compute the modes to see the gains of the first modes at the listener.")}
578<p>Try it: on axis, the modes with equal lobes nearly vanish; at a grazing angle they return and the pattern changes.</p>`;
579 },
580 actions: [
581 { label: "Compute the modes", run: (h) => h.computeModes() },
582 { label: "Listener on axis (0°)", run: (h) => h.setParam("listenerAngle", 0) },
583 { label: "At 60°", run: (h) => h.setParam("listenerAngle", 60) },
584 { label: "Grazing (85°)", run: (h) => h.setParam("listenerAngle", 85) },
585 { label: "Default (30°)", run: (h) => h.setParam("listenerAngle", 30) },
586 { label: "Synthesize and play", run: async (h) => { await h.synthesize(); h.play(); } },
587 ],
588 },
589 {
590 id: "spectrogram",
591 chapter: "Sound",
592 title: "Reading the spectrogram",
593 body: (c) => {
594 const N = c.spectrogram.window, fs = c.params.fs;
595 return `
596<p>The spectrogram cuts the sound into short windows, takes the Fourier transform of each, and paints the magnitude in decibels against time and frequency. With a window of $N$ samples at the rate $f_s$, the frequency resolution is $\\Delta f = f_s/N$ and the time resolution is $N/f_s$; the two cannot both be fine. A short window shows the attack sharply and smears the partials into bands; a long window separates neighboring modes and blurs the attack. Two modes closer than $\\Delta f$ merge into one line that beats.</p>
597<p>Each partial appears as a horizontal line that fades. With a display range of 80 dB, a partial with $T_{60}$ = 2 s disappears after about 2.7 s, and the slope of the fade is its decay rate. The ticks at the left edge are the computed modal frequencies: the lines of the linear model's sound sit on them by construction, and the lines of the full model's sound sit on them for a light hit and drift for a hard one.</p>
598${live(`Window of ${N} samples at ${fs / 1000} kHz: $\\Delta f$ = ${(fs / N).toFixed(1)} Hz, $\\Delta t$ = ${((N / fs) * 1e3).toFixed(1)} ms. Frequency axis ${c.spectrogram.logFreq ? "logarithmic" : "linear"}.`)}`;
599 },
600 actions: [
601 { label: "Short window (512)", run: (h) => h.setSpectrogram({ window: 512 }) },
602 { label: "Long window (4096)", run: (h) => h.setSpectrogram({ window: 4096 }) },
603 { label: "Default (2048)", run: (h) => h.setSpectrogram({ window: 2048 }) },
604 { label: "Log frequency axis", run: (h) => h.setSpectrogram({ logFreq: true }) },
605 { label: "Linear frequency axis", run: (h) => h.setSpectrogram({ logFreq: false }) },
606 ],
607 },
609 // ----- Stands and cymbals -----
610 {
611 id: "stand",
612 chapter: "Stands and cymbals",
613 title: "The cymbal stand",
614 body: (c) => {
615 const pl = c.plate;
616 const sr = pl ? standRates(pl) : null;
617 let liveText = "";
618 if (pl && sr && pl.stand) {
619 const pad = pl.stand.pad;
620 const np = pl.stand.topPad ? 2 : 1;
621 const { mass, Ix, Iy } = massProperties(pl);
622 const Ktheta = (np * (pad.E / pad.thickness) * Math.PI * (pad.outerRadius ** 4 - pad.innerRadius ** 4)) / 4;
623 const I = Math.min(Ix, Iy);
624 const fRock = Math.sqrt(Ktheta / I) / (2 * Math.PI);
625 const R = Math.min(pl.Lx, pl.Ly) / 2;
626 const fUmbrella = (9.08 * bendingConstant(pl.mat, meanThickness(pl))) / (2 * Math.PI * R * R);
627 liveText = `${np} pad${np > 1 ? "s" : ""} of ${mm(pad.thickness, 0)} felt at $E_p$ = ${sig(pad.E / 1e6)} MPa between ${mm(pad.innerRadius, 0)} and ${mm(pad.outerRadius, 0)}; mass $M$ = ${sig(mass * 1e3)} g.<br>$K$ = ${sig(sr.totalKw / 1e3)} kN/m, so $f_b$ = ${sr.bounceHz.toFixed(1)} Hz for a rigid cymbal; $K_\\theta$ = ${sig(Ktheta)} N m, $I$ = ${sig(I)} kg m², so $f_r$ = ${fRock.toFixed(1)} Hz. Static compression of the bottom pad under the weight alone: ${mm(mass * GRAVITY / (sr.totalKw / np), 3)}.<br>First umbrella mode of a uniform free disc of this radius and mean thickness: ${fUmbrella.toFixed(1)} Hz.`;
628 if (c.model) {
629 const kinds = modeKinds(pl, c.model, 8);
630 const sigma = modalRates(c.model);
631 const rows = kinds.map((k, m) => [String(m + 1), c.model!.freqs[m].toFixed(1), k.label, k.share.toFixed(2), (LN1000 / sigma[m]).toFixed(2)]);
632 liveText += table(["Mode", "f (Hz)", "Kind", "Rigid share", "T60 (s)"], rows);
633 } else liveText += "<br>Compute the modes to find the rock and the bounce among them.";
634 }
635 return `
636<p>A cymbal rests on a felt pad around the rod of its stand, usually with a second pad above it under a wing nut. Felt is a soft spring: a pad of thickness $t$ and Young's modulus $E_p$ has a stiffness per unit area of $E_p/t$ in compression, and an annulus of felt between the radii $r_1$ and $r_2$ resists a vertical motion of the cymbal with the stiffness</p>
637$$ K = n_p\\,\\frac{E_p}{t}\\,\\pi\\left(r_2^2 - r_1^2\\right), $$
638<p>with $n_p$ the number of pads. The cymbal as a whole then bounces on the felt at $f_b = \\frac{1}{2\\pi}\\sqrt{K/M}$, and it rocks, tilting about a diameter, against the torque of the annulus, whose stiffness is its second moment $K_\\theta = n_p (E_p/t)\\,\\pi(r_2^4 - r_1^4)/4$, at $f_r = \\frac{1}{2\\pi}\\sqrt{K_\\theta/I}$ with $I$ the moment of inertia of the cymbal about a diameter. These are modes that a free plate does not have, below every plate mode: the resonances of the stand, a few hertz for the rock and tens of hertz for the bounce. The felt is lossy, and its loss factor $\\eta$ adds to each mode a decay rate proportional to the fraction of the mode's energy stored in the felt, $\\sigma = \\eta\\,\\phi^T K_s\\phi/(2\\omega)$; the rock and the bounce die within a fraction of a second, while the plate modes, which hardly move the center, are barely affected.</p>
639<p>In the linear model the pads are linear springs. The full model lets them do what real felt does, push only while compressed, so that a hard hit can lift the cymbal off the bottom pad. The rod and its sleeve are not modeled; the shear of the felt is what holds the cymbal sideways. The stand is a setting of the setup rather than of the design: the Stand parameters beside the script put any plate on it or take it off, and set the felt's radii, thickness, modulus and loss factor, the preload of the wing nut and the position of the rod.</p>
640${liveText ? live(liveText) : need("Turn the stand on (the Stand parameters, or the button below) to see it.")}
641<p>The rock and the bounce are found among the computed modes by their rigid share, the fraction of a mode's kinetic energy that is a rigid translation or a rigid tilt of the cymbal: $(\\sum_k m_k\\phi_k)^2/M$ for the translation, and the same with the tilt. The rock comes out near $f_r$: at a few hertz it lies far below the first plate mode, and the cymbal tilts nearly as a whole. The bounce comes out well below $f_b$, and the reason is instructive. The formula treats the cymbal as rigid, but a thin plate is not: the pads push on its center, and, loosely speaking, the compliance of the plate around the center adds in series with that of the felt, so the center yields and the rim follows late. The bounce mode is a rigid translation blended with the umbrella-shaped plate mode, one nodal circle and no nodal diameter, whose antinode is at the center where the pads act; and the higher plate modes that move the center take a share of the felt's damping, which shortens their decay. A thicker plate is stiffer at its center and its bounce comes closer to $f_b$: compare the 20 inch ride with the flat disc.</p>`;
642 },
643 actions: [
644 { label: "The flat bronze disc on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("flat-disc"); } },
645 { label: "The 20 inch ride on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("ride-20"); } },
646 { label: "Take it off the stand", run: (h) => h.setParam("standOn", false) },
647 { label: "Compute the modes", run: (h) => h.computeModes() },
648 { label: "Show the rock", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("rock"); if (m >= 0) h.showMode(m); } } },
649 { label: "Show the bounce", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("bounce"); if (m >= 0) h.showMode(m); } } },
650 { label: "Show the first plate mode", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("plate"); if (m >= 0) h.showMode(m); } } },
651 { label: "Synthesize and play", run: async (h) => { await h.synthesize(); h.play(); } },
652 ],
653 },
654 {
655 id: "taper",
656 chapter: "Stands and cymbals",
657 title: "Thick at the bell, thin at the edge",
658 body: (c) => {
659 const pl = c.plate;
660 let liveText = "";
661 if (pl) {
662 const hr = thicknessRange(pl);
663 const R = Math.min(pl.Lx, pl.Ly) / 2;
664 const f = (h: number) => (5.25 * bendingConstant(pl.mat, h)) / (2 * Math.PI * R * R);
665 liveText = `Thickness from ${mm(hr.min)} to ${mm(hr.max)}; radius ${mm(R, 0)}. First mode of a uniform free disc of this radius: ${f(hr.min).toFixed(1)} Hz at the thin value, ${f(hr.max).toFixed(1)} Hz at the thick one.`;
666 if (c.model) {
667 const labels = modeLabels(pl, c.model, 8);
668 const m = labels.indexOf("plate");
669 if (m >= 0) liveText += `<br>The first plate mode computed: mode ${m + 1} at ${c.model.freqs[m].toFixed(1)} Hz${pl.stand ? " (after the stand's own modes)" : ""}.`;
670 } else liveText += "<br>Compute the modes to compare.";
671 }
672 return `
673<p>A cymbal is lathed to a taper, and the model takes the thickness as a field $h(x,y)$: the mass per area $\\rho h$ and the bending stiffness $D \\propto h^3$ vary from node to node, and the discretized energy uses the local values. Where the plate is thin, bending waves are slow and short ($\\kappa \\propto h$), so a mode that reaches the edge has its wavelength compressed there and its frequency lowered; each frequency lies between those of uniform plates of the thinnest and the thickest value, weighted toward the thickness where the mode has most of its curvature. The taper also sorts the partials in space: the thin rim, with its short waves, carries the high ones, and the thick center the low ones.</p>
674<p>For the first elastic mode of a free disc, which has two nodal diameters, the scaling law of the earlier page reads $f = 5.25\\,\\kappa/(2\\pi R^2)$, with the number from Leissa's tables for $\\nu$ near 0.33. A flat 16 inch disc 1 mm thick has this mode near 20 Hz, far below what one hears as the pitch of a cymbal; the sound is carried by the hundreds of modes above it, and the bow and the bell of a real cymbal raise the lowest ones (next page).</p>
675${liveText ? live(liveText) : need("Load the 16 inch crash to see its taper.")}
676<p>Try it: color the view by thickness to see the taper, and step through the first plate modes to see how their curvature sits on the thin rim.</p>`;
677 },
678 actions: [
679 { label: "Load the 16 inch crash, on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("crash-16"); } },
680 { label: "Color by thickness", run: (h) => h.setViewOptions({ color: "thickness" }) },
681 { label: "Color by height", run: (h) => h.setViewOptions({ color: "height" }) },
682 { label: "Compute the modes", run: (h) => h.computeModes() },
683 { label: "Show the first plate mode", run: (h) => { const c = h.ctx(); if (c.plate && c.model) { const l = modeLabels(c.plate, c.model, 8); const m = l.indexOf("plate"); if (m >= 0) h.showMode(m); } } },
684 { label: "Synthesize and play", run: async (h) => { await h.synthesize(); h.play(); } },
685 ],
686 },
687 {
688 id: "shell",
689 chapter: "Stands and cymbals",
690 title: "Curvature: a plate becomes a shell",
691 body: (c) => {
692 const flat = c.memo.flat12, bowed = c.memo.bowed12;
693 let liveText = "";
694 if (c.plate) {
695 const ps = profileStats(c.plate);
696 liveText = ps.peakToPeak > 0 ? `This design's profile: ${mm(ps.peakToPeak, 1)} peak to peak, largest slope ${ps.maxSlope.toFixed(3)}; ${c.sys ? `${c.sys.n} unknowns, ${c.sys.fields} per node.` : ""}` : "This design is flat: one unknown per node, bending only.";
697 }
698 if (flat && bowed) liveText += compareTable(flat, bowed, 8);
699 return `
700<p>When the rest shape $w_0(x,y)$ is curved, bending the plate also stretches it. To first order the membrane strains become</p>
701$$ \\varepsilon_{xx} = u_x + w_{0,x}\\, w_x, \\qquad \\varepsilon_{yy} = v_y + w_{0,y}\\, w_y, $$
702$$ \\gamma_{xy} = u_y + v_x + w_{0,x}\\, w_y + w_{0,y}\\, w_x, $$
703<p>where $u$ and $v$ are the in-plane displacements. A transverse motion $w$ over a slope $w_{0,x}$ changes the length of the surface, and the plate must either stretch, which costs the large membrane stiffness $Eh/(1-\\nu^2)$, or move within its plane to relieve the stretch. The linear model therefore has three unknowns per node instead of one, and the stiffness matrix couples them; the eigenproblem is three times larger with a band three times wider, and the modes take longer to compute. The modes that bend across the curvature must stretch and are raised in frequency, while those that bend along a straight direction of the surface are nearly unchanged. This is the stiffness of a shell: a sheet of paper curled into an arch holds a weight that the flat sheet cannot. The treatment is that of a shallow shell, valid while the slopes are small compared with one, and the interface reports the largest slope of a design.</p>
704<p>Try it at 12 mm spacing, where the flat and the bowed rectangle each take a few seconds: the bow of 3 mm across the width raises the low modes that bend across the width, and leaves the ones that bend along the length alone. Show a few of the bowed plate's modes and compare their shapes with the flat plate's.</p>
705${liveText ? live(liveText) : need("Load a design.")}`;
706 },
707 actions: [
708 { label: "Flat rectangle at 12 mm", run: (h) => remember(h, "flat12", "Flat", () => h.loadPreset("flat-rectangle"), 0.012) },
709 { label: "Bowed rectangle at 12 mm", run: (h) => remember(h, "bowed12", "Bowed", () => h.loadPreset("bowed-rectangle"), 0.012) },
710 { label: "Show mode 1", run: (h) => h.showMode(0) },
711 { label: "Mode 2", run: (h) => h.showMode(1) },
712 { label: "Mode 3", run: (h) => h.showMode(2) },
713 { label: "Back to 8 mm", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("flat-rectangle"); } },
714 ],
715 },
717 // ----- Large amplitude -----
718 {
719 id: "nonlinear",
720 chapter: "Large amplitude",
721 title: "Large amplitude: the full model",
722 body: (c) => {
723 const pl = c.plate;
724 let liveText = "";
725 if (pl) {
726 const wmax = maxAngularFrequency(pl.grid, pl.mat, thicknessRange(pl).max);
727 const dtMax = (CFL * 2) / wmax;
728 const sps = Math.ceil(1 / (c.params.fs * dtMax));
729 const dt = 1 / (c.params.fs * sps);
730 liveText = `In-plane wave speed $c_p$ = ${sig(inPlaneSpeed(pl.mat))} m/s; $\\omega_{\\max}$ = ${sig(wmax)} rad/s on this grid, so $\\Delta t$ = ${(dt * 1e6).toFixed(2)} µs, ${sps} steps per audio sample, ${sig(c.params.fs * sps / 1e6)} million steps per second of sound.`;
731 liveText += c.gpu ? "<br>WebGPU is available." : "<br>WebGPU is not available in this browser, so the full model cannot run here; the text still applies.";
732 if (c.full) liveText += `<br>Full model run: ${c.full.t.toFixed(2)} s simulated${c.full.done ? "" : " so far"}, peak force ${c.full.peakForce.toFixed(0)} N, largest displacement ${mm(c.full.wmax)} (${(c.full.wmax / meanThickness(pl)).toFixed(1)} thicknesses).`;
733 if (c.lastContact) liveText += `<br>Linear model, same design: peak force ${c.lastContact.peakForce.toFixed(0)} N.`;
734 }
735 return `
736<p>The linear model assumes that the displacement is small compared with the thickness. A hard hit moves a cymbal by several millimeters, and then the terms dropped from the strains matter:</p>
737$$ \\varepsilon_{xx} = u_x + w_{0,x}\\, w_x + \\tfrac12 w_x^2, $$
738<p>and likewise for the other two (the strains of von Karman). A flat plate deflected by $w$ is longer than it was, by a fraction of order $w_x^2$, and the resulting membrane tension pulls it back in addition to the bending: the plate stiffens with amplitude, its frequencies rise, and as the motion decays they fall back, a downward pitch glide. The tension produced by one mode acts on all the others, so the modes exchange energy: it flows from the low modes struck by the stick toward higher ones, and the tone of the first instants turns into the wash of a crash. Neither effect exists in a linear system, where every mode keeps its frequency and its energy for good.</p>
739<p>The full model keeps these terms and integrates the equations of motion in time on the GPU with the symplectic Euler scheme, $v^{n+1} = v^n + \\Delta t\\, a(x^n)$ and $x^{n+1} = x^n + \\Delta t\\, v^{n+1}$, which does not drift in energy over long runs. An explicit scheme is stable only when the time step is shorter than $2/\\omega_{\\max}$, with $\\omega_{\\max}$ the highest frequency of the grid. The fastest waves are the in-plane ones, with the speed $c_p = \\sqrt{E/\\rho(1-\\nu^2)}$ of about 4 km/s in metal, which cross a cell in a couple of microseconds; the step must be shorter than that, and several steps make one audio sample.</p>
740${liveText ? live(liveText) : need("Load a design.")}
741<p>Try it: a hard hit on the flat disc at 12 mm spacing for 1 s of sound, which takes some tens of seconds, then the same hit in the linear model. Compare the two spectrograms: the full model's lines bend downward as the sound decays and the space between them fills in; the linear model's stay put. Then a light hit, on which the two agree.</p>`;
742 },
743 actions: [
744 { label: "Set up: flat disc on the stand, 12 mm, 1 s, 10 m/s", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("duration", 1); h.setParam("strikeVelocity", 10); h.setParam("standOn", true); await h.loadPreset("flat-disc"); h.setRunMode("full"); } },
745 { label: "Run the full model", needsGpu: true, run: async (h) => { h.setRunMode("full"); await h.runFull(); } },
746 { label: "Same hit, linear model", run: async (h) => { h.setRunMode("linear"); await h.synthesize(); } },
747 { label: "Light hit: 0.2 m/s", run: (h) => h.setParam("strikeVelocity", 0.2) },
748 { label: "Play", run: (h) => h.play() },
749 ],
750 },
752 // ----- Closing -----
753 {
754 id: "limits",
755 chapter: "Closing",
756 title: "What the model leaves out",
757 body: () => `
758<p>Every page above named an approximation, and it is worth collecting them. The plate is thin and its rotations moderate, which holds for a cymbal moved by millimeters. A curved rest shape is treated as a shallow shell, valid for a gentle bow but strained by the deep bell of a real crash; the interface reports the largest slope. The grid places the low modes within a percent or two and flattens the high partials by the amounts on <i>The grid</i>, and its staircase edge splits the mode pairs of a disc that the hammering of a real cymbal splits anyway. The decay times are guesses that sound plausible, not measurements, and the full model matches them only through a two-parameter fit. The stand is a linear felt with a loss factor, without the rod, the sleeve or friction, and gravity enters only as the static preload. The listener hears a plate in a wall, which overstates the bass of a plate in free air, and air loading of the plate itself is neglected.</p>
759<p>What remains is a model in which every number can be traced to a formula on these pages, and in which the two ways of computing the sound, the modes of the linearized plate and the time stepping of the nonlinear one, share one discretization and agree where they should. The document THEORY.md in the repository gives the complete equations, the discretization, and the checks that were made, and README.md describes the script and the interface.</p>
760<p>Some experiments to try from here: an ellipse, whose degenerate mode pairs split; a thunder sheet, whose modes are dense and low; a ride with a loose wing nut (a small preload in the Stand parameters) hit hard in the full model, which lifts off its pad; a design of your own, written in the script, with a thickness or a profile the presets do not have.</p>`,
761 actions: [
762 { label: "Steel ellipse, free", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", false); await h.loadPreset("ellipse"); } },
763 { label: "Thunder sheet, free", run: async (h) => { h.setParam("spacing", 0.012); h.setParam("standOn", false); await h.loadPreset("thunder-sheet"); } },
764 { label: "20 inch ride on the stand", run: async (h) => { h.setParam("spacing", 0.008); h.setParam("standOn", true); await h.loadPreset("ride-20"); } },
765 ],
766 },
767];
769export function stepIndex(id: string): number {
770 return STEPS.findIndex((s) => s.id === id);
773/** Chapters in order with their steps' indices. */
774export function chapters(): { title: string; steps: number[] }[] {
775 const out: { title: string; steps: number[] }[] = [];
776 STEPS.forEach((s, i) => {
777 const last = out[out.length - 1];
778 if (last && last.title === s.chapter) last.steps.push(i);
779 else out.push({ title: s.chapter, steps: [i] });
780 });
781 return out;
784/** The empty context, for rendering a step before any design has compiled. */
785export function emptyContext(params: SimParams): TutorialContext {
786 return { plate: null, design: null, params, spacing: params.spacing, sys: null, modes: null, model: null, lastContact: null, recording: null, full: null, gpu: false, runMode: "linear", spectrogram: { window: 2048, logFreq: false }, memo: {} };
↑↓ move↵openescclose