/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Mode-energy history (sped-up Modes chip, real-time Mode history), 10x and 20x speeds, Save WAV
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 1a2f9299b90d parent cb30402 Browse files
7 changed files+302−13
src/fput-chain/README.mdmodified+2−2View file
@@ -2,9 +2,9 @@
22
33 Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-chaos). Work in progress.
44
5-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 5x 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 (`src/lyapunov.ts`, two-trajectory Benettin method as in the pendulum app: 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 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 `src/traces.ts` and `src/cursor.ts` of double-pendulum-chaos, 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).
5+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 (`src/lyapunov.ts`, two-trajectory Benettin method as in the pendulum app: 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 `src/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 `src/traces.ts` and `src/cursor.ts` of double-pendulum-chaos, 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 (`src/render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (30 to 1000 Hz, default 100; 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 (`src/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 (`src/sound.ts`). Chips (as in the pendulum app): Spectrogram (on; pendulum's `NoteSpectrogram`: 2048-sample Hann, zero-pad 4096, hop 512; 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), 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 (`src/scatter.ts` from the pendulum app: 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). 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 (`src/render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (30 to 1000 Hz, default 100; 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 (`src/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 (`src/sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `src/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; pendulum's `NoteSpectrogram`: 2048-sample Hann, zero-pad 4096, hop 512; 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 (on; 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 `src/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 (`src/scatter.ts` from the pendulum app: 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). 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 (`src/poincare-core.ts`, `src/poincare-worker.ts`, `src/poincare.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 (`src/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
src/fput-chain/index.htmlmodified+9−2View file
@@ -70,7 +70,7 @@
7070 #sound-mass { width: 3.5em; }
7171 #sound-chips { margin-left: auto; }
7272 #sound-hint { margin: 14px 0 0; }
73- #spectrogram canvas { width: 100%; height: auto; border-radius: 4px; }
73+ #spectrogram canvas, #mode-history canvas, #live-modes canvas { width: 100%; height: auto; border-radius: 4px; }
7474 #sound-traces canvas { height: auto; margin-top: 12px; }
7575 #scatter canvas, #pc-plot canvas { width: 100%; height: auto; }
7676 #pc-energy { max-width: 360px; }
@@ -153,6 +153,8 @@
153153 <option value="1" selected>1× (real time)</option>
154154 <option value="2">2×</option>
155155 <option value="5">5×</option>
156+ <option value="10">10×</option>
157+ <option value="20">20×</option>
156158 </select>
157159 </div>
158160 </div>
@@ -186,12 +188,14 @@
186188 <div class="card-head">Real time <button type="button" class="help" data-help="realtime" title="About the real-time charts" aria-label="About the real-time charts">?</button></div>
187189 <div class="chips">
188190 <label class="chip"><input id="bars-toggle" type="checkbox" checked /><span>Mode bars</span></label>
191+ <label class="chip" title="Each mode's share of the energy over the last 300 s"><input id="live-modes-toggle" type="checkbox" /><span>Mode history</span></label>
189192 <span id="toggles"></span>
190193 </div>
191194 <figure id="bars">
192195 <canvas id="modes"></canvas>
193196 <div id="mode-stats" class="num muted"></div>
194197 </figure>
198+ <figure id="live-modes-fig" hidden><div id="live-modes"></div></figure>
195199 <div id="traces"></div>
196200 </section>
197201 </div>
@@ -203,8 +207,10 @@
203207 <input id="sound-mass" type="number" min="1" step="1" value="1" aria-label="Mass" />
204208 <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
205209 <button id="replay" type="button" disabled title="Play the last strike again with the current settings">Replay</button>
210+ <button id="save-wav" type="button" disabled title="Save the last note as a WAV file">Save WAV</button>
206211 <span id="sound-chips" class="chips">
207212 <label class="chip"><input id="spec-toggle" type="checkbox" checked /><span>Spectrogram</span></label>
213+ <label class="chip" title="Each mode's share of the energy over the note"><input id="modes-toggle" type="checkbox" checked /><span>Modes</span></label>
208214 </span>
209215 </div>
210216 <div class="row">
@@ -214,6 +220,7 @@
214220 <div class="row"><span id="sound-status" class="muted"></span></div>
215221 <p id="sound-hint" class="muted">Strike to hear the whole decay.</p>
216222 <figure id="spec-fig"><div id="spectrogram"></div></figure>
223+ <figure id="modes-fig"><div id="mode-history"></div></figure>
217224 <div id="sound-traces"></div>
218225 <figure id="scatter-fig" hidden>
219226 <div id="scatter"></div>
@@ -263,7 +270,7 @@
263270 <template id="help-sound">
264271 <h2>Sped up · listen</h2>
265272 <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>
266- <p>Signal chooses what is heard. Chaotic motion sounds noisy and settles into steady tones as the energy decays. The charts span the whole note: spectrogram, Lyapunov exponent, energy, and λ against energy collected over notes.</p>
273+ <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard. Chaotic motion sounds noisy and settles into steady tones as the energy decays. The charts span the whole note: spectrogram, Lyapunov exponent, energy, and λ against energy collected over notes.</p>
267274 </template>
268275 <template id="help-section">
269276 <h2>Poincaré section</h2>
src/fput-chain/main.tsmodified+53−5View file
@@ -4,6 +4,7 @@ import {
44 import { newCursor } from "./cursor";
55 import { setupHelp } from "./help";
66 import { Lyapunov } from "./lyapunov";
7+import { LiveModeHistory, ModeHistory } from "./modehistory";
78 import { PoincarePanel } from "./poincare";
89 import { sectionDef } from "./poincare-core";
910 import { END_RATIO, DIAG_EVERY, UNDAMPED_LENGTH, type RenderRequest, type RenderResult } from "./render-worker";
@@ -12,6 +13,7 @@ import { SIGNALS, signalByKey } from "./signals";
1213 import { Player } from "./sound";
1314 import { NoteSpectrogram } from "./spectrogram";
1415 import { TracePanel, type Group } from "./traces";
16+import { saveFile, wavBlob } from "./wav";
1517
1618 setupHelp();
1719
@@ -86,12 +88,19 @@ const strikeIndex = () => Math.min(P.N, Math.max(1, Math.round(Number(indexInput
8688 document.getElementById("strike")!.addEventListener("click", () => {
8789 const E = strikeEnergy();
8890 indexInput.value = String(strikeIndex());
89- strikeFrom(kindSelect.value === "mode" ? strikeMode(P, modes, strikeIndex(), E) : strikeMass(P, strikeIndex(), E));
91+ const mode = kindSelect.value === "mode";
92+ strikeFrom(
93+ mode ? strikeMode(P, modes, strikeIndex(), E) : strikeMass(P, strikeIndex(), E),
94+ `${mode ? "mode" : "mass"}${strikeIndex()}`,
95+ );
9096 });
9197
9298 // Start the chain (and, with the sound panel open, a note) from a state: a
9399 // strike, or a point clicked on the Poincare section.
94-function strikeFrom(state: State) {
100+// label names the strike in saved files.
101+let strikeLabel = "";
102+function strikeFrom(state: State, label: string) {
103+ strikeLabel = label;
95104 s = state;
96105 lastStrike = copyState(s);
97106 verlet = new Verlet(P, s);
@@ -107,6 +116,8 @@ function strikeFrom(state: State) {
107116 traces.clear();
108117 modes.energies(s, Ek);
109118 traces.sample(t);
119+ liveModes.clear(P.N);
120+ liveModes.sample(t, Ek);
110121 poincare?.clearLive();
111122 poincare?.follow(E0, true);
112123 pauseButton.disabled = false;
@@ -195,6 +206,14 @@ function groups(shown: string[]): Group[] {
195206 for (const x of g) x.shown = shown.includes(x.label);
196207 return g;
197208 }
209+// Mode history (chip after Mode bars): each mode's share of the energy over
210+// the last 300 s, as a heat map; the cascade of energy up the modes (which
211+// takes ~10 to 50 periods of mode 1) shows here.
212+const liveModes = new LiveModeHistory(300);
213+document.getElementById("live-modes")!.appendChild(liveModes.canvas);
214+const liveModesFig = document.getElementById("live-modes-fig")!;
215+const liveModesToggle = document.getElementById("live-modes-toggle") as HTMLInputElement;
216+liveModesToggle.addEventListener("change", () => (liveModesFig.hidden = !liveModesToggle.checked));
198217 const togglesEl = document.getElementById("toggles")!;
199218 const chartsEl = document.getElementById("traces")!;
200219 let traces: TracePanel;
@@ -386,8 +405,15 @@ document.getElementById("scatter-clear")!.addEventListener("click", () => scatte
386405 // Its section crossings go to the Poincare panel as the audio clock reaches
387406 // them (next: index of the next crossing triple).
388407 let note: {
389- x: Float32Array; energy: Float32Array; lambda: Float32Array; crossings: Float32Array; next: number; rate: number;
408+ x: Float32Array; energy: Float32Array; lambda: Float32Array; modeEnergy: Float32Array; N: number;
409+ crossings: Float32Array; next: number; rate: number;
390410 } | null = null;
411+// Mode-energy history of the note (chip after Spectrogram).
412+const modeHistory = new ModeHistory(960, soundCursor);
413+document.getElementById("mode-history")!.appendChild(modeHistory.canvas);
414+const modesFig = document.getElementById("modes-fig")!;
415+const modesToggle = document.getElementById("modes-toggle") as HTMLInputElement;
416+modesToggle.addEventListener("change", () => (modesFig.hidden = !modesToggle.checked));
391417 let fed = 0;
392418 function feedDisplays(upTo: number) {
393419 if (!note || !spec) return;
@@ -400,6 +426,8 @@ function feedDisplays(upTo: number) {
400426 noteLambda = note.lambda[fed / DIAG_EVERY];
401427 soundTraces.sample(t);
402428 scatter.add(noteEnergy, noteLambda, t);
429+ const k = fed / DIAG_EVERY;
430+ modeHistory.push(t + DIAG_EVERY / note.rate, note.modeEnergy.subarray(k * note.N, (k + 1) * note.N));
403431 poincare?.setNoteEnergy(noteEnergy);
404432 }
405433 const c = note.crossings;
@@ -414,6 +442,12 @@ function feedDisplays(upTo: number) {
414442 const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" });
415443 let requestId = 0;
416444 let noteInfo = "";
445+let noteFile = ""; // name for Save WAV
446+const saveButton = document.getElementById("save-wav") as HTMLButtonElement;
447+// Save WAV: the last rendered note as heard (16-bit mono, peak at 0.9).
448+saveButton.addEventListener("click", () => {
449+ if (note) saveFile(wavBlob(note.x, note.rate), noteFile);
450+});
417451 function renderNote() {
418452 if (!lastStrike || !soundPanel.open) return;
419453 const ctx = player.ensure();
@@ -426,6 +460,9 @@ function renderNote() {
426460 maxLength: 2 * predictedNoteLength(), signal: sig.key, j: j - 1,
427461 };
428462 noteInfo = `${sig.label}${sig.perMass ? ` ${j}` : ""}`;
463+ noteFile =
464+ `fput-N${P.N}-${strikeLabel}-${energy(P, lastStrike).toPrecision(3)}J-` +
465+ `${sig.key}${sig.perMass ? j : ""}-${pitch().toFixed(0)}Hz.wav`;
429466 soundStatus.textContent = "computing…";
430467 soundHint.hidden = true;
431468 player.stop();
@@ -436,7 +473,10 @@ worker.onmessage = (e: MessageEvent<RenderResult>) => {
436473 const rate = player.sampleRate;
437474 const x = e.data.samples;
438475 Player.normalize(x, rate);
439- note = { x, energy: e.data.energy, lambda: e.data.lambda, crossings: e.data.crossings, next: 0, rate };
476+ note = {
477+ x, energy: e.data.energy, lambda: e.data.lambda, modeEnergy: e.data.modeEnergy, N: P.N,
478+ crossings: e.data.crossings, next: 0, rate,
479+ };
440480 poincare?.noteStarted(e.data.energy[0]);
441481 fed = 0;
442482 const span = x.length / rate;
@@ -445,12 +485,14 @@ worker.onmessage = (e: MessageEvent<RenderResult>) => {
445485 soundCursor,
446486 );
447487 spec.start(span);
488+ modeHistory.start(P.N, span);
448489 document.getElementById("spectrogram")!.replaceChildren(spec.canvas);
449490 soundTraces.clear();
450491 soundTraces.setFixed({ t0: 0, span });
451492 scatter.newNote(span);
452493 soundStatus.textContent = `${noteInfo} · ${fmtDuration(span)}`;
453494 player.play(x);
495+ saveButton.disabled = false;
454496 player.setPaused(!running);
455497 };
456498 replayButton.addEventListener("click", renderNote);
@@ -477,11 +519,14 @@ function setParams(next: Params) {
477519 pauseButton.textContent = "Pause";
478520 lastStrike = null;
479521 replayButton.disabled = true;
522+ saveButton.disabled = true;
523+ note = null;
480524 player.stop();
481525 soundMass.max = String(P.N);
482526 soundMass.value = String(Math.min(P.N, Number(soundMass.value) || 1));
483527 showPitch();
484528 buildTraces(shown);
529+ liveModes.clear(P.N);
485530 poincare?.setParams(P);
486531 showParams();
487532 }
@@ -491,7 +536,7 @@ let poincare: PoincarePanel | null = null;
491536 setParams(P);
492537 poincare = new PoincarePanel(
493538 P,
494- (state) => strikeFrom(state),
539+ (state) => strikeFrom(state, "section"),
495540 () => {
496541 if (running) setRunning(false); // a hand-set section energy pauses everything
497542 },
@@ -707,6 +752,7 @@ function frame(now: number) {
707752 sinceSample -= SAMPLE_DT;
708753 modes.energies(s, Ek);
709754 traces.sample(t);
755+ liveModes.sample(t, Ek);
710756 }
711757 }
712758 }
@@ -719,6 +765,7 @@ function frame(now: number) {
719765 showSeparation();
720766 if (!barsFig.hidden) drawModes();
721767 traces.draw();
768+ if (!liveModesFig.hidden) liveModes.draw();
722769 if (note && soundPanel.open) {
723770 // Feed the displays up to the audio clock; playhead unless pinned.
724771 const at = player.position();
@@ -727,6 +774,7 @@ function frame(now: number) {
727774 if (!soundCursor.pinned && (at !== null || playing)) soundCursor.t = at;
728775 playing = at !== null;
729776 if (!specFig.hidden) spec?.draw();
777+ if (!modesFig.hidden) modeHistory.draw();
730778 soundTraces.draw();
731779 if (!scatterFig.hidden) scatter.draw();
732780 }
src/fput-chain/modehistory.tsadded+189−0View file
@@ -0,0 +1,189 @@
1+// Mode-energy history of one whole note: a heat map with time across (strike
2+// at the left, end of the note at the right, like the spectrogram) and mode
3+// number up (mode 1 at the bottom). Each cell's color is the mode's share of
4+// the harmonic energy at that moment, log10(E_n / sum E), over DECADES
5+// decades, so the picture does not fade as the note decays. Columns arrive
6+// as the audio clock reaches them. An optional shared time cursor is drawn.
7+
8+import { attachCursor, type Cursor } from "./cursor";
9+import { colormap } from "./spectrogram";
10+
11+const HEIGHT = 160;
12+const DECADES = 6;
13+
14+export class ModeHistory {
15+ canvas: HTMLCanvasElement;
16+ private cursor: Cursor | null;
17+ private width: number;
18+ private N = 1;
19+ private span = 1; // s across the width
20+ private drawnX = 0; // pixel columns filled
21+ private image: ImageData;
22+ private dirty = true;
23+
24+ constructor(width = 960, cursor: Cursor | null = null) {
25+ this.width = width;
26+ this.cursor = cursor;
27+ this.canvas = document.createElement("canvas");
28+ this.canvas.width = width;
29+ this.canvas.height = HEIGHT;
30+ this.image = this.canvas.getContext("2d")!.createImageData(width, HEIGHT);
31+ if (cursor) attachCursor(this.canvas, cursor, (f) => f * this.span);
32+ this.start(1, 1);
33+ }
34+
35+ // Start a new note with N modes, shown over `span` seconds.
36+ start(N: number, span: number): void {
37+ this.N = N;
38+ this.span = span;
39+ this.drawnX = 0;
40+ const d = this.image.data;
41+ for (let p = 0; p < d.length; p += 4) {
42+ d[p] = d[p + 1] = d[p + 2] = 246; // no data (yet)
43+ d[p + 3] = 255;
44+ }
45+ this.dirty = true;
46+ }
47+
48+ // Mode energies (J) at time t (s into the note), valid until the next push.
49+ push(t: number, E: Float32Array | Float64Array): void {
50+ const x1 = Math.min(this.width, Math.ceil((t / this.span) * this.width));
51+ if (x1 <= this.drawnX) return;
52+ let sum = 0;
53+ for (let n = 0; n < this.N; n++) sum += E[n];
54+ const d = this.image.data;
55+ for (let y = 0; y < HEIGHT; y++) {
56+ const n = Math.min(this.N - 1, Math.floor(((HEIGHT - 1 - y) / HEIGHT) * this.N)); // mode index, 1 at the bottom
57+ const lg = sum > 0 ? Math.log10(E[n] / sum) : -Infinity;
58+ const u = 1 + lg / DECADES;
59+ for (let x = this.drawnX; x < x1; x++) {
60+ const p = 4 * (y * this.width + x);
61+ colormap(u, d, p);
62+ d[p + 3] = 255;
63+ }
64+ }
65+ this.drawnX = x1;
66+ this.dirty = true;
67+ }
68+
69+ private lastCursor = "";
70+
71+ draw(): void {
72+ const key = this.cursor ? `${this.cursor.t}|${this.cursor.pinned}` : "";
73+ if (!this.dirty && key === this.lastCursor) return;
74+ this.dirty = false;
75+ this.lastCursor = key;
76+ const ctx = this.canvas.getContext("2d")!;
77+ ctx.putImageData(this.image, 0, 0);
78+ ctx.font = "12px ui-monospace, monospace";
79+ const label = (text: string, x: number, y: number) => {
80+ ctx.fillStyle = "rgba(0,0,0,0.6)";
81+ ctx.fillRect(x, y, ctx.measureText(text).width + 6, 16);
82+ ctx.fillStyle = "#fff";
83+ ctx.textBaseline = "top";
84+ ctx.fillText(text, x + 3, y + 2);
85+ };
86+ label(`mode ${this.N}`, 2, 2);
87+ label("mode 1", 2, HEIGHT - 18);
88+ const scale = `share of energy, 1e-${DECADES} … 1`;
89+ label(scale, this.width - ctx.measureText(scale).width - 8, HEIGHT - 18);
90+ const ct = this.cursor?.t ?? null;
91+ if (ct !== null && ct >= 0 && ct <= this.span) {
92+ const cx = (ct / this.span) * this.width;
93+ ctx.strokeStyle = this.cursor!.pinned ? "#fff" : "rgba(255,255,255,0.6)";
94+ ctx.lineWidth = 1;
95+ ctx.beginPath();
96+ ctx.moveTo(cx, 0);
97+ ctx.lineTo(cx, HEIGHT);
98+ ctx.stroke();
99+ }
100+ }
101+}
102+
103+// The same picture for the animated chain, scrolling: the last `window`
104+// seconds, newest at the right (each mode's share of the harmonic energy;
105+// samples closer than window / LIVE_CAP / 2 to the previous kept one are
106+// skipped, so the ring always spans the window).
107+const LIVE_CAP = 8192; // samples kept (ring)
108+
109+export class LiveModeHistory {
110+ canvas: HTMLCanvasElement;
111+ private width: number;
112+ private height: number;
113+ private window: number;
114+ private N = 1;
115+ private t = new Float64Array(LIVE_CAP);
116+ private share: Float32Array = new Float32Array(LIVE_CAP); // log10 shares, N per sample
117+ private head = 0;
118+ private count = 0;
119+ private image: ImageData;
120+
121+ constructor(window: number, width = 480, height = 120) {
122+ this.window = window;
123+ this.width = width;
124+ this.height = height;
125+ this.canvas = document.createElement("canvas");
126+ this.canvas.width = width;
127+ this.canvas.height = height;
128+ this.image = this.canvas.getContext("2d")!.createImageData(width, height);
129+ }
130+
131+ clear(N: number): void {
132+ if (N !== this.N || this.share.length !== LIVE_CAP * N) this.share = new Float32Array(LIVE_CAP * N);
133+ this.N = N;
134+ this.head = this.count = 0;
135+ }
136+
137+ // Mode energies (J) at time t (s).
138+ sample(t: number, E: Float64Array): void {
139+ if (this.count && t - this.t[(this.head - 1 + LIVE_CAP) % LIVE_CAP] < this.window / LIVE_CAP / 2) return;
140+ let sum = 0;
141+ for (let n = 0; n < this.N; n++) sum += E[n];
142+ const o = this.head * this.N;
143+ for (let n = 0; n < this.N; n++) this.share[o + n] = sum > 0 ? Math.log10(E[n] / sum) : -Infinity;
144+ this.t[this.head] = t;
145+ this.head = (this.head + 1) % LIVE_CAP;
146+ this.count = Math.min(this.count + 1, LIVE_CAP);
147+ }
148+
149+ draw(): void {
150+ const { width: W, height: Hh, N } = this;
151+ const d = this.image.data;
152+ const newest = (this.head - 1 + LIVE_CAP) % LIVE_CAP;
153+ const tEnd = this.count ? this.t[newest] : 0, tStart = tEnd - this.window;
154+ // Walk the samples from oldest visible to newest; each pixel column takes
155+ // the latest sample at or before its right edge.
156+ let k = 0; // samples back from the newest, decreasing as x grows
157+ while (k < this.count - 1 && this.t[(newest - k - 1 + LIVE_CAP) % LIVE_CAP] >= tStart) k++;
158+ for (let x = 0; x < W; x++) {
159+ const tx = tStart + ((x + 1) / W) * this.window;
160+ while (k > 0 && this.t[(newest - k + 1 + LIVE_CAP) % LIVE_CAP] <= tx) k--;
161+ const i = (newest - k + LIVE_CAP) % LIVE_CAP;
162+ const has = this.count > 0 && this.t[i] <= tx && this.t[i] >= tStart - 1e-9;
163+ for (let y = 0; y < Hh; y++) {
164+ const p = 4 * (y * W + x);
165+ if (!has) {
166+ d[p] = d[p + 1] = d[p + 2] = 246;
167+ } else {
168+ const n = Math.min(N - 1, Math.floor(((Hh - 1 - y) / Hh) * N));
169+ colormap(1 + this.share[i * N + n] / DECADES, d, p);
170+ }
171+ d[p + 3] = 255;
172+ }
173+ }
174+ const ctx = this.canvas.getContext("2d")!;
175+ ctx.putImageData(this.image, 0, 0);
176+ ctx.font = "11px ui-monospace, monospace";
177+ const label = (text: string, x: number, y: number) => {
178+ ctx.fillStyle = "rgba(0,0,0,0.6)";
179+ ctx.fillRect(x, y, ctx.measureText(text).width + 6, 15);
180+ ctx.fillStyle = "#fff";
181+ ctx.textBaseline = "top";
182+ ctx.fillText(text, x + 3, y + 2);
183+ };
184+ label(`mode ${N}`, 2, 2);
185+ label("mode 1", 2, Hh - 17);
186+ const span = `last ${this.window} s`;
187+ label(span, W - ctx.measureText(span).width - 8, Hh - 17);
188+ }
189+}
src/fput-chain/render-worker.tsmodified+10−3View file
@@ -8,7 +8,7 @@
88 // once its energy has fallen to END_RATIO of the start (-60 dB); an undamped
99 // one after UNDAMPED_LENGTH seconds.
1010
11-import { Verlet, copyState, energy, stepFor, type Params, type State } from "./chain";
11+import { Modes, Verlet, copyState, energy, stepFor, type Params, type State } from "./chain";
1212 import { Lyapunov } from "./lyapunov";
1313 import { sectionDef } from "./poincare-core";
1414 import { signalByKey } from "./signals";
@@ -34,6 +34,7 @@ export interface RenderResult {
3434 samples: Float32Array; // raw signal, in the signal's units
3535 energy: Float32Array; // chain energy (J) at samples 0, DIAG_EVERY, 2 DIAG_EVERY, ...
3636 lambda: Float32Array; // largest Lyapunov exponent over the last LYAP_WINDOW s (1/s), same samples
37+ modeEnergy: Float32Array; // harmonic energy of each mode (J), N values per diagnostic sample
3738 // Poincare section crossings (N = 2 and 3; for N = 3 only while exactly
3839 // symmetric): sample index, x, q; repeated.
3940 crossings: Float32Array;
@@ -51,6 +52,9 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
5152 let out = new Float32Array(nMax);
5253 const en = new Float32Array(Math.ceil(nMax / DIAG_EVERY));
5354 const lam = new Float32Array(en.length);
55+ const modes = new Modes(r.P);
56+ const Ek = new Float64Array(r.P.N);
57+ const modeE = new Float32Array(en.length * r.P.N);
5458 const lyap = new Lyapunov(LYAP_WINDOW);
5559 let t = 0; // physical time (s)
5660 lyap.start(r.P, s, t);
@@ -62,6 +66,8 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
6266 const E = energy(r.P, s);
6367 en[n / DIAG_EVERY] = E;
6468 lam[n / DIAG_EVERY] = lyap.windowed();
69+ modes.energies(s, Ek);
70+ modeE.set(Ek, (n / DIAG_EVERY) * r.P.N);
6571 if (r.P.tau > 0 && E <= END_RATIO * E0) break;
6672 sub = Math.ceil(dtSample / stepFor(r.P, E));
6773 h = dtSample / sub;
@@ -81,9 +87,10 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
8187 out = out.slice(0, n);
8288 const nDiag = Math.ceil(n / DIAG_EVERY);
8389 const result: RenderResult = {
84- id: r.id, samples: out, energy: en.slice(0, nDiag), lambda: lam.slice(0, nDiag), crossings: Float32Array.from(crossings),
90+ id: r.id, samples: out, energy: en.slice(0, nDiag), lambda: lam.slice(0, nDiag),
91+ modeEnergy: modeE.slice(0, nDiag * r.P.N), crossings: Float32Array.from(crossings),
8592 };
8693 (self as unknown as Worker).postMessage(result, [
87- result.samples.buffer, result.energy.buffer, result.lambda.buffer, result.crossings.buffer,
94+ result.samples.buffer, result.energy.buffer, result.lambda.buffer, result.modeEnergy.buffer, result.crossings.buffer,
8895 ]);
8996 };
src/fput-chain/spectrogram.tsmodified+1−1View file
@@ -42,7 +42,7 @@ const STOPS = [
4242 [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99],
4343 [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
4444 ];
45-function colormap(u: number, out: Uint8ClampedArray, p: number): void {
45+export function colormap(u: number, out: Uint8ClampedArray, p: number): void {
4646 const s = Math.max(0, Math.min(1, u)) * (STOPS.length - 1);
4747 const i = Math.min(STOPS.length - 2, Math.floor(s)), w = s - i;
4848 for (let ch = 0; ch < 3; ch++) out[p + ch] = STOPS[i][ch] * (1 - w) + STOPS[i + 1][ch] * w;
src/fput-chain/wav.tsadded+38−0View file
@@ -0,0 +1,38 @@
1+// WAV export: a note as a 16-bit PCM mono WAV file, scaled so its peak is at
2+// 0.9 of full scale (so every saved note is equally loud and none clips).
3+
4+export function wavBlob(x: Float32Array, sampleRate: number): Blob {
5+ let peak = 0;
6+ for (const v of x) peak = Math.max(peak, Math.abs(v));
7+ const g = peak > 0 ? 0.9 / peak : 0;
8+ const buf = new ArrayBuffer(44 + 2 * x.length);
9+ const v = new DataView(buf);
10+ const text = (o: number, s: string) => {
11+ for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
12+ };
13+ text(0, "RIFF");
14+ v.setUint32(4, 36 + 2 * x.length, true);
15+ text(8, "WAVE");
16+ text(12, "fmt ");
17+ v.setUint32(16, 16, true); // fmt chunk size
18+ v.setUint16(20, 1, true); // PCM
19+ v.setUint16(22, 1, true); // mono
20+ v.setUint32(24, sampleRate, true);
21+ v.setUint32(28, 2 * sampleRate, true); // bytes per second
22+ v.setUint16(32, 2, true); // bytes per frame
23+ v.setUint16(34, 16, true); // bits per sample
24+ text(36, "data");
25+ v.setUint32(40, 2 * x.length, true);
26+ for (let i = 0; i < x.length; i++) v.setInt16(44 + 2 * i, Math.round(32767 * Math.max(-1, Math.min(1, g * x[i]))), true);
27+ return new Blob([buf], { type: "audio/wav" });
28+}
29+
30+// Hand a file to the browser to save.
31+export function saveFile(blob: Blob, name: string): void {
32+ const url = URL.createObjectURL(blob);
33+ const a = document.createElement("a");
34+ a.href = url;
35+ a.download = name;
36+ a.click();
37+ setTimeout(() => URL.revokeObjectURL(url), 1000);
38+}
↑↓ move↵openescclose