/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Anti-alias filter for rendered notes (beam, fput-chain): 4x oversampling, Kaiser FIR low-pass to 0.42 fs, decimation; beam warning now says modes above the passband are filtered out
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 0172972df39c parent 1115407 Browse files
6 changed files+88−20
src/beam/README.mdmodified+1−1View file
@@ -4,7 +4,7 @@ Struck beam with bending stiffness, step 3 of "Listening for chaos" (after doubl
44
55 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, at rest shape plus displacement, scaled to fit (scale given; the rest shape is a faint line), 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. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", 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. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, rise h, a, τ, τ top, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
66
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. Chips: Spectrogram (on; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band; mode lines each drawn only while at least 6 px from their neighbours: at the default note the six lowest are drawn, modes 7 and 8 are 2 px apart; on the old linear axis to 10 kHz the modes filled the bottom eighth), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart or λ vs E is shown, as it doubles the cost) Energy (log) and λ vs E (off; `../common/scatter.ts` as in fput-chain: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note, earlier notes faded, hover for values, Clear; not cleared when parameters change). All but λ vs E span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
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. Notes are anti-alias filtered (`../common/antialias.ts`, in the shared render loop): the signal is read 4 times per audio sample, low-passed by a 143-tap Kaiser FIR (passband to 0.42 fs, 20.2 kHz at 48 kHz, ripple 0.0003 dB; at least 87 dB down from 0.58 fs, so what folds lands above the passband) and decimated, centred so samples stay aligned with the diagnostics. A note shows when the top mode is above the passband (those modes are filtered out). Checked (Node, one trajectory recorded at 32x or 64x, against an FFT brick-wall reference at 20 kHz, error below 18 kHz): default N = 8 -153 dB before, -107 dB now (filter and float32 floor); clamped N = 32, 25 J -46 -> -105 dB; cantilever N = 8, 2.51 J -95 -> -111 dB; cantilever N = 16, 2.51 J -20 -> -104 dB. Cost: the 4 points per sample force at least 4 steps per sample, so the default N = 8 note now takes ~0.9 s to compute (0.3 s before; 1.49 M steps instead of 0.57 M); N = 32 9.9 s (7.9 s). 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. Chips: Spectrogram (on; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band; mode lines each drawn only while at least 6 px from their neighbours: at the default note the six lowest are drawn, modes 7 and 8 are 2 px apart; on the old linear axis to 10 kHz the modes filled the bottom eighth), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart or λ vs E is shown, as it doubles the cost) Energy (log) and λ vs E (off; `../common/scatter.ts` as in fput-chain: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note, earlier notes faded, hover for values, Clear; not cleared when parameters change). All but λ vs E span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
88
99 Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam/ (part of the chaos-apps site).
1010
src/beam/index.htmlmodified+1−1View file
@@ -153,7 +153,7 @@
153153 <template id="help-sound">
154154 <h2>Sped up · listen</h2>
155155 <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>
156- <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard. The charts span the whole note: the spectrogram, with dashed lines at the normal-mode pitches, the Lyapunov exponent over the last 10 s of the beam's own time, the energy, and Modes, each mode's share of the energy as a heat map (mode 1 at the bottom). λ vs E collects the exponent against the energy over notes, earlier notes faded (Clear starts over). A warning shows when the top mode is above half the sample rate and would alias.</p>
156+ <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard. The charts span the whole note: the spectrogram, with dashed lines at the normal-mode pitches, the Lyapunov exponent over the last 10 s of the beam's own time, the energy, and Modes, each mode's share of the energy as a heat map (mode 1 at the bottom). λ vs E collects the exponent against the energy over notes, earlier notes faded (Clear starts over). Notes are anti-alias filtered (motion above 20 kHz at 48 kHz is removed rather than folded back); a note says when the top modes are above that and so are not heard.</p>
157157 </template>
158158 <script type="module" src="./main.ts"></script>
159159 </body>
src/beam/main.tsmodified+6−5View file
@@ -13,6 +13,7 @@ import { Lyapunov } from "../common/lyapunov";
1313 import { drawModeBars } from "../common/modebars";
1414 import { LiveModeHistory, ModeHistory } from "../common/modehistory";
1515 import { setupMute } from "../common/mute";
16+import { PASS } from "../common/antialias";
1617 import { DIAG_EVERY, END_RATIO, LYAP_WINDOW, UNDAMPED_LENGTH } from "../common/note-render";
1718 import { ScatterPlot } from "../common/scatter";
1819 import { Player } from "../common/sound";
@@ -287,11 +288,11 @@ function showPitch() {
287288 const top = f[f.length - 1];
288289 document.getElementById("pitch-value")!.textContent =
289290 `lowest mode ${f[0].toFixed(0)} Hz · highest ${top.toFixed(0)} Hz`;
290- // Bending spreads the modes far apart: the top one can pass the Nyquist
291- // frequency and fold back as a false low tone.
292- const nyquist = player.sampleRate / 2;
293- aliasWarning.hidden = top < nyquist;
294- aliasWarning.textContent = `The highest modes are above ${(nyquist / 1000).toFixed(0)} kHz (half the sample rate) and will alias; lower the pitch.`;
291+ // Bending spreads the modes far apart: the top ones can pass the anti-alias
292+ // filter's passband (../common/antialias.ts) and are then not heard.
293+ const edge = PASS * player.sampleRate;
294+ aliasWarning.hidden = top < edge;
295+ aliasWarning.textContent = `The highest modes are above ${(edge / 1000).toFixed(1)} kHz and are filtered out (inaudible, and they would alias); lower the pitch to hear them.`;
295296 const S = speedup(), note = predictedNoteLength();
296297 soundClock.textContent = damped()
297298 ? `the whole decay, ${S.toFixed(0)}× faster: ${fmtDuration(note * S)} of motion in ${fmtDuration(note)}`
src/common/antialias.tsadded+59−0View file
@@ -0,0 +1,59 @@
1+// Anti-alias filter for rendered notes. The note is sampled OVERSAMPLE times
2+// per audio sample, low-passed and decimated, so motion above half the audio
3+// sample rate does not fold back as false lower tones.
4+//
5+// Linear-phase FIR with a Kaiser window, at the oversampled rate. Passband to
6+// PASS x the audio rate (20.2 kHz at 48 kHz), stopband from 1 - PASS (27.8
7+// kHz): what lies between half the audio rate and the stopband folds back
8+// only above PASS, out of hearing, which allows the wide transition and so a
9+// short filter. The output is centred on the input (no delay), so sample n is
10+// still the motion at sample n's time.
11+
12+export const OVERSAMPLE = 4;
13+export const PASS = 0.42; // passband edge, in units of the audio sample rate
14+const ATTEN = 90; // dB, stopband attenuation
15+
16+// Modified Bessel function I0 (series), for the Kaiser window.
17+function besselI0(x: number): number {
18+ let sum = 1, term = 1;
19+ for (let k = 1; k < 50; k++) {
20+ term *= (x / (2 * k)) ** 2;
21+ sum += term;
22+ if (term < 1e-17 * sum) break;
23+ }
24+ return sum;
25+}
26+
27+// Filter taps (odd count, symmetric, unit gain at DC).
28+function design(): Float64Array {
29+ const df = (1 - 2 * PASS) / OVERSAMPLE; // transition width, cycles per oversampled sample
30+ const fc = 0.5 / OVERSAMPLE; // cutoff: half the audio rate
31+ const beta = 0.1102 * (ATTEN - 8.7);
32+ let n = Math.ceil((ATTEN - 8) / (2.285 * 2 * Math.PI * df));
33+ if (n % 2 === 0) n++;
34+ const M = (n - 1) / 2, h = new Float64Array(n);
35+ let sum = 0;
36+ for (let i = 0; i < n; i++) {
37+ const k = i - M;
38+ const sinc = k === 0 ? 2 * fc : Math.sin(2 * Math.PI * fc * k) / (Math.PI * k);
39+ h[i] = sinc * (besselI0(beta * Math.sqrt(1 - (k / M) ** 2)) / besselI0(beta));
40+ sum += h[i];
41+ }
42+ for (let i = 0; i < n; i++) h[i] /= sum;
43+ return h;
44+}
45+export const TAPS = design();
46+
47+// Low-pass the oversampled signal x and keep every OVERSAMPLE-th value:
48+// y[n] = sum_k TAPS[k] x[OVERSAMPLE n + M - k], with x zero outside its range.
49+export function decimate(x: Float32Array, n: number): Float32Array {
50+ const M = (TAPS.length - 1) / 2, y = new Float32Array(n);
51+ for (let i = 0; i < n; i++) {
52+ const c = OVERSAMPLE * i;
53+ let acc = 0;
54+ const k0 = Math.max(0, c + M - (x.length - 1)), k1 = Math.min(TAPS.length - 1, c + M);
55+ for (let k = k0; k <= k1; k++) acc += TAPS[k] * x[c + M - k];
56+ y[i] = acc;
57+ }
58+ return y;
59+}
src/common/note-render.tsmodified+20−12View file
@@ -2,7 +2,10 @@
22 // up, sampled as audio. Each audio sample advances the physics by
33 // speedup / sampleRate seconds, in equal substeps no longer than the
44 // system's step for the current energy (re-chosen every DIAG_EVERY samples,
5-// so the steps lengthen as a damped note decays). On request a Lyapunov
5+// so the steps lengthen as a damped note decays). The signal is read
6+// OVERSAMPLE times per audio sample (the substeps per sample are a multiple
7+// of it), then low-passed and decimated (./antialias.ts), so motion above
8+// half the sample rate does not alias. On request a Lyapunov
69 // shadow runs along (LYAP_WINDOW of physical time; it doubles the cost). The
710 // energy and the windowed exponent (NaN without the shadow) are recorded
811 // once per DIAG_EVERY samples. A damped note ends once its energy has fallen
@@ -12,6 +15,7 @@
1215 // Each app supplies its system as a NoteSystem; anything else it records
1316 // (mode energies, section crossings) goes in its step and diag hooks.
1417
18+import { OVERSAMPLE, decimate } from "./antialias";
1519 import { Lyapunov, type Shadow } from "./lyapunov";
1620
1721 export const END_RATIO = 1e-6;
@@ -37,17 +41,18 @@ export interface NoteOptions {
3741 }
3842
3943 export interface Note {
40- samples: Float32Array; // raw signal, in the signal's units
44+ samples: Float32Array; // signal, in the signal's units, anti-alias filtered
4145 energy: Float32Array; // energy (J) at samples 0, DIAG_EVERY, 2 DIAG_EVERY, ...
4246 lambda: Float32Array; // largest Lyapunov exponent over the last LYAP_WINDOW s (1/s), same samples
4347 }
4448
4549 export function renderNote(sys: NoteSystem, o: NoteOptions): Note {
4650 const dtSample = o.speedup / o.sampleRate;
47- let sub = 1, h = dtSample;
51+ let h = dtSample;
4852 const E0 = sys.energy();
4953 const nMax = Math.round((o.damped ? o.maxLength : UNDAMPED_LENGTH) * o.sampleRate);
50- const out = new Float32Array(nMax);
54+ const over = new Float32Array(OVERSAMPLE * nMax); // signal at OVERSAMPLE points per sample
55+ let per = 1; // substeps per oversampled point
5156 const en = new Float32Array(Math.ceil(nMax / DIAG_EVERY));
5257 const lam = new Float32Array(en.length);
5358 const lyap = new Lyapunov(LYAP_WINDOW);
@@ -62,16 +67,19 @@ export function renderNote(sys: NoteSystem, o: NoteOptions): Note {
6267 lam[k] = o.lyapunov ? lyap.windowed() : NaN;
6368 sys.diag?.(k);
6469 if (o.damped && E <= END_RATIO * E0) break;
65- sub = Math.ceil(dtSample / sys.stepFor(E));
66- h = dtSample / sub;
70+ per = Math.ceil(dtSample / (OVERSAMPLE * sys.stepFor(E)));
71+ h = dtSample / (OVERSAMPLE * per);
6772 }
68- out[n] = sys.signal();
69- for (let i = 0; i < sub; i++) {
70- sys.step(h, n);
71- t += h;
72- if (o.lyapunov) lyap.step(h, t);
73+ for (let q = 0; q < OVERSAMPLE; q++) {
74+ over[OVERSAMPLE * n + q] = sys.signal();
75+ for (let i = 0; i < per; i++) {
76+ sys.step(h, n);
77+ t += h;
78+ if (o.lyapunov) lyap.step(h, t);
79+ }
7380 }
7481 }
7582 const nDiag = Math.ceil(n / DIAG_EVERY);
76- return { samples: out.slice(0, n), energy: en.slice(0, nDiag), lambda: lam.slice(0, nDiag) };
83+ const samples = decimate(over.subarray(0, OVERSAMPLE * n), n);
84+ return { samples, energy: en.slice(0, nDiag), lambda: lam.slice(0, nDiag) };
7785 }
src/fput-chain/README.mdmodified+1−1View 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; controls in `../common/ghost.ts`) 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 `../common/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" (`../common/modebars.ts`) 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 of at most 8 series, fewer when they would run into the range text at the right, the rest counted as "+n"): 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`, on the loop shared with the beam app in `../common/note-render.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, each drawn only while at least 6 px from its neighbours, since crowded together their dashes merged into a solid on-off bar; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band), 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 `../common/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`, on the loop shared with the beam app in `../common/note-render.ts`; since 2026-10-09 notes are anti-alias filtered: 4x oversampled, low-passed to 20.2 kHz and decimated, `../common/antialias.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, each drawn only while at least 6 px from its neighbours, since crowded together their dashes merged into a solid on-off bar; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band), 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 `../common/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
↑↓ move↵openescclose