/ listening-for-chaos / double-pendulum-chaos
Sign in
listening-for-chaos / double-pendulum-chaos
Section follows the sound; atlas; pause everything
- Follow: off / pendulum / sound. Sound shows the playing note's section crossings as a trail (synced to the audio clock), axes fitted to the note's energy, over the nearest precomputed atlas section if any - Atlas: sections at 24 energies, computed on request in a worker and cached in IndexedDB per parameter set for 7 days - Pause (and the space bar) pauses the animation and the sound; setting the section's energy pauses instead of turning Follow off
Jeremy Magland <jeremy.magland@gmail.com> committed commit 97e0cbf367a6 parent 6cdb939 Browse files
8 changed files+404−45
README.mdmodified+2−2View file
@@ -2,7 +2,7 @@
22
33 Interactive double pendulum, step 1 of "Listening for chaos". Work in progress.
44
5-Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause freezes the animation. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or Reset, with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s). Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; they clear on each strike. With "Follow" on, the section's energy tracks the pendulum's as it decays (recomputed after a 3% change, at most every 2 s), and optionally color orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel at the bottom starts closed; opening it switches the sound engine on (closing it switches it off). While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "Sound of" (the signal, also used by the animation spectrogram), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
5+Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause (or the space bar) pauses everything: the animation and the sound (the audio clock is suspended, so the note and every display fed by it stop where they are); Resume or Strike continues. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or Reset, with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s). Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything and shows the section at that energy, and Follow takes over again on resume. Follow: "pendulum" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sound" shows the playing note's crossings (sent by the audio worker with the samples, 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 (zooming as it decays) and nothing computed live. Without an atlas only the outline is drawn behind the trail; with one, the atlas section nearest the current energy, fitted to the current allowed region (each point scaled by the ratio of the regions' extents in th2 and p2: exact at low energy, where sections are scaled copies, an approximation over the <~15% gaps between atlas energies). Trail dots are fitted the same way from their own energies. "Precompute" builds the atlas (`src/atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything and shows the section at that energy, and Follow takes over again on resume. Follow: "pendulum" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sound" shows the playing note's crossings (sent by the audio worker with the samples, drawn as the audio clock reaches them) as a fading trail of the last 40, on the precomputed atlas section nearest the note's current energy if an atlas exists, else on the section at the note's strike energy. "Precompute" builds the atlas (`src/atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. Below the lowest atlas energy the motion is in the linear regime, where the section at E is the lowest one scaled by sqrt(E/E0) in both coordinates; it is drawn that way on the lowest energy's axes, so the section and its outline shrink toward the center together with the note's trail, and optionally color orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel at the bottom starts closed; opening it switches the sound engine on (closing it switches it off). While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "Sound of" (the signal, also used by the animation spectrogram), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
66
77 Live site: https://double-pendulum--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
88
@@ -24,4 +24,4 @@ npm run dev
2424
2525 Audio: a Web Worker (`src/audio-worker.ts`) integrates the audio copy with substeps of at most 3.2 ms of simulated time per sample, high-passes at 20 Hz, and streams 1024-sample chunks through a MessageChannel to an AudioWorklet (`src/audio-worklet.ts`) that plays them from a ring buffer, keeping about 0.25 s queued. The page shows how many times faster than real time the worker computes, and any dropouts. The worker also sends each chunk to the page, which feeds it to the audio spectrogram as the audio clock reaches it (2048-sample window, new column every 512 samples). The worker runs the same Lyapunov shadow on the note and sends one exponent and one energy value per 256 samples with each chunk; the page plots them in step with the spectrogram. The whole-note spectrogram keeps every column (pairs are averaged past 4096 columns) and draws only new pixel columns unless the axis or color scale changes.
2626
27-Code: `src/pendulum.ts` (physics), `src/sim.ts` (running simulation with energy target), `src/audio.ts` (audio setup), `src/signals.ts` (audible quantities), `src/lyapunov.ts` (Lyapunov exponent), `src/scatter.ts` (lambda vs energy), `src/cursor.ts` (shared hover/pin cursor), `src/poincare-core.ts`, `src/poincare-worker.ts`, `src/poincare.ts` (Poincare section), `src/audio-shared.ts` (constants shared with the worker), `src/traces.ts` (strip charts), `src/spectrogram.ts` (spectrogram), `src/main.ts` (page).
27+Code: `src/pendulum.ts` (physics), `src/sim.ts` (running simulation with energy target), `src/audio.ts` (audio setup), `src/signals.ts` (audible quantities), `src/lyapunov.ts` (Lyapunov exponent), `src/scatter.ts` (lambda vs energy), `src/cursor.ts` (shared hover/pin cursor), `src/poincare-core.ts`, `src/poincare-worker.ts`, `src/poincare.ts` (Poincare section), `src/atlas.ts` (cached sections for following the sound), `src/audio-shared.ts` (constants shared with the worker), `src/traces.ts` (strip charts), `src/spectrogram.ts` (spectrogram), `src/main.ts` (page).
index.htmlmodified+6−1View file
@@ -205,7 +205,12 @@
205205 <span id="pc-energy-value" class="num">20.0 J</span>
206206 <label class="chip"><input id="pc-lambda" type="checkbox" /><span>Color by λ</span></label>
207207 <label class="chip" title="Clicking a point also strikes the pendulum from that exact state, at this energy"><input id="pc-strike" type="checkbox" checked /><span>Click to strike</span></label>
208- <label class="chip" title="The section's energy follows the pendulum's as it decays"><input id="pc-follow" type="checkbox" /><span>Follow</span></label>
208+ <label title="Pendulum: the section's energy follows the pendulum's as it decays. Sound: the playing note's crossings are shown, on the section at the note's strike energy."><span class="label">Follow</span> <select id="pc-follow">
209+ <option value="off">off</option>
210+ <option value="pendulum">pendulum</option>
211+ <option value="sound">sound</option>
212+ </select></label>
213+ <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sound can show the section matching the decaying note. Kept in this browser for 7 days.">Precompute</button>
209214 <span id="pc-status" class="muted"></span>
210215 </div>
211216 <figure>
src/atlas.tsadded+136−0View file
@@ -0,0 +1,136 @@
1+// Atlas of Poincare sections at a ladder of energies, so the section can
2+// follow a decaying note faster than sections can be computed. Computed on
3+// request in its own worker (highest energy first) and kept in the browser
4+// (IndexedDB) per parameter set, deleted after MAX_AGE. Falls back to memory
5+// only if storage is unavailable.
6+
7+import { topEnergy, type Params } from "./pendulum";
8+import { seedGrid } from "./poincare-core";
9+import type { FromPoincare, ToPoincare } from "./poincare-worker";
10+
11+export const ATLAS_SIZE = 24; // energies
12+const ATLAS_CROSSINGS = 150; // per orbit
13+const GRID = [6, 7];
14+const MAX_AGE = 7 * 24 * 3600 * 1000; // ms
15+const VERSION = "atlas-v1"; // change when the computation changes
16+const DB = "double-pendulum", STORE = "atlas";
17+
18+export interface AtlasOrbit {
19+ points: Float32Array; // th2, p2 pairs
20+ lambda: number;
21+}
22+export interface AtlasEntry {
23+ E: number;
24+ orbits: AtlasOrbit[];
25+}
26+interface Record {
27+ key: string;
28+ created: number;
29+ entries: AtlasEntry[];
30+}
31+
32+// Energies: log-spaced from 0.4% of the both-inverted energy to twice it
33+// (the top of the energy sliders).
34+export function atlasEnergies(P: Params): number[] {
35+ const lo = 0.004 * topEnergy(P), hi = 2 * topEnergy(P);
36+ return Array.from({ length: ATLAS_SIZE }, (_, i) => lo * (hi / lo) ** (i / (ATLAS_SIZE - 1)));
37+}
38+
39+const keyOf = (P: Params) => `${VERSION}|${P.m1}|${P.m2}|${P.l1}|${P.l2}|${P.g}`;
40+
41+function openDb(): Promise<IDBDatabase> {
42+ return new Promise((resolve, reject) => {
43+ const req = indexedDB.open(DB, 1);
44+ req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
45+ req.onsuccess = () => resolve(req.result);
46+ req.onerror = () => reject(req.error);
47+ });
48+}
49+
50+function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
51+ return new Promise((resolve, reject) => {
52+ const req = fn(db.transaction(STORE, mode).objectStore(STORE));
53+ req.onsuccess = () => resolve(req.result);
54+ req.onerror = () => reject(req.error);
55+ });
56+}
57+
58+export class Atlas {
59+ private db: Promise<IDBDatabase | null>;
60+ private memory = new Map<string, Record>();
61+ private worker: Worker | null = null;
62+ private job = 0; // increments to cancel a computation
63+
64+ constructor() {
65+ this.db = openDb()
66+ .then(async (db) => {
67+ // Delete expired atlases.
68+ const all = await tx<Record[]>(db, "readonly", (s) => s.getAll());
69+ for (const r of all) if (Date.now() - r.created > MAX_AGE) await tx(db, "readwrite", (s) => s.delete(r.key));
70+ return db;
71+ })
72+ .catch(() => null);
73+ }
74+
75+ // Cached entries for P (possibly incomplete), sorted by energy.
76+ async load(P: Params): Promise<AtlasEntry[]> {
77+ const key = keyOf(P);
78+ let r = this.memory.get(key);
79+ const db = await this.db;
80+ if (!r && db) r = await tx<Record | undefined>(db, "readonly", (s) => s.get(key)).catch(() => undefined);
81+ if (!r || Date.now() - r.created > MAX_AGE) return [];
82+ this.memory.set(key, r);
83+ return [...r.entries].sort((a, b) => a.E - b.E);
84+ }
85+
86+ cancel(): void {
87+ this.job++;
88+ }
89+
90+ // Compute the missing entries for P, highest energy first, calling onEntry
91+ // after each one is computed and stored.
92+ async compute(P: Params, onEntry: (entries: AtlasEntry[]) => void): Promise<void> {
93+ const job = ++this.job;
94+ const key = keyOf(P);
95+ const have = await this.load(P);
96+ const record: Record = this.memory.get(key) ?? { key, created: Date.now(), entries: have };
97+ this.memory.set(key, record);
98+ const todo = atlasEnergies(P)
99+ .filter((E) => !record.entries.some((e) => Math.abs(e.E - E) < 1e-9 * E))
100+ .sort((a, b) => b - a);
101+ for (const E of todo) {
102+ const entry = await this.section(P, E, job);
103+ if (!entry || job !== this.job) return;
104+ record.entries.push(entry);
105+ const db = await this.db;
106+ if (db) await tx(db, "readwrite", (s) => s.put(record)).catch(() => undefined);
107+ onEntry([...record.entries].sort((a, b) => a.E - b.E));
108+ }
109+ }
110+
111+ // One section at energy E, from the worker (null if cancelled).
112+ private section(P: Params, E: number, job: number): Promise<AtlasEntry | null> {
113+ if (!this.worker) this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
114+ const worker = this.worker;
115+ const seeds = seedGrid(P, E, GRID[0], GRID[1]);
116+ const points: number[][] = seeds.map(() => []);
117+ const lambdas: number[] = seeds.map(() => NaN);
118+ return new Promise((resolve) => {
119+ worker.onmessage = (e: MessageEvent<FromPoincare>) => {
120+ if (job !== this.job) {
121+ worker.postMessage({ type: "stop" } satisfies ToPoincare);
122+ resolve(null);
123+ return;
124+ }
125+ for (const o of e.data.orbits) {
126+ for (const v of o.points) points[o.index].push(v);
127+ lambdas[o.index] = o.lambda;
128+ }
129+ if (e.data.finished) {
130+ resolve({ E, orbits: points.map((p, k) => ({ points: Float32Array.from(p), lambda: lambdas[k] })) });
131+ }
132+ };
133+ worker.postMessage({ type: "run", id: job, P, E, seeds, crossings: ATLAS_CROSSINGS } satisfies ToPoincare);
134+ });
135+ }
136+}
src/audio-worker.tsmodified+21−4View file
@@ -7,7 +7,9 @@
77 // to real time. With damping, a note ends (and computing stops) once its
88 // energy has fallen by END_RATIO (-60 dB in amplitude). The note's largest
99 // Lyapunov exponent (10 s window, physical time) is measured alongside; it and
10-// the energy are sent with the samples, one value per LAMBDA_EVERY samples.
10+// the energy are sent with the samples, one value per LAMBDA_EVERY samples,
11+// together with the note's Poincare section crossings (inner arm down through
12+// vertical moving right) and the sample at which each happened.
1113
1214 import { energy, normalModes, stepSize, topEnergy, type Params } from "./pendulum";
1315 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
@@ -32,7 +34,14 @@ export type ToWorker =
3234 export type FromWorker =
3335 | { type: "stats"; speed: number; underruns: number }
3436 | { type: "ended"; session: number }
35- | { type: "samples"; session: number; data: Float32Array; lambda: Float32Array; energy: Float32Array };
37+ | {
38+ type: "samples";
39+ session: number;
40+ data: Float32Array;
41+ lambda: Float32Array;
42+ energy: Float32Array;
43+ crossings: Float32Array; // sample index in the chunk, th2, p2; repeated
44+ };
3645
3746 let sim: Sim;
3847 let port: MessagePort;
@@ -122,14 +131,21 @@ function pump(): void {
122131 const chunk = new Float32Array(CHUNK);
123132 const lambda = new Float32Array(CHUNK / LAMBDA_EVERY);
124133 const noteEnergy = new Float32Array(CHUNK / LAMBDA_EVERY);
134+ const crossings: number[] = [];
125135 // Simulated time per sample; the speedup follows the current parameters.
126136 const dt = pitchHz / fLower / fs;
127137 const n = Math.ceil(dt / hMax);
128138 const h = dt / n;
129139 for (let i = 0; i < CHUNK; i++) {
130140 for (let k = 0; k < n; k++) {
141+ const a = sim.y[0], b = sim.y[1], q = sim.y[3];
131142 sim.step(h);
132143 lyap.step(sim, h);
144+ if (a < 0 && sim.y[0] >= 0 && sim.y[0] - a < 1) {
145+ const f = -a / (sim.y[0] - a);
146+ const t2 = b + f * Math.atan2(Math.sin(sim.y[1] - b), Math.cos(sim.y[1] - b));
147+ crossings.push(i, Math.atan2(Math.sin(t2), Math.cos(t2)), q + f * (sim.y[3] - q));
148+ }
133149 }
134150 if ((i + 1) % LAMBDA_EVERY === 0) {
135151 lambda[(i + 1) / LAMBDA_EVERY - 1] = lyap.windowed();
@@ -141,8 +157,9 @@ function pump(): void {
141157 chunk[i] = gain * hpY;
142158 }
143159 const copy = chunk.slice();
144- postMessage({ type: "samples", session, data: copy, lambda, energy: noteEnergy } satisfies FromWorker, {
145- transfer: [copy.buffer, lambda.buffer, noteEnergy.buffer],
160+ const cross = Float32Array.from(crossings);
161+ postMessage({ type: "samples", session, data: copy, lambda, energy: noteEnergy, crossings: cross } satisfies FromWorker, {
162+ transfer: [copy.buffer, lambda.buffer, noteEnergy.buffer, cross.buffer],
146163 });
147164 port.postMessage({ session, data: chunk }, [chunk.buffer]);
148165 produced += CHUNK;
src/audio.tsmodified+16−4View file
@@ -17,14 +17,16 @@ type Stats = Extract<FromWorker, { type: "stats" }>;
1717 export interface AudioCallbacks {
1818 onStats: (s: Stats) => void;
1919 // Samples of the current note, with its Lyapunov exponent (1/s, physical)
20- // and energy (J), one value each per LAMBDA_EVERY samples.
21- onSamples: (data: Float32Array, lambda: Float32Array, energy: Float32Array) => void;
20+ // and energy (J), one value each per LAMBDA_EVERY samples, and its section
21+ // crossings (sample index in the chunk, th2, p2; repeated).
22+ onSamples: (data: Float32Array, lambda: Float32Array, energy: Float32Array, crossings: Float32Array) => void;
2223 onEnded: () => void; // the current note decayed and stopped
2324 }
2425
2526 export class AudioOut {
2627 enabled = false; // sound engine on
2728 muted = true;
29+ paused = false; // audio clock suspended (with the animation)
2830 playing = false; // a note is being computed and played
2931 private P: Params;
3032 private alpha: number;
@@ -70,7 +72,7 @@ export class AudioOut {
7072 const msg = e.data;
7173 if (msg.type === "stats") this.cb.onStats(msg);
7274 else if (msg.session !== this.session || !this.playing) return;
73- else if (msg.type === "samples") this.cb.onSamples(msg.data, msg.lambda, msg.energy);
75+ else if (msg.type === "samples") this.cb.onSamples(msg.data, msg.lambda, msg.energy, msg.crossings);
7476 else if (msg.type === "ended") {
7577 this.playing = false;
7678 this.cb.onEnded();
@@ -84,7 +86,7 @@ export class AudioOut {
8486 );
8587 this.ctx = ctx;
8688 }
87- await this.ctx.resume();
89+ if (!this.paused) await this.ctx.resume();
8890 const g = this.gain!.gain, now = this.ctx.currentTime;
8991 g.cancelScheduledValues(now);
9092 g.setValueAtTime(0, now);
@@ -106,6 +108,16 @@ export class AudioOut {
106108 this.enabled = false;
107109 }
108110
111+ // Pause or resume the note by suspending the audio clock: playback, the
112+ // worker (which only stays a buffer ahead), and the displays fed by the
113+ // clock all stop where they are.
114+ setPaused(p: boolean): void {
115+ this.paused = p;
116+ if (!this.ctx || !this.enabled) return;
117+ if (p) this.ctx.suspend();
118+ else this.ctx.resume();
119+ }
120+
109121 // Start a new note from the given state (replacing any current note).
110122 play(y: Float64Array, E0: number, target: number, pitchHz: number, signal: string): void {
111123 if (!this.ctx || !this.enabled) return;
src/main.tsmodified+29−7View file
@@ -32,11 +32,20 @@ const energyValue = document.getElementById("energy-value")!;
3232 let sim = new Sim(P, 0, ALPHA);
3333 let running = false;
3434 const pauseButton = document.getElementById("pause") as HTMLButtonElement;
35+// Pause stops everything: the animation and the sound (see AudioOut.setPaused).
3536 const setRunning = (r: boolean) => {
3637 running = r;
3738 pauseButton.textContent = r ? "Pause" : "Resume";
39+ audio.setPaused(!r);
3840 };
3941 pauseButton.addEventListener("click", () => setRunning(!running));
42+// Space bar: pause / resume, unless typing in or using a control.
43+document.addEventListener("keydown", (e) => {
44+ const t = e.target as HTMLElement;
45+ if (e.key !== " " || pauseButton.disabled || t.closest("input, select, button, textarea, summary")) return;
46+ e.preventDefault();
47+ setRunning(!running);
48+});
4049
4150 // What follows from the parameters.
4251 const modeFreqs = () => normalModes(P).omega.map((w) => w / (2 * Math.PI));
@@ -249,14 +258,15 @@ document.getElementById("scatter-clear")!.addEventListener("click", () => scatte
249258
250259 // Chunks of the current note not yet shown, fed to the displays as the audio
251260 // clock reaches them.
252-const queue: { data: Float32Array; lambda: Float32Array; energy: Float32Array }[] = [];
261+const queue: { data: Float32Array; lambda: Float32Array; energy: Float32Array; crossings: Float32Array; next: number }[] = [];
253262 let queueOffset = 0; // samples of queue[0] already shown
254263 let fed = 0; // samples shown this session
255264
256265 function feedAudioSpec() {
257266 const played = audio.playedSamples();
258267 while (fed < played && queue.length > 0) {
259- const { data, lambda, energy: en } = queue[0];
268+ const chunk = queue[0];
269+ const { data, lambda, energy: en, crossings } = chunk;
260270 const n = Math.min(data.length - queueOffset, Math.ceil(played - fed));
261271 for (let i = 0; i < n; i++) {
262272 const j = queueOffset + i;
@@ -264,11 +274,18 @@ function feedAudioSpec() {
264274 if ((j + 1) % LAMBDA_EVERY === 0) {
265275 noteLambda = lambda[(j + 1) / LAMBDA_EVERY - 1];
266276 noteEnergy = en[(j + 1) / LAMBDA_EVERY - 1];
277+ poincare.setNoteEnergy(noteEnergy);
267278 const t = (fed + i + 1) / audio.sampleRate;
268279 audioTraces.sample(t);
269280 scatter.add(noteEnergy, noteLambda, t);
270281 }
271282 }
283+ // Section crossings reached by the audio clock.
284+ while (chunk.next < crossings.length && crossings[chunk.next] < queueOffset + n) {
285+ const atE = en[Math.min(en.length - 1, Math.floor(crossings[chunk.next] / LAMBDA_EVERY))];
286+ poincare.addNoteDot(crossings[chunk.next + 1], crossings[chunk.next + 2], atE);
287+ chunk.next += 3;
288+ }
272289 queueOffset += n;
273290 fed += n;
274291 if (queueOffset === data.length) {
@@ -287,8 +304,8 @@ const audio = new AudioOut(P, ALPHA, {
287304 (s.speed < 1.5 ? `Too slow for live audio (computed ${s.speed.toFixed(1)}× real time)` : "") +
288305 (s.underruns > 0 ? `${s.speed < 1.5 ? " · " : ""}${s.underruns} dropouts` : "");
289306 },
290- onSamples: (data, lambda, energy) => {
291- queue.push({ data, lambda, energy });
307+ onSamples: (data, lambda, energy, crossings) => {
308+ queue.push({ data, lambda, energy, crossings, next: 0 });
292309 received += data.length;
293310 },
294311 onEnded: () => {
@@ -306,6 +323,7 @@ function startNote() {
306323 audioSpec.start(predictedNoteLength());
307324 setNoteSpan(predictedNoteLength());
308325 scatter.newNote(predictedNoteLength());
326+ poincare.noteStarted(energy(P, sim.y));
309327 audioStatus.textContent = "";
310328 }
311329
@@ -422,7 +440,7 @@ function strikeFrom(y: Float64Array, E: number) {
422440 // Restart everything that follows a strike.
423441 function afterStrike() {
424442 poincare.clearLive();
425- poincare.follow(energy(P, sim.y), true);
443+ if (poincare.followMode() === "pendulum") poincare.follow(energy(P, sim.y), true);
426444 resetGhost();
427445 lyap.start(sim);
428446 setRunning(true);
@@ -467,7 +485,11 @@ document.getElementById("param-form")!.addEventListener("submit", (e) => {
467485 });
468486
469487 // Poincare section panel (own energy; same parameters, no damping).
470-const poincare = new PoincarePanel(P, strikeFrom);
488+// Setting the section's energy by hand pauses everything (Follow resumes
489+// tracking when the simulation resumes).
490+const poincare = new PoincarePanel(P, strikeFrom, () => {
491+ if (running) setRunning(false);
492+});
471493
472494 // Apply the parameters in the form: the pendulum returns to rest (waiting for
473495 // a Strike) and the strike energy keeps its fraction of the both-inverted
@@ -605,7 +627,7 @@ function frame(now: number) {
605627 traces.sample(sim.t);
606628 if (++stepCount % SPEC_DECIM === 0) spectrogram.push(signal.value(P, sim.y));
607629 }
608- if (running) poincare.follow(energy(P, sim.y));
630+ if (running && poincare.followMode() === "pendulum") poincare.follow(energy(P, sim.y));
609631 draw();
610632 traces.draw();
611633 if (!specFig.hidden) spectrogram.draw();
src/poincare-worker.tsmodified+4−2View file
@@ -15,7 +15,7 @@ const STEP_FACTOR = 4;
1515 const MAX_TIME = 2000; // s; an orbit that stops crossing (e.g. inner arm rotating left) ends here
1616
1717 export type ToPoincare =
18- | { type: "run"; id: number; P: Params; E: number; seeds: [number, number][] }
18+ | { type: "run"; id: number; P: Params; E: number; seeds: [number, number][]; crossings?: number }
1919 | { type: "add"; id: number; seed: [number, number] }
2020 | { type: "stop" };
2121
@@ -35,11 +35,13 @@ let orbits: SectionOrbit[] = [];
3535 let sent: number[] = []; // points (pairs) already posted, per orbit
3636 let doneSent: boolean[] = []; // whether the orbit's end has been posted
3737 let running = false;
38+let limit = CROSSINGS; // crossings per orbit in this run
3839
3940 onmessage = (e: MessageEvent<ToPoincare>) => {
4041 const msg = e.data;
4142 if (msg.type === "run") {
4243 runId = msg.id;
44+ limit = msg.crossings ?? CROSSINGS;
4345 P = { ...msg.P, tau: 0 };
4446 E = msg.E;
4547 const key = JSON.stringify([P.m1, P.m2, P.l1, P.l2, P.g]);
@@ -66,7 +68,7 @@ onmessage = (e: MessageEvent<ToPoincare>) => {
6668 }
6769 };
6870
69-const active = (o: SectionOrbit) => o.crossings < CROSSINGS && !o.done;
71+const active = (o: SectionOrbit) => o.crossings < limit && !o.done;
7072
7173 function work(): void {
7274 const t0 = performance.now();
src/poincare.tsmodified+190−25View file
@@ -3,10 +3,15 @@
33 // a grid and computed in a worker, click to add an orbit (or, with "Click to
44 // strike" on, also strike the pendulum above from that exact state), hover to
55 // highlight one, and coloring per orbit or by Lyapunov exponent. The animated
6-// pendulum's own crossings are drawn as dark dots ("you are here"); with
7-// "Follow" on, the section's energy tracks the pendulum's as it decays.
6+// pendulum's own crossings are drawn as dark rings ("you are here"); Follow
7+// "Pendulum" makes the section's energy track the pendulum's as it decays.
8+// Follow "Sound" shows the playing note's crossings instead, as a short
9+// fading trail, with the axes fitted to the note's current energy and, if an
10+// atlas has been computed ("Precompute"), the atlas section nearest that
11+// energy behind it (see showSound).
812
913 import { topEnergy, type Params } from "./pendulum";
14+import { Atlas, ATLAS_SIZE, type AtlasEntry } from "./atlas";
1015 import { p2Max, sectionState, seedGrid, thetaMax } from "./poincare-core";
1116 import type { FromPoincare, ToPoincare } from "./poincare-worker";
1217
@@ -18,6 +23,8 @@ const GRID = [6, 7]; // seed grid: across th2, across p2
1823 const LIVE_MAX = 400; // pendulum crossings kept
1924 const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
2025 const FOLLOW_MIN_MS = 2000; // and at most this often
26+const NOTE_TRAIL = 150; // note crossings shown (each fitted from its own energy)
27+const RESCALE_STEP = 0.02; // sound view: refit after this relative energy change
2128 // Lambda coloring is logarithmic between these (1/s): regular orbits measure
2229 // ~0.01 or less over a run (lambda -> 0 as the run grows), weakly chaotic ones
2330 // ~0.2 to 0.3, strongly chaotic ones ~1 to 1.5.
@@ -49,19 +56,36 @@ export class PoincarePanel {
4956 private status: HTMLElement;
5057 private debounce = 0;
5158 private strikeBox: HTMLInputElement;
52- private followBox: HTMLInputElement;
59+ private followSelect: HTMLSelectElement;
60+ private noteDots: number[] = []; // th2, p2 pairs of the playing note
61+ private atlas = new Atlas();
62+ private atlasEntries: AtlasEntry[] = [];
63+ private soundView = false; // showing the sound view (Follow "Sound" during a note)
64+ private soundE = 0; // energy the sound view is fitted to
65+ private atlasComputing = false;
66+ private atlasButton: HTMLButtonElement;
5367 private lastFollow = 0;
5468 private live: number[] = []; // the pendulum's crossings: th2, p2 pairs
5569 private onStrike: (y: Float64Array, E: number) => void;
70+ private onUserEnergy: () => void;
5671 // Axes (data ranges), set from E.
5772 private thMax = 1;
5873 private pMax = 1;
5974
60- constructor(P: Params, onStrike: (y: Float64Array, E: number) => void) {
75+ // onUserEnergy: the section's energy was set by hand (the page pauses).
76+ constructor(P: Params, onStrike: (y: Float64Array, E: number) => void, onUserEnergy: () => void) {
6177 this.P = P;
6278 this.onStrike = onStrike;
79+ this.onUserEnergy = onUserEnergy;
6380 this.strikeBox = document.getElementById("pc-strike") as HTMLInputElement;
64- this.followBox = document.getElementById("pc-follow") as HTMLInputElement;
81+ this.followSelect = document.getElementById("pc-follow") as HTMLSelectElement;
82+ this.followSelect.addEventListener("change", () => {
83+ // Leaving the sound view: back to the section at the slider's energy.
84+ if (this.followMode() !== "sound" && this.soundView && this.open) this.run();
85+ this.changed = true;
86+ });
87+ this.atlasButton = document.getElementById("pc-atlas") as HTMLButtonElement;
88+ this.atlasButton.addEventListener("click", () => this.computeAtlas());
6589 this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
6690 this.energyValue = document.getElementById("pc-energy-value")!;
6791 this.status = document.getElementById("pc-status")!;
@@ -79,7 +103,7 @@ export class PoincarePanel {
79103 else this.send({ type: "stop" });
80104 });
81105 this.energyInput.addEventListener("input", () => {
82- this.followBox.checked = false; // setting the energy by hand stops following
106+ this.onUserEnergy(); // pauses, so Follow does not override this energy
83107 this.E = Number(this.energyInput.value);
84108 this.showEnergy();
85109 // Recompute shortly after the slider stops moving.
@@ -106,6 +130,12 @@ export class PoincarePanel {
106130 setParams(P: Params): void {
107131 const frac = this.E / topEnergy(this.P);
108132 this.P = P;
133+ this.atlas.cancel();
134+ this.atlasComputing = false;
135+ this.setAtlas([]);
136+ this.atlas.load(P).then((entries) => {
137+ if (this.P === P) this.setAtlas(entries);
138+ });
109139 const eMax = 2 * topEnergy(P);
110140 this.energyInput.max = String(eMax);
111141 this.energyInput.step = String(eMax / 1200);
@@ -127,22 +157,131 @@ export class PoincarePanel {
127157 this.changed = true;
128158 }
129159
130- // The pendulum's current energy: with "Follow" on, recompute the section at
160+ followMode(): string {
161+ return this.followSelect.value;
162+ }
163+
164+ // A note started at energy E: with Follow "Sound", switch to the sound view.
165+ noteStarted(E: number): void {
166+ this.noteDots = [];
167+ this.changed = true;
168+ if (this.followMode() === "sound") this.showSound(E, true);
169+ }
170+
171+ // The playing note's current energy (Follow "Sound").
172+ setNoteEnergy(E: number): void {
173+ if (this.followMode() === "sound" && E > 0) this.showSound(E);
174+ }
175+
176+ private setAtlas(entries: AtlasEntry[]): void {
177+ this.atlasEntries = entries;
178+ this.showAtlasButton();
179+ if (this.soundView) this.showSound(this.soundE, true);
180+ }
181+
182+ private showAtlasButton(): void {
183+ const n = this.atlasEntries.length;
184+ this.atlasButton.disabled = this.atlasComputing || n === ATLAS_SIZE;
185+ this.atlasButton.textContent =
186+ n === ATLAS_SIZE ? "Atlas ready" : this.atlasComputing ? `Atlas ${n}/${ATLAS_SIZE}` : n ? `Precompute (${n}/${ATLAS_SIZE})` : "Precompute";
187+ }
188+
189+ private computeAtlas(): void {
190+ this.atlasComputing = true;
191+ this.showAtlasButton();
192+ const P = this.P;
193+ this.atlas
194+ .compute(P, (entries) => {
195+ if (this.P === P) this.setAtlas(entries);
196+ })
197+ .finally(() => {
198+ if (this.P !== P) return;
199+ this.atlasComputing = false;
200+ this.showAtlasButton();
201+ });
202+ }
203+
204+ // Extent of the allowed region at energy E: largest |th2| and |p2|.
205+ private extent(E: number): [number, number] {
206+ return [thetaMax(this.P, E), p2Max(this.P, E, 0)];
207+ }
208+
209+ // Sound view at the note's energy E (no computation): axes fitted to E's
210+ // allowed region, with the atlas section nearest E (if any) fitted to it,
211+ // each point (th2, p2) of the section at E_k drawn at
212+ // (th2 tmax(E)/tmax(E_k), p2 pmax(E)/pmax(E_k)). In the linear regime (low
213+ // energy) sections at different energies are scaled copies, so this is
214+ // exact; above it, it is an approximation over the gap between atlas
215+ // energies (< ~15%). Without an atlas only the outline is drawn. Refitted
216+ // after a RESCALE_STEP change.
217+ private showSound(E: number, force = false): void {
218+ if (!(E > 0)) return;
219+ if (!force && this.soundView && Math.abs(Math.log(E / this.soundE)) < RESCALE_STEP) return;
220+ if (!this.soundView) {
221+ this.runId++; // ignore the interactive computation's remaining results
222+ this.send({ type: "stop" });
223+ this.soundView = true;
224+ }
225+ this.soundE = E;
226+ const [tm, pm] = this.extent(E);
227+ this.orbits = [];
228+ if (this.atlasEntries.length) {
229+ let k = 0;
230+ for (let i = 1; i < this.atlasEntries.length; i++) {
231+ if (Math.abs(Math.log(this.atlasEntries[i].E / E)) < Math.abs(Math.log(this.atlasEntries[k].E / E))) k = i;
232+ }
233+ const entry = this.atlasEntries[k];
234+ const [tk, pk] = this.extent(entry.E);
235+ const ft = tm / tk, fp = pm / pk;
236+ this.orbits = entry.orbits.map((o) => {
237+ const points = new Array<number>(o.points.length);
238+ for (let i = 0; i < o.points.length; i += 2) {
239+ points[i] = o.points[i] * ft;
240+ points[i + 1] = o.points[i + 1] * fp;
241+ }
242+ return { points, lambda: o.lambda, done: true, clicked: false };
243+ });
244+ }
245+ this.E = E;
246+ this.energyInput.value = String(E);
247+ this.showEnergy();
248+ this.thMax = tm;
249+ this.pMax = pm;
250+ this.finished = true;
251+ this.changed = true;
252+ }
253+
254+ // A crossing of the playing note, as the audio clock reaches it, with the
255+ // note's energy E then (the dot is fitted to the current energy's region
256+ // when drawn, like the section).
257+ addNoteDot(th2: number, p2: number, E: number): void {
258+ this.noteDots.push(th2, p2, E);
259+ if (this.noteDots.length > 3 * NOTE_TRAIL) this.noteDots.splice(0, this.noteDots.length - 3 * NOTE_TRAIL);
260+ if (this.followMode() === "sound") this.changed = true;
261+ }
262+
263+ private setEnergy(E: number): void {
264+ if (!(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
265+ this.E = E;
266+ this.energyInput.value = String(E);
267+ this.showEnergy();
268+ if (this.open) this.run();
269+ }
270+
271+ // The pendulum's current energy: with Follow "Pendulum", recompute the section at
131272 // it once it has changed by FOLLOW_CHANGE (at most every FOLLOW_MIN_MS), or
132273 // at once when `now` is set (a new strike).
133274 follow(E: number, now = false): void {
134- if (!this.followBox.checked || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
275+ if (this.followMode() !== "pendulum" || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
135276 const t = performance.now();
136277 if (!now && (Math.abs(E - this.E) < FOLLOW_CHANGE * this.E || t - this.lastFollow < FOLLOW_MIN_MS)) return;
137278 this.lastFollow = t;
138- this.E = E;
139- this.energyInput.value = String(E);
140- this.showEnergy();
141- if (this.open) this.run();
279+ this.setEnergy(E);
142280 }
143281
144282 private showEnergy(): void {
145- this.energyValue.textContent = `${this.E.toFixed(1)} J`;
283+ const E = this.E;
284+ this.energyValue.textContent = `${E >= 10 ? E.toFixed(1) : E >= 0.01 ? E.toPrecision(2) : E.toExponential(1)} J`;
146285 }
147286
148287 private send(msg: ToPoincare): void {
@@ -155,6 +294,8 @@ export class PoincarePanel {
155294
156295 // Start computing the section at the current energy.
157296 private run(): void {
297+ this.soundView = false;
298+ this.soundE = 0;
158299 this.runId++;
159300 this.orbits = [];
160301 this.finished = false;
@@ -219,8 +360,9 @@ export class PoincarePanel {
219360 const [x, y] = this.mouse(e);
220361 const [th2, p2] = this.fromPx(x, y);
221362 const state = sectionState(this.P, this.E, th2, p2);
222- if (!this.worker || !state) return;
363+ if (!state) return;
223364 if (this.strikeBox.checked) this.onStrike(state, this.E);
365+ if (!this.worker || this.soundView) return; // the sound view is not computed here
224366 this.orbits.push({ points: [], lambda: NaN, done: false, clicked: true });
225367 this.finished = false;
226368 this.send({ type: "add", id: this.runId, seed: [th2, p2] });
@@ -261,9 +403,10 @@ export class PoincarePanel {
261403 ctx.stroke();
262404 ctx.strokeStyle = "#b9b8b0";
263405 ctx.beginPath();
406+ const tm = thetaMax(this.P, this.E); // the outline at this energy (may be inside the axes)
264407 for (const sign of [1, -1]) {
265408 for (let i = 0; i <= 200; i++) {
266- const th2 = -this.thMax + (2 * this.thMax * i) / 200;
409+ const th2 = -tm + (2 * tm * i) / 200;
267410 const x = this.px(th2), y = this.py(sign * p2Max(this.P, this.E, th2));
268411 if (i === 0) ctx.moveTo(x, y);
269412 else ctx.lineTo(x, y);
@@ -284,16 +427,38 @@ export class PoincarePanel {
284427 }
285428 }
286429
287- // The pendulum's own crossings: dark rings on white, the latest larger.
288- ctx.strokeStyle = "#23262b";
289- ctx.fillStyle = "#fff";
290- for (let i = 0; i < this.live.length; i += 2) {
291- const latest = i === this.live.length - 2;
292- ctx.beginPath();
293- ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
294- ctx.fill();
295- ctx.lineWidth = latest ? 2.5 : 1.5;
296- ctx.stroke();
430+ if (this.followMode() === "sound") {
431+ // The playing note's latest crossings: a fading trail, the newest larger.
432+ const n = this.noteDots.length / 3;
433+ for (let k = 0; k < n; k++) {
434+ const latest = k === n - 1;
435+ const [tk, pk] = this.extent(this.noteDots[3 * k + 2]);
436+ ctx.globalAlpha = 0.2 + (0.8 * (k + 1)) / n;
437+ ctx.beginPath();
438+ ctx.arc(
439+ this.px((this.noteDots[3 * k] * this.thMax) / tk),
440+ this.py((this.noteDots[3 * k + 1] * this.pMax) / pk),
441+ latest ? 6 : 3.5, 0, 2 * Math.PI,
442+ );
443+ ctx.fillStyle = "#fff";
444+ ctx.fill();
445+ ctx.strokeStyle = "#c2412d";
446+ ctx.lineWidth = latest ? 2.5 : 1.5;
447+ ctx.stroke();
448+ }
449+ ctx.globalAlpha = 1;
450+ } else {
451+ // The pendulum's own crossings: dark rings on white, the latest larger.
452+ ctx.strokeStyle = "#23262b";
453+ ctx.fillStyle = "#fff";
454+ for (let i = 0; i < this.live.length; i += 2) {
455+ const latest = i === this.live.length - 2;
456+ ctx.beginPath();
457+ ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
458+ ctx.fill();
459+ ctx.lineWidth = latest ? 2.5 : 1.5;
460+ ctx.stroke();
461+ }
297462 }
298463
299464 // Legend row.
↑↓ move↵openescclose