/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Beam app (step 1): beam with bending, clamped/pinned/cantilever ends, local or Kirchhoff stretching; sped-up note with spectrogram. Note player, mute button, formats and canvas sizing in src/common/
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 8cbfe83766f2 parent 8874f6d Browse files
18 changed files+1030−64
README.mdmodified+3−2View file
@@ -4,7 +4,8 @@ Interactive apps for "Listening for chaos": struck dynamical systems heard as so
44
55 - `src/double-pendulum/`: double pendulum ([README](src/double-pendulum/README.md)), served at `/double-pendulum/`.
66 - `src/fput-chain/`: FPUT-beta chain ([README](src/fput-chain/README.md)), served at `/fput-chain/`.
7-- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, λ vs E scatter, strip charts, whole-note spectrogram, Lyapunov exponent by the two-trajectory method with a per-app `Shadow`; Poincare section orbits, their worker, the precomputed atlas and the section panel, with a per-app `Section` and panel configuration) and `style.css`, the shared styles. Each app's `main.ts` imports `../common/style.css` and then its own `style.css`, so app rules come after the shared ones in dev and in the build.
7+- `src/beam/`: beam with bending stiffness ([README](src/beam/README.md)), served at `/beam/`.
8+- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, note player, mute button, number formats, canvas sizing, λ vs E scatter, strip charts, whole-note spectrogram, Lyapunov exponent by the two-trajectory method with a per-app `Shadow`; Poincare section orbits, their worker, the precomputed atlas and the section panel, with a per-app `Section` and panel configuration) and `style.css`, the shared styles. Each app's `main.ts` imports `../common/style.css` and then its own `style.css`, so app rules come after the shared ones in dev and in the build.
89 - `src/index.html`: landing page linking the apps.
910
1011 Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
@@ -16,4 +17,4 @@ npm install
1617 npm run dev
1718 ```
1819
19-The dev server serves every app (http://localhost:5173/double-pendulum/, http://localhost:5173/fput-chain/); edits anywhere under `src/` reload the open pages.
20+The dev server serves every app (http://localhost:5173/double-pendulum/, http://localhost:5173/fput-chain/, http://localhost:5173/beam/); edits anywhere under `src/` reload the open pages.
src/beam/README.mdadded+23−0View file
@@ -0,0 +1,23 @@
1+# beam
2+
3+Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram).
4+
5+Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, displacements scaled to fit (scale given), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, a, τ, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
6+
7+"Sped up · listen" (as in fput-chain): each Strike renders the same strike in a worker (`render-worker.ts`), sped up so the lowest mode sounds at the Pitch (5 to 1000 Hz, default 100), and plays it; the page starts muted. A damped note is the whole decay to -60 dB. A red warning shows when the top mode is above half the sample rate (it would alias). Signals (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 2), or the sum of accelerations (the net force of the supports). Replay, Save WAV (named e.g. beam-clamped-local-N8-mass2-2.51J-v2-100Hz.wav), spectrogram over the whole note with dashed lines at the mode pitches. The status line gives the render time.
8+
9+Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam/ (part of the chaos-apps site).
10+
11+## Model
12+
13+- SI units. Defaults m = 1 kg, k = 0, κ = 20 N/m, k3 = 4000 N/m^3, a = 0.5 m (drawing only), τ = 540 s, local stretching, clamped ends.
14+- Masses on rails: only sideways motion u_j, so every axial force is taken by the rails and tension and stretching are defined at a free end too.
15+- Potential: springs between neighbours (and to each support), k d^2 / 2 with d = u_j - u_{j-1}; bending κ c^2 / 2 at each mass with both neighbours, c = u_{j-1} - 2 u_j + u_{j+1} (supports at u = 0). Pinned support: no extra term (ghost u_{-2} = -u_0 gives c = 0 there). Clamped: a joint at the support with ghost u_{-2} = u_0, c = 2 u_0, energy 2 κ u_0^2. Free end: no spring or joint beyond the last mass.
16+- Stretching: "local", k3 d^4 / 4 per spring (FPUT); or "Kirchhoff", k3 S^2 / (4 nS) with S the sum of d^2 over the nS springs, which acts as one tension k3 S / nS shared by all springs. They agree when all springs are stretched alike.
17+- Normal modes of the linear beam from the stiffness matrix (assembled from the force routine) by Jacobi; mode energies as in fput-chain. Checked: pinned frequencies equal the closed form omega_n^2 = (4 k s^2 + 16 κ s^4) / m, s = sin(pi n / (2 (N + 1))), to 1e-14; N = 32 ratios clamped 2.75, 5.35, 8.78 (beam theory 2.76, 5.40, 8.93), cantilever 6.26, 17.48, 34.09 (6.27, 17.55, 34.39). At N = 8: clamped 1, 2.59, 4.68, 6.97, top 13.2; pinned 1, 3.88, 8.29, top 32.2; cantilever 1, 6.13, 16.4, top 75.
18+- Nonlinear scale: the energy of mode 1 alone at the amplitude where its stretching energy equals its linear energy (N = 8, local: clamped 176 mJ, pinned 8.7 mJ, cantilever 1.1 mJ).
19+- Damping: drag proportional to mass, every mode's amplitude decays as exp(-t / τ); applied exactly in half steps around each Verlet step, dissipated energy in W.
20+- Integrator: velocity Verlet, step 0.03 / omega_eff, omega_eff^2 = (Kmax + 12 k3 d^2) / m, Kmax the top eigenvalue of the stiffness matrix and d = (4 E / k3)^(1/4) (Kirchhoff: the k3 term over sqrt(nS)). Relative energy error <= 8e-5 over 200 mode-1 periods, all ends and both forms, 10 mJ to 100 J.
21+- First look (offline, N = 8, hammer on mass 2, no damping, two-trajectory Lyapunov exponent times the mode-1 period T1): local stretching is chaotic from ~1 J (clamped 0.57 at 1 J, 3.3 at 10 J; pinned 1.8 at 1 J; cantilever 1.2 at 0.1 J). Kirchhoff is much less chaotic at the same energy (clamped 0.04 at 1 J, 0.24 at 10 J; pinned ~0 up to 1 J, 0.09 at 10 J).
22+
23+Code: `beam.ts` (physics, modes, strikes), `main.ts` (page), `signals.ts`, `render-worker.ts`; shared: `../common/sound.ts`, `../common/spectrogram.ts`, `../common/wav.ts`, `../common/mute.ts`, `../common/format.ts`, `../common/canvas.ts`, `../common/help.ts`.
src/beam/beam.tsadded+297−0View file
@@ -0,0 +1,297 @@
1+// A beam as N equal masses on vertical rails, spaced along a line between two
2+// supports (or one, for a cantilever). The masses move sideways only, by
3+// u[0..N-1] (m); the rails take every axial force, so tension and stretching
4+// make sense at a free end too. SI units. Three kinds of terms:
5+// - tension: springs between neighbours with tension f(d) = k d for a
6+// sideways offset d = u[j] - u[j - 1] (a stretched string; k = T / a);
7+// - bending: kappa c^2 / 2 at each joint, with c = u[j - 1] - 2 u[j] + u[j + 1]
8+// the second difference (kappa = EI / a^3);
9+// - stretching, the nonlinearity, in one of two forms: "local", k3 d^4 / 4
10+// per spring (as in the FPUT chain), or "kirchhoff", k3 S^2 / (4 nS) with
11+// S the sum of d^2 over the nS springs: one tension shared by all springs,
12+// set by the total stretch. The two agree when every spring is stretched
13+// alike, so switching between them changes how the modes are coupled, not
14+// how strong the nonlinearity is.
15+// Ends: "pinned" (both ends: u = 0, no moment), "clamped" (both: u = 0, zero
16+// slope) or "cantilever" (clamped at the left, free at the right). The
17+// supports are nodes -1 and N at u = 0. A pinned support has no joint term
18+// (its second difference, with the mirror ghost u[-2] = -u[0], is 0); a
19+// clamped one has a joint with ghost u[-2] = u[0], so c = 2 u[0]. A free end
20+// has no spring or joint beyond the last mass.
21+// Optional damping: linear drag on each mass proportional to its mass,
22+// force -gamma m v = -gamma p, so every linear mode's amplitude decays as
23+// exp(-t / tau) with tau = 2 / gamma.
24+// State: displacements u, momenta p (kg m/s), and W, the energy (J)
25+// dissipated so far (so E + W is conserved).
26+
27+export type Ends = "clamped" | "pinned" | "cantilever";
28+export type Stretch = "local" | "kirchhoff";
29+
30+export interface Params {
31+ N: number; // number of moving masses
32+ m: number; // kg, each mass
33+ k: number; // N/m, tension (may be 0 with kappa > 0)
34+ kappa: number; // N/m, bending (may be 0 with k > 0)
35+ k3: number; // N/m^3, stretching
36+ stretch: Stretch;
37+ ends: Ends;
38+ a: number; // m, rest spacing (for drawing only; it does not enter the equations)
39+ tau: number; // s, amplitude decay time from drag (all modes); 0 = no damping
40+}
41+
42+// Drag rate gamma (1/s).
43+export const dragRate = (P: Params) => (P.tau > 0 ? 2 / P.tau : 0);
44+
45+const freeRight = (P: Params) => P.ends === "cantilever";
46+// Springs j = 0..nS-1 join nodes j - 1 and j.
47+export const springCount = (P: Params) => (freeRight(P) ? P.N : P.N + 1);
48+// Joints at masses 0..nJ-1 (each needs both neighbours).
49+const jointCount = (P: Params) => (freeRight(P) ? P.N - 1 : P.N);
50+
51+export interface State {
52+ u: Float64Array;
53+ p: Float64Array;
54+ W: number;
55+}
56+
57+export function newState(N: number): State {
58+ return { u: new Float64Array(N), p: new Float64Array(N), W: 0 };
59+}
60+
61+export const copyState = (s: State): State => ({ u: Float64Array.from(s.u), p: Float64Array.from(s.p), W: s.W });
62+
63+// Displacement of node i: a mass, or a support (0).
64+const at = (u: Float64Array, i: number) => (i >= 0 && i < u.length ? u[i] : 0);
65+
66+// Sum of squared spring offsets (for the Kirchhoff form).
67+function stretchSum(P: Params, u: Float64Array): number {
68+ let S = 0;
69+ for (let j = 0, nS = springCount(P); j < nS; j++) S += (at(u, j) - at(u, j - 1)) ** 2;
70+ return S;
71+}
72+
73+// Force on each mass, written into F. With k3 = 0 this is linear, -K u.
74+export function forces(P: Params, u: Float64Array, F: Float64Array): void {
75+ const { N, k, kappa, k3 } = P;
76+ F.fill(0);
77+ const nS = springCount(P);
78+ // Kirchhoff: one extra tension coefficient for all springs.
79+ const kK = P.stretch === "kirchhoff" ? (k3 * stretchSum(P, u)) / nS : 0;
80+ for (let j = 0; j < nS; j++) {
81+ const d = at(u, j) - at(u, j - 1);
82+ const f = (P.stretch === "local" ? k + k3 * d * d : k + kK) * d; // spring j pulls its ends together
83+ if (j > 0) F[j - 1] += f;
84+ if (j < N) F[j] -= f;
85+ }
86+ if (kappa > 0) {
87+ for (let i = 0, nJ = jointCount(P); i < nJ; i++) {
88+ const c = kappa * (at(u, i - 1) - 2 * u[i] + at(u, i + 1));
89+ if (i > 0) F[i - 1] -= c;
90+ F[i] += 2 * c;
91+ if (i + 1 < N) F[i + 1] -= c;
92+ }
93+ // Clamped supports: c = 2 u at the end mass, energy 2 kappa u^2.
94+ if (P.ends !== "pinned") F[0] -= 4 * kappa * u[0];
95+ if (P.ends === "clamped") F[N - 1] -= 4 * kappa * u[N - 1];
96+ }
97+}
98+
99+export function kinetic(P: Params, s: State): number {
100+ let T = 0;
101+ for (const p of s.p) T += p * p;
102+ return T / (2 * P.m);
103+}
104+
105+export function potential(P: Params, s: State): number {
106+ const { N, k, kappa, k3 } = P;
107+ const u = s.u;
108+ const nS = springCount(P);
109+ let V = 0, S = 0;
110+ for (let j = 0; j < nS; j++) {
111+ const d2 = (at(u, j) - at(u, j - 1)) ** 2;
112+ V += (k * d2) / 2 + (P.stretch === "local" ? (k3 * d2 * d2) / 4 : 0);
113+ S += d2;
114+ }
115+ if (P.stretch === "kirchhoff") V += (k3 * S * S) / (4 * nS);
116+ for (let i = 0, nJ = jointCount(P); i < nJ; i++) V += (kappa * (at(u, i - 1) - 2 * u[i] + at(u, i + 1)) ** 2) / 2;
117+ if (P.ends !== "pinned") V += 2 * kappa * u[0] ** 2;
118+ if (P.ends === "clamped") V += 2 * kappa * u[N - 1] ** 2;
119+ return V;
120+}
121+
122+export function energy(P: Params, s: State): number {
123+ return kinetic(P, s) + potential(P, s);
124+}
125+
126+// Velocity Verlet (leapfrog), order 2, symplectic, as in the FPUT chain;
127+// drag applied exactly in two half steps around it (Strang splitting),
128+// p -> p exp(-gamma h / 2), with the kinetic energy removed added to W.
129+export class Verlet {
130+ private P: Params;
131+ private F: Float64Array;
132+ constructor(P: Params, s: State) {
133+ this.P = P;
134+ this.F = new Float64Array(P.N);
135+ forces(P, s.u, this.F);
136+ }
137+ // Force on each mass at the current displacements.
138+ get force(): Float64Array {
139+ return this.F;
140+ }
141+ step(s: State, h: number): void {
142+ const { u, p } = s;
143+ const F = this.F;
144+ const { N, m } = this.P;
145+ const gamma = dragRate(this.P);
146+ if (gamma > 0) this.drag(s, Math.exp(-0.5 * gamma * h));
147+ for (let j = 0; j < N; j++) p[j] += 0.5 * h * F[j];
148+ for (let j = 0; j < N; j++) u[j] += (h * p[j]) / m;
149+ forces(this.P, u, F);
150+ for (let j = 0; j < N; j++) p[j] += 0.5 * h * F[j];
151+ if (gamma > 0) this.drag(s, Math.exp(-0.5 * gamma * h));
152+ }
153+ private drag(s: State, f: number): void {
154+ let p2 = 0;
155+ for (let j = 0; j < s.p.length; j++) {
156+ p2 += s.p[j] * s.p[j];
157+ s.p[j] *= f;
158+ }
159+ s.W += ((1 - f * f) * p2) / (2 * this.P.m);
160+ }
161+}
162+
163+// Eigenvalues and eigenvectors of a symmetric matrix (cyclic Jacobi). A is
164+// overwritten; returns the eigenvalues and V with eigenvector i in column i.
165+function jacobi(A: Float64Array[]): { values: Float64Array; V: Float64Array[] } {
166+ const n = A.length;
167+ const V = Array.from({ length: n }, (_, i) => {
168+ const row = new Float64Array(n);
169+ row[i] = 1;
170+ return row;
171+ });
172+ for (let sweep = 0; sweep < 100; sweep++) {
173+ let off = 0, diag = 0;
174+ for (let i = 0; i < n; i++) {
175+ diag += A[i][i] ** 2;
176+ for (let j = i + 1; j < n; j++) off += A[i][j] ** 2;
177+ }
178+ if (off <= 1e-30 * diag) break;
179+ for (let p = 0; p < n; p++) {
180+ for (let q = p + 1; q < n; q++) {
181+ if (A[p][q] === 0) continue;
182+ const theta = (A[q][q] - A[p][p]) / (2 * A[p][q]);
183+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
184+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
185+ for (let r = 0; r < n; r++) {
186+ const arp = A[r][p], arq = A[r][q];
187+ A[r][p] = c * arp - s * arq;
188+ A[r][q] = s * arp + c * arq;
189+ }
190+ for (let r = 0; r < n; r++) {
191+ const apr = A[p][r], aqr = A[q][r];
192+ A[p][r] = c * apr - s * aqr;
193+ A[q][r] = s * apr + c * aqr;
194+ }
195+ for (let r = 0; r < n; r++) {
196+ const vrp = V[r][p], vrq = V[r][q];
197+ V[r][p] = c * vrp - s * vrq;
198+ V[r][q] = s * vrp + c * vrq;
199+ }
200+ }
201+ }
202+ }
203+ return { values: Float64Array.from({ length: n }, (_, i) => A[i][i]), V };
204+}
205+
206+// Normal modes of the linear beam (k3 = 0), mode n = 1..N stored at index
207+// n - 1 in order of frequency, from the stiffness matrix K (assembled from
208+// forces(), so it matches the dynamics exactly). Shapes S[n - 1] have unit
209+// norm and start upward at the left; mode amplitudes are Q = S u, P = S p.
210+export class Modes {
211+ readonly N: number;
212+ readonly m: number;
213+ readonly omega: Float64Array; // rad/s
214+ readonly S: Float64Array[]; // S[n - 1][j]
215+ readonly Kmax: number; // largest eigenvalue of K (N/m)
216+ constructor(P: Params) {
217+ const { N, m } = P;
218+ this.N = N;
219+ this.m = m;
220+ const lin = { ...P, k3: 0 };
221+ const K: Float64Array[] = [];
222+ const e = new Float64Array(N), F = new Float64Array(N);
223+ for (let i = 0; i < N; i++) {
224+ e.fill(0);
225+ e[i] = 1;
226+ forces(lin, e, F);
227+ K.push(F.map((f) => -f)); // column i of K (K is symmetric)
228+ }
229+ const { values, V } = jacobi(K);
230+ const order = Array.from(values.keys()).sort((x, y) => values[x] - values[y]);
231+ this.omega = Float64Array.from(order, (i) => Math.sqrt(Math.max(0, values[i]) / m));
232+ this.Kmax = values[order[N - 1]];
233+ this.S = order.map((i) => {
234+ const row = Float64Array.from({ length: N }, (_, j) => V[j][i]);
235+ const first = row.find((x) => Math.abs(x) > 1e-9) ?? 1;
236+ if (first < 0) for (let j = 0; j < N; j++) row[j] = -row[j];
237+ return row;
238+ });
239+ }
240+ // Harmonic energy of each mode (J), P_n^2 / 2m + m omega_n^2 Q_n^2 / 2, into
241+ // out. Their sum is the energy without the stretching (quartic) part.
242+ energies(s: State, out: Float64Array): void {
243+ const m = this.m;
244+ for (let n = 0; n < this.N; n++) {
245+ const row = this.S[n];
246+ let Q = 0, Pn = 0;
247+ for (let j = 0; j < this.N; j++) {
248+ Q += row[j] * s.u[j];
249+ Pn += row[j] * s.p[j];
250+ }
251+ const w = this.omega[n];
252+ out[n] = (Pn * Pn) / (2 * m) + (m * w * w * Q * Q) / 2;
253+ }
254+ }
255+}
256+
257+// Energy scale of the nonlinearity: the energy of mode 1 alone at the
258+// amplitude where its stretching (quartic) energy equals its linear energy.
259+// Below it the beam rings as independent modes; above it the modes trade
260+// energy.
261+export function nonlinearScale(P: Params, modes: Modes): number {
262+ const s = newState(P.N);
263+ s.u.set(modes.S[0]); // unit amplitude
264+ const V2 = potential({ ...P, k3: 0 }, s);
265+ const V4 = potential(P, s) - V2;
266+ return V4 > 0 ? (V2 * V2) / V4 : Infinity; // A^2 = V2 / V4 makes them equal
267+}
268+
269+// Integrator step (s) for motion with energy E: 0.03 / omega, with omega from
270+// the stiffest the beam can be. Linear part: the top mode, Kmax. Stretching
271+// adds at most 12 k3 d^2 with d = (4 E / k3)^(1/4), the largest offset a
272+// spring holding all of E could reach (Kirchhoff spreads it over nS springs,
273+// sqrt(nS) less).
274+export function stepFor(P: Params, modes: Modes, E: number): number {
275+ const d2 = Math.sqrt((4 * Math.max(E, 0)) / P.k3);
276+ const nl = (12 * P.k3 * d2) / (P.stretch === "kirchhoff" ? Math.sqrt(springCount(P)) : 1);
277+ return 0.03 * Math.sqrt(P.m / (modes.Kmax + nl));
278+}
279+
280+// Strikes from rest. All the energy starts as kinetic energy, so the total is
281+// exactly E whatever k3 is.
282+
283+// Velocity in the shape of mode n (1..N).
284+export function strikeMode(P: Params, modes: Modes, n: number, E: number): State {
285+ const s = newState(P.N);
286+ const pn = Math.sqrt(2 * P.m * E); // mode momentum; the shape has unit norm
287+ const row = modes.S[n - 1];
288+ for (let j = 0; j < P.N; j++) s.p[j] = pn * row[j];
289+ return s;
290+}
291+
292+// Velocity on mass j (1..N) alone: a hammer blow to one mass.
293+export function strikeMass(P: Params, j: number, E: number): State {
294+ const s = newState(P.N);
295+ s.p[j - 1] = Math.sqrt(2 * P.m * E);
296+ return s;
297+}
src/beam/index.htmladded+114−0View file
@@ -0,0 +1,114 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Beam chaos</title>
7+ </head>
8+ <body>
9+ <main>
10+ <header><h1>Beam chaos</h1></header>
11+ <section class="card">
12+ <canvas id="beam"></canvas>
13+ <div id="controls">
14+ <div class="row">
15+ <button id="strike" class="primary">Strike</button>
16+ <select id="strike-kind">
17+ <option value="mode">mode</option>
18+ <option value="mass" selected>mass</option>
19+ </select>
20+ <input id="strike-index" type="number" min="1" value="2" step="1" />
21+ <button id="pause" disabled>Pause</button>
22+ <button type="button" class="help" data-help="beam" title="About the beam" aria-label="About the beam">?</button>
23+ </div>
24+ <div class="row">
25+ <span class="muted">Strike energy</span>
26+ <input id="energy" type="range" min="-5" max="2" step="0.01" value="0.4" />
27+ <span id="energy-value" class="num"></span>
28+ </div>
29+ <div class="row">
30+ <span class="muted">Masses</span>
31+ <input id="n" type="number" min="2" max="64" step="1" value="8" />
32+ <span class="muted" style="margin-left: 12px">Ends</span>
33+ <select id="ends">
34+ <option value="clamped" selected>clamped–clamped</option>
35+ <option value="pinned">pinned–pinned</option>
36+ <option value="cantilever">cantilever (clamped–free)</option>
37+ </select>
38+ <span class="muted" style="margin-left: 12px">Speed</span>
39+ <select id="speed">
40+ <option value="0.1">0.1×</option>
41+ <option value="0.25">0.25×</option>
42+ <option value="0.5">0.5×</option>
43+ <option value="1" selected>1× (real time)</option>
44+ <option value="2">2×</option>
45+ <option value="5">5×</option>
46+ <option value="10">10×</option>
47+ <option value="20">20×</option>
48+ </select>
49+ </div>
50+ </div>
51+ <details>
52+ <summary>Parameters <button type="button" class="help" data-help="params" title="About the parameters" aria-label="About the parameters">?</button></summary>
53+ <div id="param-form">
54+ <label>m <input id="p-m" type="number" step="any" /> <span class="unit">kg</span></label>
55+ <label>k <input id="p-k" type="number" step="any" /> <span class="unit">N/m (tension)</span></label>
56+ <label>κ <input id="p-kappa" type="number" step="any" /> <span class="unit">N/m (bending)</span></label>
57+ <label>k3 <input id="p-k3" type="number" step="any" /> <span class="unit">N/m³ (stretching)</span></label>
58+ <label>a <input id="p-a" type="number" step="any" /> <span class="unit">m (spacing)</span></label>
59+ <label>τ <input id="p-tau" type="number" step="any" /> <span class="unit">s (decay, 0 = off)</span></label>
60+ <label class="wide">stretching <select id="p-stretch">
61+ <option value="local">local (each spring)</option>
62+ <option value="kirchhoff">Kirchhoff (one shared tension)</option>
63+ </select></label>
64+ </div>
65+ <div id="param-actions" class="row">
66+ <button id="param-apply">Apply</button>
67+ <span id="param-status"></span>
68+ </div>
69+ <div id="params"></div>
70+ </details>
71+ </section>
72+ <details class="card panel" id="sound" open>
73+ <summary><span class="label">Sped up · listen</span> <span id="sound-clock" class="muted"></span> <button type="button" class="help" data-help="sound" title="About the sped-up run" aria-label="About the sped-up run">?</button></summary>
74+ <div class="row">
75+ <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
76+ <label><span class="label">Signal</span> <select id="signal"></select></label>
77+ <input id="sound-mass" type="number" min="1" step="1" value="2" aria-label="Mass" />
78+ <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
79+ <button id="replay" type="button" disabled title="Play the last strike again with the current settings">Replay</button>
80+ <button id="save-wav" type="button" disabled title="Save the last note as a WAV file">Save WAV</button>
81+ </div>
82+ <div class="row">
83+ <label class="grow"><span class="label">Pitch</span> <input id="pitch" type="range" min="0.699" max="3" step="0.001" value="2" aria-label="Pitch of the lowest mode" /></label>
84+ <span id="pitch-value" class="num"></span>
85+ </div>
86+ <div class="row"><span id="alias" class="muted" hidden></span></div>
87+ <div class="row"><span id="sound-status" class="muted"></span></div>
88+ <p id="sound-hint" class="muted">Strike to hear the whole decay.</p>
89+ <figure id="spec-fig"><div id="spectrogram"></div></figure>
90+ </details>
91+ </main>
92+ <dialog id="help">
93+ <div id="help-body"></div>
94+ <div id="help-actions"><button id="help-close" type="button">Close</button></div>
95+ </dialog>
96+
97+ <template id="help-beam">
98+ <h2>The beam</h2>
99+ <p>Equal masses on vertical rails, moving sideways only, shown in real time (displacements scaled to fit). Neighbours are joined by springs (tension k), and each mass resists bending (κ, on how far it sits off the line through its neighbours). The springs also stiffen as they stretch (k3): this is the nonlinearity. Strike gives one mass, or one normal mode, the energy on the slider.</p>
100+ <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), or a cantilever: clamped at the left and free at the right. Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. The space bar pauses.</p>
101+ </template>
102+ <template id="help-params">
103+ <h2>Parameters</h2>
104+ <p>Mass, tension k, bending κ (either may be 0, not both), stretching k3, the rest spacing a (drawing only), and the decay time τ of the amplitude (drag on the masses; 0 for none). Apply restarts the beam at rest.</p>
105+ <p>Stretching "local" stiffens each spring by its own stretch, as in the FPUT chain. "Kirchhoff" gives all springs one shared tension set by the total stretch, as for a beam whose ends are held apart. The two agree when every spring is stretched alike. The nonlinear scale below is the energy of mode 1 at which its stretching energy equals its linear energy.</p>
106+ </template>
107+ <template id="help-sound">
108+ <h2>Sped up · listen</h2>
109+ <p>A copy of the same strike, run fast enough to be heard: Pitch sets where the lowest normal mode lands, and the whole decay plays in a few seconds. While this panel is open each Strike plays a note; Replay plays the last one again with the current settings.</p>
110+ <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard. The spectrogram spans the whole note, with dashed lines at the normal-mode pitches; a warning shows when the top mode is above half the sample rate and would alias.</p>
111+ </template>
112+ <script type="module" src="./main.ts"></script>
113+ </body>
114+</html>
src/beam/main.tsadded+417−0View file
@@ -0,0 +1,417 @@
1+import "../common/style.css";
2+import "./style.css";
3+import {
4+ Modes, Verlet, copyState, energy, newState, nonlinearScale, stepFor, strikeMass, strikeMode,
5+ type Ends, type Params, type State, type Stretch,
6+} from "./beam";
7+import { fit } from "../common/canvas";
8+import { fmtDuration, fmtEnergy, fmtLength } from "../common/format";
9+import { setupHelp } from "../common/help";
10+import { setupMute } from "../common/mute";
11+import { Player } from "../common/sound";
12+import { NoteSpectrogram } from "../common/spectrogram";
13+import { saveFile, wavBlob } from "../common/wav";
14+import { END_RATIO, UNDAMPED_LENGTH, type RenderRequest, type RenderResult } from "./render-worker";
15+import { SIGNALS, signalByKey } from "./signals";
16+
17+setupHelp();
18+
19+const beamCanvas = document.getElementById("beam") as HTMLCanvasElement;
20+const energyInput = document.getElementById("energy") as HTMLInputElement;
21+const energyValue = document.getElementById("energy-value")!;
22+const nInput = document.getElementById("n") as HTMLInputElement;
23+const endsSelect = document.getElementById("ends") as HTMLSelectElement;
24+const kindSelect = document.getElementById("strike-kind") as HTMLSelectElement;
25+const indexInput = document.getElementById("strike-index") as HTMLInputElement;
26+const speedSelect = document.getElementById("speed") as HTMLSelectElement;
27+const pauseButton = document.getElementById("pause") as HTMLButtonElement;
28+
29+// Parameters. No tension by default: a pure beam (k = 0, bending kappa = 20),
30+// clamped at both ends, with the FPUT chain's stretching coefficient.
31+// tau: amplitude decay time from drag; 540 s makes the default strike ring
32+// down in about 8 s of sound at the default pitch.
33+let P: Params = { N: 8, m: 1, k: 0, kappa: 20, k3: 4000, stretch: "local", ends: "clamped", a: 0.5, tau: 540 };
34+
35+// The beam. It sits at rest until the first Strike; changing parameters puts
36+// it back at rest.
37+let modes: Modes;
38+let s: State;
39+let verlet: Verlet;
40+let H: number; // integrator step (s), set at each strike from its energy (see stepFor)
41+let E0 = 0; // energy of the last strike
42+let t = 0;
43+let running = false;
44+let ampl = 0; // recent largest |u|, for scaling the drawing
45+let lastStrike: State | null = null; // state right after the last strike (for the sound)
46+
47+// Colors by index k of n: a few fixed colors, a blue-to-red sweep for more.
48+const PALETTE = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
49+const indexColor = (k: number, n: number) =>
50+ n <= PALETTE.length ? PALETTE[k] : `hsl(${215 - (215 * k) / (n - 1)}, 55%, 45%)`;
51+
52+const strikeEnergy = () => 10 ** Number(energyInput.value);
53+const showEnergy = () => (energyValue.textContent = fmtEnergy(strikeEnergy()));
54+showEnergy();
55+energyInput.addEventListener("input", showEnergy);
56+
57+// Pause stops everything: the animation and the sound.
58+function setRunning(r: boolean) {
59+ running = r;
60+ pauseButton.textContent = r ? "Pause" : "Resume";
61+ player.setPaused(!r);
62+}
63+pauseButton.addEventListener("click", () => setRunning(!running));
64+document.addEventListener("keydown", (e) => {
65+ const el = e.target as HTMLElement;
66+ if (e.key !== " " || pauseButton.disabled || el.closest("input, select, button, summary")) return;
67+ e.preventDefault();
68+ setRunning(!running);
69+});
70+
71+const strikeIndex = () => Math.min(P.N, Math.max(1, Math.round(Number(indexInput.value) || 1)));
72+
73+// label names the strike in saved files.
74+let strikeLabel = "";
75+document.getElementById("strike")!.addEventListener("click", () => {
76+ const E = strikeEnergy();
77+ indexInput.value = String(strikeIndex());
78+ const mode = kindSelect.value === "mode";
79+ strikeLabel = `${mode ? "mode" : "mass"}${strikeIndex()}`;
80+ s = mode ? strikeMode(P, modes, strikeIndex(), E) : strikeMass(P, strikeIndex(), E);
81+ lastStrike = copyState(s);
82+ verlet = new Verlet(P, s);
83+ E0 = energy(P, s);
84+ H = stepFor(P, modes, E0);
85+ showDerived();
86+ t = 0;
87+ ampl = 0;
88+ replayButton.disabled = false;
89+ renderNote();
90+ pauseButton.disabled = false;
91+ setRunning(true);
92+});
93+
94+const relErr = () => (E0 > 0 ? (energy(P, s) + s.W - E0) / E0 : 0);
95+
96+// Parameters: numbers in the form, stretching form in a select; Apply
97+// restarts at rest. N and the ends are set above the form.
98+const paramInputs = {
99+ m: document.getElementById("p-m") as HTMLInputElement,
100+ k: document.getElementById("p-k") as HTMLInputElement,
101+ kappa: document.getElementById("p-kappa") as HTMLInputElement,
102+ k3: document.getElementById("p-k3") as HTMLInputElement,
103+ a: document.getElementById("p-a") as HTMLInputElement,
104+ tau: document.getElementById("p-tau") as HTMLInputElement,
105+};
106+const stretchSelect = document.getElementById("p-stretch") as HTMLSelectElement;
107+const paramStatus = document.getElementById("param-status")!;
108+function showParams() {
109+ for (const [key, el] of Object.entries(paramInputs)) el.value = String(P[key as keyof typeof paramInputs]);
110+ stretchSelect.value = P.stretch;
111+ endsSelect.value = P.ends;
112+ showDerived();
113+}
114+// Values that follow from the parameters (and, for the step, the last strike).
115+function showDerived() {
116+ const hz = Array.from(modes.omega, (w) => w / (2 * Math.PI));
117+ const list = P.N <= 8 ? hz.map((f) => f.toPrecision(3)).join(", ") : `${hz[0].toPrecision(3)} … ${hz[P.N - 1].toPrecision(3)}`;
118+ const ratios = hz.slice(1, 6).map((f) => (f / hz[0]).toFixed(2)).join(", ");
119+ document.getElementById("params")!.textContent =
120+ `normal modes ${list} Hz\n` +
121+ `ratios to mode 1 ${ratios}${P.N > 6 ? ", …" : ""}; top ${(hz[P.N - 1] / hz[0]).toFixed(1)}\n` +
122+ `nonlinear scale ${fmtEnergy(nonlinearScale(P, modes))} (mode 1: stretching = linear energy)\n` +
123+ `decay time ${damped() ? `${P.tau} s (amplitude, all modes)` : "none (no damping)"}\n` +
124+ `integrator step ${(1000 * H).toPrecision(3)} ms (from the strike energy; ` +
125+ `${(1000 * stepFor(P, modes, 0)).toPrecision(3)} ms when weak, ` +
126+ `${(1000 * stepFor(P, modes, 10 ** Number(energyInput.max))).toPrecision(3)} ms at the top of the slider)`;
127+}
128+
129+// Sound: the last strike again, sped up so the lowest linear mode sounds at
130+// the chosen pitch, rendered in a worker (as in the FPUT chain). While the
131+// panel is open each Strike plays a note; the note is computed and shown even
132+// when muted, which is how the page starts. A damped note is the whole decay,
133+// to -60 dB. The spectrogram (strike at the left, end at the right) fills in
134+// as the audio clock reaches each sample.
135+const soundPanel = document.getElementById("sound") as HTMLDetailsElement;
136+const replayButton = document.getElementById("replay") as HTMLButtonElement;
137+const saveButton = document.getElementById("save-wav") as HTMLButtonElement;
138+const signalSelect = document.getElementById("signal") as HTMLSelectElement;
139+const soundMass = document.getElementById("sound-mass") as HTMLInputElement;
140+const pitchInput = document.getElementById("pitch") as HTMLInputElement;
141+const soundStatus = document.getElementById("sound-status")!;
142+const soundHint = document.getElementById("sound-hint")!;
143+const soundClock = document.getElementById("sound-clock")!;
144+const aliasWarning = document.getElementById("alias")!;
145+for (const sig of SIGNALS) signalSelect.add(new Option(sig.label, sig.key));
146+signalSelect.value = "v";
147+const showSoundMass = () => (soundMass.hidden = !signalByKey(signalSelect.value).perMass);
148+signalSelect.addEventListener("change", showSoundMass);
149+showSoundMass();
150+
151+const player = new Player();
152+setupMute(document.getElementById("mute") as HTMLButtonElement, player);
153+document.getElementById("volume")!.addEventListener("input", (e) =>
154+ player.setVolume(Number((e.target as HTMLInputElement).value)),
155+);
156+const damped = () => P.tau > 0;
157+const pitch = () => 10 ** Number(pitchInput.value); // Hz of the lowest linear mode
158+const speedup = () => (pitch() * 2 * Math.PI) / modes.omega[0];
159+const modePitches = () => Array.from(modes.omega, (w) => (w / (2 * Math.PI)) * speedup());
160+// Energy decays at about 2/tau, so a drop by END_RATIO takes about
161+// (tau/2) ln(1/END_RATIO) of physical time (s of sound here).
162+const predictedNoteLength = () =>
163+ damped() ? ((P.tau / 2) * Math.log(1 / END_RATIO)) / speedup() : UNDAMPED_LENGTH;
164+function showPitch() {
165+ const f = modePitches();
166+ const top = f[f.length - 1];
167+ document.getElementById("pitch-value")!.textContent =
168+ `lowest mode ${f[0].toFixed(0)} Hz · highest ${top.toFixed(0)} Hz`;
169+ // Bending spreads the modes far apart: the top one can pass the Nyquist
170+ // frequency and fold back as a false low tone.
171+ const nyquist = player.sampleRate / 2;
172+ aliasWarning.hidden = top < nyquist;
173+ aliasWarning.textContent = `The highest modes are above ${(nyquist / 1000).toFixed(0)} kHz (half the sample rate) and will alias; lower the pitch.`;
174+ const S = speedup(), note = predictedNoteLength();
175+ soundClock.textContent = damped()
176+ ? `the whole decay, ${S.toFixed(0)}× faster: ${fmtDuration(note * S)} of motion in ${fmtDuration(note)}`
177+ : `${S.toFixed(0)}× faster than the beam above`;
178+}
179+pitchInput.addEventListener("input", showPitch);
180+
181+// Closing the panel stops the note. The audio context is created at the
182+// first note, from the Strike click (browsers require a user gesture).
183+soundPanel.addEventListener("toggle", () => {
184+ if (!soundPanel.open) player.stop();
185+});
186+
187+let spec: NoteSpectrogram | null = null;
188+let note: { x: Float32Array; rate: number } | null = null;
189+let fed = 0; // samples of the note given to the spectrogram
190+function feedDisplays(upTo: number) {
191+ if (!note || !spec) return;
192+ const end = Math.min(note.x.length, upTo);
193+ for (; fed < end; fed++) spec.push(note.x[fed]);
194+}
195+
196+const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" });
197+let requestId = 0;
198+let noteInfo = "";
199+let noteFile = ""; // name for Save WAV
200+let renderStart = 0;
201+// Save WAV: the last rendered note as heard (16-bit mono, peak at 0.9).
202+saveButton.addEventListener("click", () => {
203+ if (note) saveFile(wavBlob(note.x, note.rate), noteFile);
204+});
205+function renderNote() {
206+ if (!lastStrike || !soundPanel.open) return;
207+ const ctx = player.ensure();
208+ player.setPaused(!running); // resumes the audio clock within the click
209+ showPitch(); // the sample rate is known now
210+ const sig = signalByKey(signalSelect.value);
211+ const j = Math.min(P.N, Math.max(1, Math.round(Number(soundMass.value) || 1)));
212+ soundMass.value = String(j);
213+ const req: RenderRequest = {
214+ id: ++requestId, P, state: lastStrike, speedup: speedup(), sampleRate: ctx.sampleRate,
215+ maxLength: 2 * predictedNoteLength(), signal: sig.key, j: j - 1,
216+ };
217+ noteInfo = `${sig.label}${sig.perMass ? ` ${j}` : ""}`;
218+ noteFile =
219+ `beam-${P.ends}-${P.stretch}-N${P.N}-${strikeLabel}-${energy(P, lastStrike).toPrecision(3)}J-` +
220+ `${sig.key}${sig.perMass ? j : ""}-${pitch().toFixed(0)}Hz.wav`;
221+ soundStatus.textContent = "computing…";
222+ soundHint.hidden = true;
223+ player.stop();
224+ renderStart = performance.now();
225+ worker.postMessage(req);
226+}
227+worker.onmessage = (e: MessageEvent<RenderResult>) => {
228+ if (e.data.id !== requestId) return; // superseded
229+ const rate = player.sampleRate;
230+ const x = e.data.samples;
231+ Player.normalize(x, rate);
232+ note = { x, rate };
233+ fed = 0;
234+ const span = x.length / rate;
235+ // Spectrogram window: 2048 samples down to a lowest-mode pitch of 30 Hz;
236+ // below that longer, in proportion (as in the FPUT chain).
237+ const n = Math.min(16384, 2 ** Math.ceil(Math.log2((2048 * 30) / Math.min(30, pitch()))));
238+ spec = new NoteSpectrogram({ dt: 1 / rate, n, nfft: 2 * n, hop: n / 4, refFreqs: modePitches(), width: 960 });
239+ spec.start(span);
240+ document.getElementById("spectrogram")!.replaceChildren(spec.canvas);
241+ const secs = (performance.now() - renderStart) / 1000;
242+ soundStatus.textContent = `${noteInfo} · ${fmtDuration(span)} · computed in ${secs.toFixed(1)} s`;
243+ player.play(x);
244+ saveButton.disabled = false;
245+ player.setPaused(!running);
246+};
247+replayButton.addEventListener("click", renderNote);
248+
249+// Set new parameters: everything back to rest.
250+function setParams(next: Params) {
251+ P = next;
252+ modes = new Modes(P);
253+ H = stepFor(P, modes, 0);
254+ s = newState(P.N);
255+ verlet = new Verlet(P, s);
256+ E0 = 0;
257+ t = 0;
258+ ampl = 0;
259+ nInput.value = String(P.N);
260+ indexInput.max = String(P.N);
261+ indexInput.value = String(strikeIndex());
262+ pauseButton.disabled = true;
263+ setRunning(false);
264+ pauseButton.textContent = "Pause";
265+ lastStrike = null;
266+ replayButton.disabled = true;
267+ saveButton.disabled = true;
268+ note = null;
269+ player.stop();
270+ soundMass.max = String(P.N);
271+ soundMass.value = String(Math.min(P.N, Number(soundMass.value) || 1));
272+ showPitch();
273+ showParams();
274+}
275+setParams(P);
276+
277+nInput.addEventListener("change", () => {
278+ const N = Math.min(64, Math.max(2, Math.round(Number(nInput.value) || P.N)));
279+ setParams({ ...P, N });
280+});
281+endsSelect.addEventListener("change", () => setParams({ ...P, ends: endsSelect.value as Ends }));
282+document.getElementById("param-apply")!.addEventListener("click", () => {
283+ const v = Object.fromEntries(Object.entries(paramInputs).map(([key, el]) => [key, Number(el.value)]));
284+ const ok = Object.entries(v).every(
285+ ([key, x]) => Number.isFinite(x) && (x > 0 || (["tau", "k", "kappa"].includes(key) && x === 0)),
286+ );
287+ if (!ok || !(v.k > 0 || v.kappa > 0)) {
288+ paramStatus.textContent = "parameters must be positive (tau, k or κ may be 0, not both k and κ)";
289+ return;
290+ }
291+ paramStatus.textContent = "";
292+ setParams({ ...P, ...v, stretch: stretchSelect.value as Stretch });
293+});
294+
295+const TEXT = "#23262b", MUTED = "#7a7d82", LINE = "#e3e2dc";
296+const MONO = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
297+
298+// The beam seen from the side: masses on their rails (faint vertical lines),
299+// the beam through them and the supports, displacements scaled to fit (the
300+// scale is given). Supports: a clamp is a hatched wall, a pin a triangle; a
301+// free end has none.
302+function drawBeam() {
303+ const ctx = fit(beamCanvas);
304+ const W = beamCanvas.clientWidth, Ht = beamCanvas.clientHeight;
305+ ctx.clearRect(0, 0, W, Ht);
306+ const { N } = P;
307+ const free = P.ends === "cantilever";
308+ const m = 28, dx = (W - 2 * m) / (free ? N : N + 1);
309+ const x = (i: number) => m + (i + 1) * dx; // node i: masses 0..N-1, supports -1 and N
310+ const top = 34, bottom = Ht - 14, y0 = (top + bottom) / 2, half = 0.45 * (bottom - top);
311+ const scale = ampl > 0 ? half / ampl : 0; // px per m
312+ const y = (i: number) => (i >= 0 && i < N ? y0 - scale * s.u[i] : y0);
313+
314+ // Rails and the rest line.
315+ ctx.strokeStyle = LINE;
316+ ctx.lineWidth = 1;
317+ ctx.beginPath();
318+ for (let j = 0; j < N; j++) {
319+ ctx.moveTo(x(j), top);
320+ ctx.lineTo(x(j), bottom);
321+ }
322+ ctx.moveTo(m, y0);
323+ ctx.lineTo(free ? x(N - 1) : W - m, y0);
324+ ctx.stroke();
325+
326+ // Supports.
327+ ctx.strokeStyle = MUTED;
328+ ctx.fillStyle = MUTED;
329+ const clamp = (xs: number, dir: number) => {
330+ ctx.lineWidth = 2;
331+ ctx.beginPath();
332+ ctx.moveTo(xs, y0 - 18);
333+ ctx.lineTo(xs, y0 + 18);
334+ ctx.stroke();
335+ ctx.lineWidth = 1;
336+ ctx.beginPath();
337+ for (let k = -18; k < 18; k += 6) {
338+ ctx.moveTo(xs, y0 + k + 6);
339+ ctx.lineTo(xs + 6 * dir, y0 + k);
340+ }
341+ ctx.stroke();
342+ };
343+ const pin = (xs: number) => {
344+ ctx.beginPath();
345+ ctx.moveTo(xs, y0);
346+ ctx.lineTo(xs - 7, y0 + 12);
347+ ctx.lineTo(xs + 7, y0 + 12);
348+ ctx.closePath();
349+ ctx.fill();
350+ };
351+ if (P.ends === "pinned") {
352+ pin(x(-1));
353+ pin(x(N));
354+ } else {
355+ clamp(x(-1), -1);
356+ if (P.ends === "clamped") clamp(x(N), 1);
357+ }
358+
359+ // The beam and its masses.
360+ ctx.strokeStyle = TEXT;
361+ ctx.lineWidth = 2.5;
362+ ctx.lineJoin = "round";
363+ ctx.beginPath();
364+ ctx.moveTo(x(-1), y0);
365+ for (let j = 0; j < N; j++) ctx.lineTo(x(j), y(j));
366+ if (!free) ctx.lineTo(x(N), y0);
367+ ctx.stroke();
368+ const r = Math.max(2.5, Math.min(6, dx / 5));
369+ for (let j = 0; j < N; j++) {
370+ ctx.fillStyle = indexColor(j, N);
371+ ctx.beginPath();
372+ ctx.arc(x(j), y(j), r, 0, 2 * Math.PI);
373+ ctx.fill();
374+ }
375+
376+ // Readouts.
377+ ctx.fillStyle = TEXT;
378+ ctx.font = MONO;
379+ ctx.textAlign = "left";
380+ ctx.fillText(`t ${t.toFixed(2)} s · E ${fmtEnergy(energy(P, s))}`, 6, 16);
381+ ctx.fillStyle = MUTED;
382+ ctx.textAlign = "right";
383+ ctx.fillText(`energy error ${Math.abs(relErr()).toExponential(1)}`, W - 6, 16);
384+ ctx.font = "11px system-ui, sans-serif";
385+ if (ampl > 0) ctx.fillText(`displacement scaled to fit, full height ±${fmtLength(ampl / 0.9)}`, W - 6, Ht - 2);
386+ ctx.textAlign = "left";
387+}
388+
389+// Animation: the simulation advances `speed` seconds per second of real time.
390+let last = performance.now();
391+let carry = 0; // simulated time owed but not yet stepped
392+let playing = false; // a note was playing at the last frame
393+function frame(now: number) {
394+ const dt = Math.min(0.1, (now - last) / 1000);
395+ last = now;
396+ if (running) {
397+ carry += Number(speedSelect.value) * dt;
398+ while (carry >= H) {
399+ verlet.step(s, H);
400+ carry -= H;
401+ t += H;
402+ }
403+ }
404+ let umax = 0;
405+ for (const u of s.u) umax = Math.max(umax, Math.abs(u));
406+ ampl = Math.max(umax, 0.998 * ampl);
407+ drawBeam();
408+ if (note && soundPanel.open) {
409+ const at = player.position();
410+ if (at !== null) feedDisplays(Math.round(at * note.rate));
411+ else if (playing) feedDisplays(note.x.length); // ended
412+ playing = at !== null;
413+ spec?.draw();
414+ }
415+ requestAnimationFrame(frame);
416+}
417+requestAnimationFrame(frame);
src/beam/render-worker.tsadded+55−0View file
@@ -0,0 +1,55 @@
1+// Renders a note: the beam from a given state, sped up, sampled as audio.
2+// Each audio sample advances the physics by speedup / sampleRate seconds, in
3+// equal substeps no longer than stepFor the current energy (re-chosen every
4+// DIAG_EVERY samples, so the steps lengthen as a damped note decays). A
5+// damped note ends once its energy has fallen to END_RATIO of the start
6+// (-60 dB); an undamped one after UNDAMPED_LENGTH seconds.
7+
8+import { Modes, Verlet, copyState, energy, stepFor, type Params, type State } from "./beam";
9+import { signalByKey } from "./signals";
10+
11+export const END_RATIO = 1e-6;
12+export const UNDAMPED_LENGTH = 10; // s of sound
13+export const DIAG_EVERY = 256; // samples between energy checks
14+
15+export interface RenderRequest {
16+ id: number;
17+ P: Params;
18+ state: State;
19+ speedup: number;
20+ sampleRate: number;
21+ maxLength: number; // s of sound, at most
22+ signal: string; // signal key
23+ j: number; // mass index (0-based) for per-mass signals
24+}
25+
26+export interface RenderResult {
27+ id: number;
28+ samples: Float32Array; // raw signal, in the signal's units
29+}
30+
31+self.onmessage = (e: MessageEvent<RenderRequest>) => {
32+ const r = e.data;
33+ const s = copyState(r.state);
34+ const modes = new Modes(r.P);
35+ const verlet = new Verlet(r.P, s);
36+ const signal = signalByKey(r.signal);
37+ const dtSample = r.speedup / r.sampleRate;
38+ let sub = 1, h = dtSample;
39+ const E0 = energy(r.P, s);
40+ const nMax = Math.round((r.P.tau > 0 ? r.maxLength : UNDAMPED_LENGTH) * r.sampleRate);
41+ const out = new Float32Array(nMax);
42+ let n = 0;
43+ for (; n < nMax; n++) {
44+ if (n % DIAG_EVERY === 0) {
45+ const E = energy(r.P, s);
46+ if (r.P.tau > 0 && E <= END_RATIO * E0) break;
47+ sub = Math.ceil(dtSample / stepFor(r.P, modes, E));
48+ h = dtSample / sub;
49+ }
50+ out[n] = signal.value(r.P, s, verlet.force, r.j);
51+ for (let k = 0; k < sub; k++) verlet.step(s, h);
52+ }
53+ const result: RenderResult = { id: r.id, samples: out.slice(0, n) };
54+ (self as unknown as Worker).postMessage(result, [result.samples.buffer]);
55+};
src/beam/signals.tsadded+32−0View file
@@ -0,0 +1,32 @@
1+// Quantities that can be heard. In a single mass's motion, mode n appears
2+// with weight sqrt(E_n) / omega_n (displacement), sqrt(E_n) (velocity) or
3+// sqrt(E_n) omega_n (acceleration), times the mode shape at that mass.
4+
5+import type { Params, State } from "./beam";
6+
7+export interface Signal {
8+ key: string;
9+ label: string;
10+ perMass: boolean; // takes a mass index j
11+ // Value now; F is the force on each mass at the current displacements.
12+ value: (P: Params, s: State, F: Float64Array, j: number) => number;
13+}
14+
15+export const SIGNALS: Signal[] = [
16+ { key: "v", label: "velocity of mass", perMass: true, value: (P, s, _F, j) => s.p[j] / P.m },
17+ { key: "u", label: "displacement of mass", perMass: true, value: (_P, s, _F, j) => s.u[j] },
18+ { key: "a", label: "acceleration of mass", perMass: true, value: (P, _s, F, j) => F[j] / P.m },
19+ {
20+ // The forces between masses cancel in the sum, so this is the net force
21+ // of the supports on the beam (for a cantilever, the clamp's): what a
22+ // soundboard under them would feel.
23+ key: "suma", label: "sum of accelerations", perMass: false,
24+ value: (P, _s, F) => {
25+ let x = 0;
26+ for (const f of F) x += f;
27+ return x / P.m;
28+ },
29+ },
30+];
31+
32+export const signalByKey = (key: string) => SIGNALS.find((s) => s.key === key) ?? SIGNALS[0];
src/beam/style.cssadded+21−0View file
@@ -0,0 +1,21 @@
1+/* Styles for this app only, after ../common/style.css. */
2+
3+.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
4+.row + .row { margin-top: 10px; }
5+canvas { display: block; width: 100%; }
6+[hidden] { display: none !important; }
7+#beam { aspect-ratio: 2.4; }
8+input[type="number"] { width: 4.5em; font-family: var(--mono); }
9+#energy-value { min-width: 4.5em; text-align: right; }
10+details.panel { margin-top: 16px; border: 1px solid var(--line); padding: 16px; }
11+details.panel summary { display: flex; align-items: center; gap: 10px; }
12+#sound .row + .row { margin-top: 12px; }
13+#sound label { display: flex; align-items: center; gap: 8px; }
14+#sound .grow { flex: 1; min-width: 220px; }
15+#sound-mass { width: 3.5em; }
16+#sound-hint { margin: 14px 0 0; }
17+#alias { color: #a33; }
18+#spectrogram canvas { width: 100%; height: auto; border-radius: 4px; }
19+#param-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 16px; margin-top: 10px; }
20+#param-form select { width: 100%; }
21+#param-form label.wide { grid-column: span 2; grid-template-columns: 5.5em auto; }
src/beam/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
src/common/canvas.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+// Size a canvas's backing store to its displayed size at device resolution;
2+// returns its context, scaled so drawing is in CSS pixels.
3+export function fit(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
4+ const dpr = window.devicePixelRatio || 1;
5+ const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
6+ if (canvas.width !== w || canvas.height !== h) {
7+ canvas.width = w;
8+ canvas.height = h;
9+ }
10+ const ctx = canvas.getContext("2d")!;
11+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
12+ return ctx;
13+}
src/common/format.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+// Lengths, energies and durations in readable units.
2+
3+export function fmtLength(x: number): string {
4+ const a = Math.abs(x);
5+ return a >= 1 ? `${x.toPrecision(3)} m` : a >= 0.01 ? `${(100 * x).toPrecision(3)} cm` : `${(1000 * x).toPrecision(3)} mm`;
6+}
7+
8+export function fmtEnergy(E: number): string {
9+ return E >= 1 ? `${E.toPrecision(3)} J` : E >= 1e-3 ? `${(1e3 * E).toPrecision(3)} mJ` : `${(1e6 * E).toPrecision(3)} µJ`;
10+}
11+
12+export const fmtDuration = (x: number) => (x >= 120 ? `${(x / 60).toFixed(0)} min` : `${x.toFixed(x >= 10 ? 0 : 1)} s`);
src/common/mute.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+// Mute button. Speaker icon: with sound waves when audible; crossed out,
2+// tinted, and labelled "Muted" when muted, so the silence is not mistaken for
3+// a fault.
4+
5+const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
6+const ICON_MUTED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9l5 6M21 9l-5 6"/></svg>`;
7+const ICON_AUDIBLE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10"/></svg>`;
8+
9+export interface Mutable {
10+ muted: boolean;
11+ setMuted(m: boolean): void;
12+}
13+
14+// Returns a function that redraws the button, for when target.muted is
15+// changed from elsewhere.
16+export function setupMute(button: HTMLButtonElement, target: Mutable): () => void {
17+ const show = () => {
18+ const label = target.muted ? "Unmute" : "Mute";
19+ button.innerHTML = target.muted ? `${ICON_MUTED}<span>Muted</span>` : ICON_AUDIBLE;
20+ button.title = label;
21+ button.setAttribute("aria-label", label);
22+ button.classList.toggle("audible", !target.muted);
23+ button.classList.toggle("is-muted", target.muted);
24+ };
25+ show();
26+ button.addEventListener("click", () => {
27+ target.setMuted(!target.muted);
28+ show();
29+ });
30+ return show;
31+}
src/fput-chain/sound.ts →src/common/sound.tsrenamed+0−0View file
No changes to the file's content.
src/double-pendulum/main.tsmodified+2−18View file
@@ -1,5 +1,6 @@
11 import "../common/style.css";
22 import "./style.css";
3+import { setupMute } from "../common/mute";
34 import { AudioOut } from "./audio";
45 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
56 import { newCursor } from "../common/cursor";
@@ -389,24 +390,7 @@ audioPanel.addEventListener("toggle", async () => {
389390 document.getElementById("pc-follow")!.addEventListener("change", (e) => {
390391 if ((e.target as HTMLSelectElement).value === "sound") audioPanel.open = true;
391392 });
392-// Speaker icon: with sound waves when audible; crossed out, tinted, and
393-// labelled "Muted" when muted, so the silence is not mistaken for a fault.
394-const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
395-const ICON_MUTED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9l5 6M21 9l-5 6"/></svg>`;
396-const ICON_AUDIBLE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10"/></svg>`;
397-const showMute = () => {
398- const label = audio.muted ? "Unmute" : "Mute";
399- muteButton.innerHTML = audio.muted ? `${ICON_MUTED}<span>Muted</span>` : ICON_AUDIBLE;
400- muteButton.title = label;
401- muteButton.setAttribute("aria-label", label);
402- muteButton.classList.toggle("audible", !audio.muted);
403- muteButton.classList.toggle("is-muted", audio.muted);
404-};
405-showMute();
406-muteButton.addEventListener("click", () => {
407- audio.setMuted(!audio.muted);
408- showMute();
409-});
393+const showMute = setupMute(muteButton, audio);
410394 pitchInput.addEventListener("input", () => {
411395 showPitch();
412396 audio.setPitch(pitchHz());
src/fput-chain/README.mdmodified+2−2View file
@@ -4,7 +4,7 @@ Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-c
44
55 Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`../common/lyapunov.ts`, two-trajectory Benettin method shared with the pendulum app, with the chain's shadow in `lyapunov.ts`: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
66
7-"Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; `NoteSpectrogram` in `../common/spectrogram.ts`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, drawn only while at least 6 px apart, since crowded together their dashes merged into a solid on-off bar at the bottom), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
7+"Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`../common/sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; `NoteSpectrogram` in `../common/spectrogram.ts`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, drawn only while at least 6 px apart, since crowded together their dashes merged into a solid on-off bar at the bottom), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
88
99 Poincaré section (shown only for N = 2 and N = 3, below the sped-up panel, open; adapted from the pendulum app): its own energy slider (log, 10 µJ to 100 J, default 2.51 J), same parameters, no damping. N = 2: each time mass 1 passes its rest position moving right, mass 2's (u2, p2) is plotted. N = 3: only the symmetric family (u1 = u3, p1 = p3; only modes 1 and 3 move, since the cubic force couples odd modes only to odd modes), which is two-dimensional: each time mass 2 passes its rest position moving right, mass 1's (u1, p1) is plotted. On either section the springs are stretched by 0 or +-x, so V = vFactor (k x^2 / 2 + k3 x^4 / 4) (vFactor 2 or 4), the plotted momentum counts once (N = 2) or twice (N = 3) in the kinetic energy, the outline is where the crossing mass's momentum vanishes, and that momentum follows from E. 42 orbits seeded on a grid, 300 crossings each, in a worker (`poincare-core.ts`, the chain's section, on `../common/section.ts` and `../common/section-worker.ts`; `poincare.ts`, the panel's configuration for `../common/section-panel.ts`; integrator step stepFor(E), energy error < 5e-5; ~1 s per section offline, a few s in the pane), drawn as they arrive; per-orbit colors or Color by λ (each orbit's Lyapunov exponent over its run, log scale); hover to pick out an orbit with its λ; click inside the outline to add an orbit there, and with Click to strike (on) also strike the chain from that exact state (and play it, if the sound panel is open). Follow (as in the pendulum app): "real time" makes the section's energy track the chain's as it decays (recomputed after a 3% change, at most every 2 s, and at each strike); "sped up" (which opens the sped-up panel) shows the playing note's crossings (found in the render worker, for N = 3 only while exactly symmetric, and drawn as the audio clock reaches them) as a fading trail of the last 150, with the axes fitted to the note's current energy (refit every 2%) and each dot fitted from its own energy; nothing is computed live. "Precompute sections" (shown only with Follow "sped up" until complete) builds an atlas (`../common/atlas.ts`, configured in `atlas.ts`): sections at 32 energies log-spaced from 1 mJ to 100 J (a factor ~1.45 apart), 42 orbits x 150 crossings, highest first, in its own worker (~22 s in the pane for N = 3), stored in IndexedDB per N, m, k, k3 and deleted after 7 days; the sound view shows the atlas section nearest the current energy, fitted to its allowed region (exact in the linear regime, below ~25 mJ, where sections are scaled copies; an approximation across the gaps above). Without an atlas only the outline is drawn behind the trail. Setting the section's energy by hand pauses everything and clears the rings and the trail; Follow takes over again on Resume. The chain's own crossings show as dark rings when within 5% of the section's energy and, for N = 3, only while the chain is exactly symmetric (else the legend says "chain not symmetric, not shown"; strikes on mass 2 or on mode 1 or 3 are symmetric, mode shapes are made exactly mirror-symmetric for this). Findings: the two-mass chain appears regular at every energy tried (10 mJ to 100 J): nested closed curves and island chains, no chaotic sea; the grid orbits' λ falls as the run lengthens (at 10 J, median 0.045 /s over 300 crossings, 0.007 /s over 3000), as for regular orbits. A likely reason: in mode coordinates (u = S Q) the potential is (k/2) rho^2 + (k3/8) rho^4 with rho^2 = Q1^2 + 3 Q2^2, a function of one combination of the two modes. The N = 3 symmetric family is chaotic: grid orbits with λ > 0.1 /s: 0 of 42 at 25 mJ, 10 at 0.25 J (islands in a chaotic sea), 37 at 2.51 J. It is unstable to breaking the symmetry: an exactly symmetric start stays symmetric (the arithmetic is exactly mirror-symmetric), but an asymmetry of 1e-12 grows to full size within ~100 s at 0.25 to 25 J. The ghost and the Lyapunov shadow offset all masses equally, so after a symmetric strike they too stay symmetric and measure the exponent within the family.
1010
@@ -26,4 +26,4 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/fput
2626 - Chaos for N = 4 (two-trajectory Lyapunov, 5000 / sqrt(k/m) s): none below E ~ 1 k d0^2; onset near 3 k d0^2; at the default 10 k d0^2, lambda ~0.1 sqrt(k/m) = 0.32 /s, energy shared by ~2.5 modes.
2727 - Symmetry: the quartic force couples odd modes only to odd modes, so a strike in an odd mode leaves the even modes at exactly zero (they stay off the bottom of the chart). A strike on one mass, other than the middle one, excites all modes.
2828
29-Code: `chain.ts` (physics, modes, strikes), `main.ts` (page), `../common/traces.ts` and `../common/cursor.ts` (charts), `signals.ts`, `render-worker.ts`, `sound.ts`, `../common/spectrogram.ts`, `../common/scatter.ts` (sound), `lyapunov.ts` (Lyapunov exponent, animation and sound), `poincare*.ts` (section, N = 2 and 3).
29+Code: `chain.ts` (physics, modes, strikes), `main.ts` (page), `../common/traces.ts` and `../common/cursor.ts` (charts), `signals.ts`, `render-worker.ts`, `../common/sound.ts`, `../common/spectrogram.ts`, `../common/scatter.ts` (sound), `lyapunov.ts` (Lyapunov exponent, animation and sound), `poincare*.ts` (section, N = 2 and 3).
src/fput-chain/main.tsmodified+5−42View file
@@ -13,7 +13,10 @@ import { sectionDef } from "./poincare-core";
1313 import { END_RATIO, DIAG_EVERY, UNDAMPED_LENGTH, type RenderRequest, type RenderResult } from "./render-worker";
1414 import { ScatterPlot } from "../common/scatter";
1515 import { SIGNALS, signalByKey } from "./signals";
16-import { Player } from "./sound";
16+import { Player } from "../common/sound";
17+import { setupMute } from "../common/mute";
18+import { fmtDuration, fmtEnergy, fmtLength } from "../common/format";
19+import { fit } from "../common/canvas";
1720 import { NoteSpectrogram } from "../common/spectrogram";
1821 import { TracePanel, type Group } from "../common/traces";
1922 import { saveFile, wavBlob } from "../common/wav";
@@ -58,15 +61,6 @@ const PALETTE = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
5861 const indexColor = (k: number, n: number) =>
5962 n <= PALETTE.length ? PALETTE[k] : `hsl(${215 - (215 * k) / (n - 1)}, 55%, 45%)`;
6063
61-// Lengths and energies in readable units.
62-function fmtLength(x: number): string {
63- const a = Math.abs(x);
64- return a >= 1 ? `${x.toPrecision(3)} m` : a >= 0.01 ? `${(100 * x).toPrecision(3)} cm` : `${(1000 * x).toPrecision(3)} mm`;
65-}
66-function fmtEnergy(E: number): string {
67- return E >= 1 ? `${E.toPrecision(3)} J` : E >= 1e-3 ? `${(1e3 * E).toPrecision(3)} mJ` : `${(1e6 * E).toPrecision(3)} µJ`;
68-}
69-
7064 const strikeEnergy = () => 10 ** Number(energyInput.value);
7165 const showEnergy = () => (energyValue.textContent = fmtEnergy(strikeEnergy()));
7266 showEnergy();
@@ -325,7 +319,6 @@ const modePitches = () => Array.from(modes.omega, (w) => (w / (2 * Math.PI)) * s
325319 // (tau/2) ln(1/END_RATIO) of physical time (s of sound here).
326320 const predictedNoteLength = () =>
327321 damped() ? ((P.tau / 2) * Math.log(1 / END_RATIO)) / speedup() : UNDAMPED_LENGTH;
328-const fmtDuration = (x: number) => (x >= 120 ? `${(x / 60).toFixed(0)} min` : `${x.toFixed(x >= 10 ? 0 : 1)} s`);
329322 function showPitch() {
330323 const f = modePitches();
331324 document.getElementById("pitch-value")!.textContent =
@@ -341,24 +334,7 @@ document.getElementById("volume")!.addEventListener("input", (e) =>
341334 player.setVolume(Number((e.target as HTMLInputElement).value)),
342335 );
343336
344-// Speaker icon: with sound waves when audible; crossed out, tinted, and
345-// labelled "Muted" when muted, so the silence is not mistaken for a fault.
346-const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
347-const ICON_MUTED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9l5 6M21 9l-5 6"/></svg>`;
348-const ICON_AUDIBLE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10"/></svg>`;
349-function showMute() {
350- const label = player.muted ? "Unmute" : "Mute";
351- muteButton.innerHTML = player.muted ? `${ICON_MUTED}<span>Muted</span>` : ICON_AUDIBLE;
352- muteButton.title = label;
353- muteButton.setAttribute("aria-label", label);
354- muteButton.classList.toggle("audible", !player.muted);
355- muteButton.classList.toggle("is-muted", player.muted);
356-}
357-showMute();
358-muteButton.addEventListener("click", () => {
359- player.setMuted(!player.muted);
360- showMute();
361-});
337+setupMute(muteButton, player);
362338
363339 // Closing the panel stops the note. The audio context is created at the
364340 // first note, from the Strike click (browsers require a user gesture).
@@ -572,19 +548,6 @@ document.getElementById("param-apply")!.addEventListener("click", () => {
572548 setParams({ ...P, ...v });
573549 });
574550
575-// Size a canvas's backing store to its displayed size at device resolution.
576-function fit(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
577- const dpr = window.devicePixelRatio || 1;
578- const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
579- if (canvas.width !== w || canvas.height !== h) {
580- canvas.width = w;
581- canvas.height = h;
582- }
583- const ctx = canvas.getContext("2d")!;
584- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
585- return ctx;
586-}
587-
588551 const ACCENT = "#2f5d8a", TEXT = "#23262b", MUTED = "#7a7d82", LINE = "#e3e2dc";
589552 const MONO = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
590553
src/index.htmlmodified+1−0View file
@@ -33,6 +33,7 @@
3333 <ul>
3434 <li><a href="./double-pendulum/"><strong>Double pendulum</strong><div class="muted">Strike it, watch and hear the motion turn regular as the energy decays; Lyapunov exponent and Poincaré section.</div></a></li>
3535 <li><a href="./fput-chain/"><strong>FPUT chain</strong><div class="muted">A struck chain of N masses with cubic springs; energy spreading up the modes, Lyapunov exponent and Poincaré sections for N = 2 and 3.</div></a></li>
36+ <li><a href="./beam/"><strong>Beam</strong><div class="muted">A struck beam of N masses with bending stiffness and stretching: clamped, pinned or cantilever; inharmonic, cymbal-like overtones.</div></a></li>
3637 </ul>
3738 </main>
3839 </body>
vite.config.tsmodified+1−0View file
@@ -18,6 +18,7 @@ export default defineConfig({
1818 main: page("index.html"),
1919 "double-pendulum": page("double-pendulum/index.html"),
2020 "fput-chain": page("fput-chain/index.html"),
21+ beam: page("beam/index.html"),
2122 },
2223 // Name the chunk holding src/common/ "common" (otherwise it takes the
2324 // name of one of its modules).
↑↓ move↵openescclose