Label the two clocks; section rings only at matching energy; Sound opens without a note
4 changed files+43−27
src/double-pendulum/README.mdmodified+1−1View 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 (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.
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. The plots card is labelled "Real time" and the Sound panel's header says how much faster the sound runs (e.g. the whole decay, 262x faster: 62 min of motion in 14 s), since the two run on different clocks; the Follow options read "pendulum (real time)" and "sound (sped up)". 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; only those within 5% of the section's energy are drawn (others would fall outside the allowed region), and they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything, clears the recent crossings (rings and note trail) 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. "Color by λ" colors orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything. 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
src/double-pendulum/index.htmlmodified+5−3View file
@@ -85,6 +85,7 @@
8585 #param-status { color: #a33; font-size: 12px; }
8686 #params { margin-top: 10px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
8787
88+ .card-head { color: var(--text); font-size: 12px; margin-bottom: 12px; }
8889 .chips { display: flex; flex-wrap: wrap; gap: 6px; }
8990 .chip { position: relative; cursor: pointer; user-select: none; }
9091 .chip input { position: absolute; opacity: 0; pointer-events: none; }
@@ -162,6 +163,7 @@
162163 </section>
163164
164165 <section class="card">
166+ <div class="card-head label">Real time</div>
165167 <div class="chips" id="toggles">
166168 <label class="chip"><input id="spec-toggle" type="checkbox" /><span>Spectrogram</span></label>
167169 </div>
@@ -173,7 +175,7 @@
173175 </div>
174176
175177 <details class="card panel" id="audio">
176- <summary><span class="label">Sound</span></summary>
178+ <summary><span class="label">Sound</span> <span id="audio-clock" class="muted"></span></summary>
177179 <div class="row">
178180 <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
179181 <label><span class="label">Sound of</span> <select id="signal"></select></label>
@@ -207,8 +209,8 @@
207209 <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>
208210 <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">
209211 <option value="off">off</option>
210- <option value="pendulum">pendulum</option>
211- <option value="sound">sound</option>
212+ <option value="pendulum">pendulum (real time)</option>
213+ <option value="sound">sound (sped up)</option>
212214 </select></label>
213215 <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>
214216 <span id="pc-status" class="muted"></span>
src/double-pendulum/main.tsmodified+19−13View file
@@ -166,14 +166,21 @@ const traces = new TracePanel(
166166 );
167167
168168 // Sound: a separate, sped-up simulation. While the Sound panel is open, each
169-// Strike starts a note from the same state as the animation (opening it while
170-// the pendulum swings starts one from its current state); the note is
171-// computed and shown even when muted (the default). A damped note ends by
172-// itself.
169+// Strike starts a note from the same state as the animation (opening the
170+// panel does not start one); the note is computed and shown even when muted
171+// (the default). A damped note ends by itself.
173172 const audioPanel = document.getElementById("audio") as HTMLDetailsElement;
174173 const muteButton = document.getElementById("mute") as HTMLButtonElement;
175174 const pitchInput = document.getElementById("pitch") as HTMLInputElement;
175+// Length of a note (s of sound): energy decays at about 2/tau, so a drop by
176+// END_RATIO takes ~(tau/2) ln(1/END_RATIO) of physical time.
177+const predictedNoteLength = () => {
178+ const S = pitchHz() / modeHz[0];
179+ return damped() ? ((P.tau / 2) * Math.log(1 / END_RATIO)) / S : 10;
180+};
176181 const pitchValue = document.getElementById("pitch-value")!;
182+const audioClock = document.getElementById("audio-clock")!;
183+const duration = (s: number) => (s >= 120 ? `${(s / 60).toFixed(0)} min` : `${s.toFixed(s >= 10 ? 0 : 1)} s`);
177184 const volumeInput = document.getElementById("volume") as HTMLInputElement;
178185 const audioStatus = document.getElementById("audio-status")!;
179186 // Pitch slider 0..1000 maps log-uniformly to 30..1000 Hz for the lower mode.
@@ -187,14 +194,18 @@ const showPitch = () => {
187194 pitchValue.textContent =
188195 `${lo.toFixed(0)} Hz · upper ${hi.toFixed(0)} Hz · ×${S.toFixed(0)}` +
189196 (damped() ? ` · decay ${(P.tau / S).toFixed(1)} s` : "");
197+ // The sound runs on its own clock: the same strike, sped up by S.
198+ const note = predictedNoteLength();
199+ audioClock.textContent = damped()
200+ ? `the whole decay, ${S.toFixed(0)}× faster: ${duration(note * S)} of motion in ${duration(note)}`
201+ : `${S.toFixed(0)}× faster than the pendulum above`;
190202 };
191203 showPitch();
192204
193205 // Displays of the current note, from strike (left) to end (right), fed with
194206 // the worker's samples as the audio clock reaches them. The time axis spans
195-// the predicted note length (energy decays at about 2/tau, so a drop by
196-// END_RATIO takes ~(tau/2) ln(1/END_RATIO) of physical time), is extended
197-// if the note runs longer, and fitted to the note when it ends.
207+// the predicted note length, is extended if the note runs longer, and fitted
208+// to the note when it ends.
198209 // Spectrogram: 2048-sample window, new column every 512 samples. Rebuilt if
199210 // the audio device's sample rate differs.
200211 // One time cursor for the sound card's charts (hover / click to pin).
@@ -207,10 +218,6 @@ const makeAudioSpec = (rate: number) => {
207218 document.getElementById("audio-spectrogram")!.replaceChildren(s.canvas);
208219 return s;
209220 };
210-const predictedNoteLength = () => {
211- const S = pitchHz() / modeHz[0];
212- return damped() ? ((P.tau / 2) * Math.log(1 / END_RATIO)) / S : 10;
213-};
214221 let noteSpan = 10; // s of sound across the displays
215222 let received = 0; // samples received for the current note
216223 const setNoteSpan = (span: number) => {
@@ -337,7 +344,6 @@ audioPanel.addEventListener("toggle", async () => {
337344 }
338345 await audio.enable();
339346 if (audio.sampleRate !== audioSpecRate) audioSpec = makeAudioSpec((audioSpecRate = audio.sampleRate));
340- if (running && energy(P, sim.y) > 0) startNote();
341347 });
342348 // Speaker icon: crossed out when muted, with sound waves when audible.
343349 const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
@@ -619,7 +625,7 @@ function frame(now: number) {
619625 if (a < 0 && sim.y[0] >= 0 && sim.y[0] - a < 1) {
620626 const f = -a / (sim.y[0] - a);
621627 const t2 = b + f * Math.atan2(Math.sin(sim.y[1] - b), Math.cos(sim.y[1] - b));
622- poincare.addLive(Math.atan2(Math.sin(t2), Math.cos(t2)), q + f * (sim.y[3] - q));
628+ poincare.addLive(Math.atan2(Math.sin(t2), Math.cos(t2)), q + f * (sim.y[3] - q), energy(P, sim.y));
623629 }
624630 ghost?.step(H);
625631 lyap.step(sim, H);
src/double-pendulum/poincare.tsmodified+18−10View file
@@ -3,8 +3,10 @@
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 rings ("you are here"); Follow
7-// "Pendulum" makes the section's energy track the pendulum's as it decays.
6+// pendulum's own crossings are drawn as dark rings ("you are here"), only
7+// those within LIVE_TOLERANCE of the section's energy (others would fall
8+// outside the allowed region); Follow "Pendulum" makes the section's energy
9+// track the pendulum's as it decays.
810 // Follow "Sound" shows the playing note's crossings instead, as a short
911 // fading trail, with the axes fitted to the note's current energy and, if an
1012 // atlas has been computed ("Precompute"), the atlas section nearest that
@@ -21,6 +23,7 @@ const PAD = 10;
2123 const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
2224 const GRID = [6, 7]; // seed grid: across th2, across p2
2325 const LIVE_MAX = 400; // pendulum crossings kept
26+const LIVE_TOLERANCE = 0.05; // relative energy difference for drawing a pendulum crossing
2427 const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
2528 const FOLLOW_MIN_MS = 2000; // and at most this often
2629 const NOTE_TRAIL = 150; // note crossings shown (each fitted from its own energy)
@@ -65,7 +68,7 @@ export class PoincarePanel {
6568 private atlasComputing = false;
6669 private atlasButton: HTMLButtonElement;
6770 private lastFollow = 0;
68- private live: number[] = []; // the pendulum's crossings: th2, p2 pairs
71+ private live: number[] = []; // the pendulum's crossings: th2, p2, E triples
6972 private onStrike: (y: Float64Array, E: number) => void;
7073 private onUserEnergy: () => void;
7174 // Axes (data ranges), set from E.
@@ -104,6 +107,9 @@ export class PoincarePanel {
104107 });
105108 this.energyInput.addEventListener("input", () => {
106109 this.onUserEnergy(); // pauses, so Follow does not override this energy
110+ // The recent crossings (pendulum rings, note trail) belong to other energies.
111+ this.live = [];
112+ this.noteDots = [];
107113 this.E = Number(this.energyInput.value);
108114 this.showEnergy();
109115 // Recompute shortly after the slider stops moving.
@@ -145,10 +151,10 @@ export class PoincarePanel {
145151 if (this.open) this.run();
146152 }
147153
148- // A crossing of the animated pendulum (inner arm down, moving right).
149- addLive(th2: number, p2: number): void {
150- this.live.push(th2, p2);
151- if (this.live.length > 2 * LIVE_MAX) this.live.splice(0, this.live.length - 2 * LIVE_MAX);
154+ // A crossing of the animated pendulum (inner arm down, moving right) at energy E.
155+ addLive(th2: number, p2: number, E: number): void {
156+ this.live.push(th2, p2, E);
157+ if (this.live.length > 3 * LIVE_MAX) this.live.splice(0, this.live.length - 3 * LIVE_MAX);
152158 this.changed = true;
153159 }
154160
@@ -448,11 +454,13 @@ export class PoincarePanel {
448454 }
449455 ctx.globalAlpha = 1;
450456 } else {
451- // The pendulum's own crossings: dark rings on white, the latest larger.
457+ // The pendulum's own crossings at about this energy: dark rings on
458+ // white, the latest larger.
452459 ctx.strokeStyle = "#23262b";
453460 ctx.fillStyle = "#fff";
454- for (let i = 0; i < this.live.length; i += 2) {
455- const latest = i === this.live.length - 2;
461+ for (let i = 0; i < this.live.length; i += 3) {
462+ if (Math.abs(this.live[i + 2] - this.E) > LIVE_TOLERANCE * this.E) continue;
463+ const latest = i === this.live.length - 3;
456464 ctx.beginPath();
457465 ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
458466 ctx.fill();