Drop the real-time spectrogram; strike hint and unmute on first open; log energy in the sped-up charts; chart time spans; section says when the pendulum is off its energy
7 changed files+70−229
README.mdmodified+3−3View 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 "Restart ghost", 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; its chip shows only with the ghost on) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s; the first 5 s after a strike, where the values are large, are left out of the y range, here and in the sound's Lyapunov chart). Chart legends show 4 significant digits, ranges 3. Parameters (masses, lengths, g, decay time, presets) fold out underneath. The plots card is labelled "Real time" and the "Sped up · listen" panel's header says how much faster its run is (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 "real time" and "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 (default 17 J) 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: "real time" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sped up" (which opens the "Sped up · listen" panel) 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 sections" (shown only with Follow "sped up", until the atlas is complete) 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 "Sped up · listen" 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; the readout gives both modes' pitches), 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 "Restart ghost", 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; its chip shows only with the ghost on) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s; the first 5 s after a strike, where the values are large, are left out of the y range, here and in the sound's Lyapunov chart). Chart legends show 4 significant digits, ranges 3. Parameters (masses, lengths, g, decay time, presets) fold out underneath. The plots card is labelled "Real time" and the "Sped up · listen" panel's header says how much faster its run is (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 "real time" and "sped up". Toggle chips choose the plots: live traces of energies, energy balance error, angles, momenta and bob positions. Each chart shows its time span at the bottom right ("last 10 s" when scrolling; the note length in the sound panel). The Poincare section panel (closed by default) has its own energy slider (default 17 J) 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; the legend then says "pendulum at 48 J, not shown"), 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: "real time" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sped up" (which opens the "Sped up · listen" panel) 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 sections" (shown only with Follow "sped up", until the atlas is complete) 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 "Sped up · listen" panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything; a line of text says to Strike (or click the section) until the first note. 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 and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Sound of" (the signal), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz; the readout gives both modes' pitches), 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 (log scale, so the exponential decay is a straight line), and a scatter of lambda against energy accumulated over notes (log energy axis, since a note spends most of its time at low energy; colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (real time or sped up) 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
@@ -20,8 +20,8 @@ npm run dev
2020 - Live energy change: both momenta are scaled by one factor at fixed angles (a push along the current motion). A target below the current potential energy can't be reached at once; until it can, kinetic energy is removed at a rate of 6 per second.
2121 - Lyapunov exponent: two-trajectory (Benettin) method in `src/lyapunov.ts`. A hidden shadow pendulum is kept 1e-8 away (angles in rad, momenta in units of sqrt((m1 + m2) l1^2 E_top)), moved back after every step, and the log growth averaged. The shadow keeps its own energy (forcing it onto the pendulum's energy by rescaling momenta inflates the separation near turning points). Checked (no damping, 300 s): 2 J and 10 J fall toward 0 (0.01/s), 30 J ~1.0/s, 50 J ~1.4/s; unchanged for shadow distances 1e-6 and 1e-10. With damping (tau 540 s) a 50 J strike's 10 s value falls from ~1.4 to ~0.1/s by 300 s.
2222 - Poincare section (`src/poincare-core.ts`): on the section th1 = 0, a point (th2, p2) is reachable at energy E exactly when p2^2 <= 2 m2 l2^2 (E - V(th2)), V(th2) = m2 g l2 (1 - cos th2); p1 is the root with the inner arm moving right. Crossings are located by linear interpolation within a step. The step is 4x the animation's: over 300 crossings the energy error stays below ~2e-5, the regular/chaotic classification is unchanged, and regular orbits move by ~1e-4. Each orbit's exponent is the shadow method's mean over its run (regular orbits ~0.01/s or less).
23-- Integrator: 2-stage Gauss-Legendre (order 4, symplectic), fixed step h chosen per parameter set by a short calibration run so the relative energy error stays below 1e-6 up to the top of the energy slider (twice the both-inverted energy); 3.13 ms for the defaults, fixed-point stage solve (started from the previous step's stage derivatives extrapolated in time; tolerance 1e-12, below which the energy error does not change). Angles are wrapped to (-pi, pi] after each step. The physics costs well under 1 ms of CPU per second; drawing dominates, so traces draw at most a min/max pair per pixel column and the spectrogram scrolls in new columns instead of redrawing.
23+- Integrator: 2-stage Gauss-Legendre (order 4, symplectic), fixed step h chosen per parameter set by a short calibration run so the relative energy error stays below 1e-6 up to the top of the energy slider (twice the both-inverted energy); 3.13 ms for the defaults, fixed-point stage solve (started from the previous step's stage derivatives extrapolated in time; tolerance 1e-12, below which the energy error does not change). Angles are wrapped to (-pi, pi] after each step. The physics costs well under 1 ms of CPU per second; drawing dominates, so traces draw at most a min/max pair per pixel column.
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/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).
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` (whole-note spectrogram), `src/main.ts` (page).
index.htmlmodified+4−7View file
@@ -109,6 +109,7 @@
109109 #audio label { display: flex; align-items: center; gap: 8px; }
110110 #audio .grow { flex: 1; min-width: 220px; }
111111 #volume { max-width: 110px; }
112+ #audio-hint { margin: 14px 0 0; }
112113 </style>
113114 </head>
114115 <body>
@@ -166,12 +167,7 @@
166167
167168 <section class="card">
168169 <div class="card-head label">Real time</div>
169- <div class="chips" id="toggles">
170- <label class="chip"><input id="spec-toggle" type="checkbox" /><span>Spectrogram</span></label>
171- </div>
172- <figure id="spec-fig" hidden>
173- <div id="spectrogram"></div>
174- </figure>
170+ <div class="chips" id="toggles"></div>
175171 <div id="traces"></div>
176172 </section>
177173 </div>
@@ -191,6 +187,7 @@
191187 <span id="pitch-value" class="num"></span>
192188 </div>
193189 <div class="row"><span id="audio-status" class="muted"></span></div>
190+ <p id="audio-hint" class="muted">Strike, or click a point on the Poincaré section, to hear the whole decay.</p>
194191 <figure id="audio-spec-fig">
195192 <div id="audio-spectrogram"></div>
196193 </figure>
@@ -212,7 +209,7 @@
212209 <option value="pendulum">real time</option>
213210 <option value="sound">sped up</option>
214211 </select></label>
215- <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sound can show the orbits behind the decaying note's trail. Kept in this browser for 7 days." hidden>Precompute sections</button>
212+ <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sped up can show the orbits behind the decaying note's trail. Kept in this browser for 7 days." hidden>Precompute sections</button>
216213 </div>
217214 <div class="row">
218215 <label class="chip"><input id="pc-lambda" type="checkbox" /><span>Color by λ</span></label>
src/main.tsmodified+20−27View file
@@ -1,7 +1,7 @@
11 import { AudioOut } from "./audio";
22 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
33 import { newCursor } from "./cursor";
4-import { PoincarePanel } from "./poincare";
4+import { PoincarePanel, formatEnergy } from "./poincare";
55 import { Lyapunov } from "./lyapunov";
66 import { ScatterPlot } from "./scatter";
77 import {
@@ -9,7 +9,7 @@ import {
99 } from "./pendulum";
1010 import { SIGNALS, signalByKey } from "./signals";
1111 import { Sim } from "./sim";
12-import { NoteSpectrogram, Spectrogram } from "./spectrogram";
12+import { NoteSpectrogram } from "./spectrogram";
1313 import { TracePanel } from "./traces";
1414
1515 const ALPHA = 0.5; // fraction of energy in the lower mode
@@ -64,26 +64,12 @@ function showParams() {
6464 }
6565 showParams();
6666
67-// The signal that is heard and shown in both spectrograms.
67+// The signal that is heard and shown in the sound's spectrogram.
6868 const signalSelect = document.getElementById("signal") as HTMLSelectElement;
6969 for (const s of SIGNALS) signalSelect.add(new Option(s.label, s.key));
7070 let signal = signalByKey(signalSelect.value);
7171
72-// Spectrogram of the animation's signal, sampled every SPEC_DECIM steps
73-// (32 ms; 8.2 s window).
74-const SPEC_DECIM = 10;
7572 const outerX = () => P.l1 * Math.sin(sim.y[0]) + P.l2 * Math.sin(sim.y[1]);
76-const specToggle = document.getElementById("spec-toggle") as HTMLInputElement;
77-const specFig = document.getElementById("spec-fig")!;
78-// Rebuilt when the parameters change (its sample spacing follows H).
79-const makeSpectrogram = () => {
80- const s = new Spectrogram({ dt: SPEC_DECIM * H, n: 256, nfft: 1024, hop: 5, refFreqs: modeHz });
81- document.getElementById("spectrogram")!.replaceChildren(s.canvas);
82- return s;
83-};
84-let spectrogram = makeSpectrogram();
85-specToggle.addEventListener("change", () => (specFig.hidden = !specToggle.checked));
86-let stepCount = 0;
8773
8874 // Energy balance: E plus the energy dissipated since the last strike or
8975 // rescale, against the energy then (exact conservation would give 0).
@@ -172,8 +158,9 @@ const traces = new TracePanel(
172158
173159 // Sound: a separate, sped-up simulation. While the Sound panel is open, each
174160 // Strike starts a note from the same state as the animation (opening the
175-// panel does not start one); the note is computed and shown even when muted
176-// (the default). A damped note ends by itself.
161+// panel does not start one, and a hint says how); the note is computed and
162+// shown even when muted. Muted until the panel is first opened. A damped note
163+// ends by itself.
177164 const audioPanel = document.getElementById("audio") as HTMLDetailsElement;
178165 const muteButton = document.getElementById("mute") as HTMLButtonElement;
179166 const pitchInput = document.getElementById("pitch") as HTMLInputElement;
@@ -188,6 +175,7 @@ const audioClock = document.getElementById("audio-clock")!;
188175 const duration = (s: number) => (s >= 120 ? `${(s / 60).toFixed(0)} min` : `${s.toFixed(s >= 10 ? 0 : 1)} s`);
189176 const volumeInput = document.getElementById("volume") as HTMLInputElement;
190177 const audioStatus = document.getElementById("audio-status")!;
178+const audioHint = document.getElementById("audio-hint")!;
191179 // Pitch slider 0..1000 maps log-uniformly to 30..1000 Hz for the lower mode.
192180 const PITCH_MIN = 30, PITCH_MAX = 1000;
193181 const pitchHz = () => PITCH_MIN * (PITCH_MAX / PITCH_MIN) ** (Number(pitchInput.value) / 1000);
@@ -246,7 +234,8 @@ const audioTraces = new TracePanel(
246234 skip: () => (LYAP_SKIP * modeHz[0]) / pitchHz(), // in seconds of sound
247235 series: [{ label: "λ 10 s", value: () => noteLambda }],
248236 },
249- { label: "Energy", unit: "J", zero: true, series: [{ label: "E", value: () => noteEnergy }] },
237+ // Log scale: the decay is exponential, a straight line.
238+ { label: "Energy", unit: "J", log: true, series: [{ label: "E", value: () => noteEnergy }] },
250239 ],
251240 10,
252241 960,
@@ -340,16 +329,24 @@ function startNote() {
340329 scatter.newNote(predictedNoteLength());
341330 poincare.noteStarted(energy(P, sim.y));
342331 audioStatus.textContent = "";
332+ audioHint.hidden = true;
343333 }
344334
345335 // Opening the panel (a click, which browsers require before audio can start)
346-// switches the sound engine on; closing it switches it off.
336+// switches the sound engine on, and unmutes the first time; closing it
337+// switches it off.
338+let opened = false;
347339 audioPanel.addEventListener("toggle", async () => {
348340 if (!audioPanel.open) {
349341 audio.disable();
350342 audioStatus.textContent = "";
351343 return;
352344 }
345+ if (!opened) {
346+ opened = true;
347+ audio.setMuted(false);
348+ showMute();
349+ }
353350 await audio.enable();
354351 if (audio.sampleRate !== audioSpecRate) audioSpec = makeAudioSpec((audioSpecRate = audio.sampleRate));
355352 });
@@ -382,7 +379,6 @@ pitchInput.addEventListener("input", () => {
382379 signalSelect.addEventListener("change", () => {
383380 signal = signalByKey(signalSelect.value);
384381 showPitch();
385- spectrogram.clear();
386382 audio.setSignal(signal.key);
387383 });
388384 volumeInput.addEventListener("input", () => audio.setVolume(Number(volumeInput.value)));
@@ -471,7 +467,6 @@ function afterStrike() {
471467 setRunning(true);
472468 pauseButton.disabled = false;
473469 traces.clear();
474- spectrogram.clear();
475470 if (audio.enabled) startNote();
476471 }
477472
@@ -542,13 +537,13 @@ function applyParams() {
542537 setRunning(false);
543538 pauseButton.disabled = true;
544539 showParams();
545- spectrogram = makeSpectrogram();
546540 traces.clear();
547541 showPitch();
548542 showEnergyLabel();
549543 audioSpec.setRefFreqs(audioModeHz());
550544 audio.setParams(P);
551545 audio.stopNote();
546+ audioHint.hidden = false;
552547 poincare.setParams(P);
553548 }
554549
@@ -631,7 +626,7 @@ function draw() {
631626 ctx.font = `${12 * dpr}px ui-monospace, SFMono-Regular, Menlo, monospace`;
632627 ctx.fillStyle = "#7a7d82";
633628 ctx.textBaseline = "top";
634- ctx.fillText(`E ${E >= 10 ? E.toFixed(1) : E.toPrecision(2)} J`, 4 * dpr, 4 * dpr);
629+ ctx.fillText(`E ${formatEnergy(E)} J`, 4 * dpr, 4 * dpr);
635630 }
636631 showEnergy();
637632 const sep = ghost
@@ -659,12 +654,10 @@ function frame(now: number) {
659654 lyap.step(sim, H);
660655 pending -= H;
661656 traces.sample(sim.t);
662- if (++stepCount % SPEC_DECIM === 0) spectrogram.push(signal.value(P, sim.y));
663657 }
664658 if (running && poincare.followMode() === "pendulum") poincare.follow(energy(P, sim.y));
665659 draw();
666660 traces.draw();
667- if (!specFig.hidden) spectrogram.draw();
668661 feedAudioSpec();
669662 if (!audioSpecFig.hidden) audioSpec.draw();
670663 audioTraces.draw();
src/poincare.tsmodified+11−1View file
@@ -291,7 +291,7 @@ export class PoincarePanel {
291291
292292 private showEnergy(): void {
293293 const E = this.E;
294- this.energyValue.textContent = `${E >= 10 ? E.toFixed(1) : E >= 0.01 ? E.toPrecision(2) : E.toExponential(1)} J`;
294+ this.energyValue.textContent = `${formatEnergy(E)} J`;
295295 }
296296
297297 private send(msg: ToPoincare): void {
@@ -487,6 +487,11 @@ export class PoincarePanel {
487487 legend = `θ2 ${th2.toFixed(3)} rad · p2 ${p2.toFixed(3)} kg m²/s${sectionState(this.P, this.E, th2, p2) ? " · click to add an orbit here" : ""}`;
488488 } else {
489489 legend = `θ2 (rad) across · p2 (kg m²/s) up · ${this.orbits.length} orbits`;
490+ // Say why the pendulum's own crossings are missing.
491+ const lastE = this.live[this.live.length - 1];
492+ if (this.followMode() !== "sound" && lastE > 0 && Math.abs(lastE - this.E) > LIVE_TOLERANCE * this.E) {
493+ legend += ` · pendulum at ${formatEnergy(lastE)} J, not shown`;
494+ }
490495 }
491496 ctx.fillText(legend, 4, 3);
492497 ctx.fillStyle = "#999";
@@ -498,3 +503,8 @@ export class PoincarePanel {
498503 ctx.textAlign = "left";
499504 }
500505 }
506+
507+// An energy for display: one decimal from 10 J, else two significant digits.
508+export function formatEnergy(E: number): string {
509+ return E >= 10 ? E.toFixed(1) : E >= 0.01 ? E.toPrecision(2) : E.toExponential(1);
510+}
src/scatter.tsmodified+17−10View file
@@ -2,7 +2,9 @@
22 // per diagnostic sample, accumulated over notes. Points are colored by time
33 // into their note on a log scale (red just after the strike, then amber, blue
44 // late): the energy decays exponentially, so on a linear time scale nearly all
5-// of the high-energy part would share one color. Earlier notes are faded. Hovering shows the nearest point's values in the legend row.
5+// of the high-energy part would share one color. For the same reason the
6+// energy axis is logarithmic. Earlier notes are faded. Hovering shows the
7+// nearest point's values in the legend row.
68 // Earlier notes are drawn once into a cached image (rebuilt when the axes
79 // change); each frame adds only the current note.
810
@@ -33,7 +35,8 @@ export class ScatterPlot {
3335 private changed = true;
3436 private hover: { x: number; y: number } | null = null;
3537 // Current axes (data -> device pixels), set at draw.
36- private eMax = 1;
38+ private lgMin = -1; // log10 of the energy axis range
39+ private lgMax = 0;
3740 private lMin = -0.5;
3841 private lMax = 2;
3942
@@ -80,7 +83,7 @@ export class ScatterPlot {
8083 }
8184
8285 add(E: number, lambda: number, t: number): void {
83- if (!Number.isFinite(E) || !Number.isFinite(lambda) || this.n >= MAX_POINTS || !this.noteLength.length) return;
86+ if (!(E > 0) || !Number.isFinite(lambda) || this.n >= MAX_POINTS || !this.noteLength.length) return;
8487 this.E[this.n] = E;
8588 this.L[this.n] = lambda;
8689 this.T[this.n] = t;
@@ -90,7 +93,7 @@ export class ScatterPlot {
9093 }
9194
9295 private px(E: number): number {
93- return PAD + (E / this.eMax) * (this.width - 2 * PAD);
96+ return PAD + ((Math.log10(E) - this.lgMin) / (this.lgMax - this.lgMin)) * (this.width - 2 * PAD);
9497 }
9598
9699 private py(l: number): number {
@@ -118,14 +121,18 @@ export class ScatterPlot {
118121 if (!this.changed) return;
119122 this.changed = false;
120123 const { n, E, L, T, dpr } = this;
121- // Axes: energy from 0; lambda including 0, padded.
122- let eMax = 0, lMin = 0, lMax = 0;
124+ // Axes: energy (log) over the points, in whole decades; lambda including
125+ // 0, padded.
126+ let eMin = Infinity, eMax = 0, lMin = 0, lMax = 0;
123127 for (let i = 0; i < n; i++) {
128+ if (E[i] < eMin) eMin = E[i];
124129 if (E[i] > eMax) eMax = E[i];
125130 if (L[i] < lMin) lMin = L[i];
126131 if (L[i] > lMax) lMax = L[i];
127132 }
128- this.eMax = eMax > 0 ? 1.05 * eMax : 1;
133+ this.lgMin = n ? Math.floor(Math.log10(eMin)) : -1;
134+ this.lgMax = n ? Math.ceil(Math.log10(eMax)) : 0;
135+ if (this.lgMax <= this.lgMin) this.lgMax = this.lgMin + 1;
129136 const span = lMax - lMin || 1;
130137 this.lMin = lMin - 0.05 * span;
131138 this.lMax = lMax + 0.05 * span;
@@ -142,7 +149,7 @@ export class ScatterPlot {
142149 ctx.stroke();
143150
144151 // Earlier notes (faded) from the cache, then the current note.
145- const key = `${this.eMax}|${this.lMin}|${this.lMax}|${this.noteStart}|${this.noteLength.length}`;
152+ const key = `${this.lgMin}|${this.lgMax}|${this.lMin}|${this.lMax}|${this.noteStart}|${this.noteLength.length}`;
146153 if (key !== this.cacheKey) {
147154 this.cacheKey = key;
148155 const cc = this.cache.getContext("2d")!;
@@ -180,12 +187,12 @@ export class ScatterPlot {
180187 ctx.fillStyle = "#2f5d8a";
181188 const legend =
182189 near >= 0
183- ? `E ${E[near].toFixed(2)} J λ ${L[near].toFixed(3)} 1/s ${T[near].toFixed(2)} s into note`
190+ ? `E ${E[near].toPrecision(3)} J λ ${L[near].toFixed(3)} 1/s ${T[near].toFixed(2)} s into note`
184191 : "λ (1/s) vs E (J)";
185192 ctx.fillText(legend, 4, 3);
186193 ctx.fillStyle = "#999";
187194 ctx.textAlign = "right";
188- ctx.fillText(`E 0 … ${this.eMax.toFixed(1)} · λ ${this.lMin.toFixed(2)} … ${this.lMax.toFixed(2)}`, this.width - 4, 3);
195+ ctx.fillText(`E 1e${this.lgMin} … 1e${this.lgMax} (log) · λ ${this.lMin.toFixed(2)} … ${this.lMax.toFixed(2)}`, this.width - 4, 3);
189196 ctx.textAlign = "left";
190197 ctx.fillText("λ = 0", 4, this.py(0) + 2);
191198 }
src/spectrogram.tsmodified+2−179View file
@@ -1,15 +1,14 @@
1-// Live spectrogram of one signal. Samples arrive at a fixed spacing dt (s);
1+// Spectrogram of one whole note. Samples arrive at a fixed spacing dt (s);
22 // every `hop` samples a Hann-windowed FFT of the last `n` samples (zero
33 // padded to `nfft`) becomes one column. The frequency axis tops out just
44 // above the highest frequency with significant power in the time-averaged
5-// spectrum of the visible history.
5+// spectrum.
66
77 import { attachCursor, type Cursor } from "./cursor";
88
99 const HEIGHT = 160;
1010 const RANGE_DB = 60; // color range below the peak
1111 const CONTENT_DB = 30; // "significant" power for the frequency auto scale
12-const FULL_EVERY = 50; // columns between full re-renders
1312
1413 export interface SpectrogramOptions {
1514 dt: number; // sample spacing (s)
@@ -20,182 +19,6 @@ export interface SpectrogramOptions {
2019 width?: number; // columns kept and drawn (canvas width), default 480
2120 }
2221
23-export class Spectrogram {
24- canvas: HTMLCanvasElement;
25- private width: number; // columns kept and drawn
26- private dt: number;
27- private n: number;
28- private hop: number;
29- private bins: number;
30- private refFreqs: number[];
31- private buf: Float64Array; // last n samples (ring)
32- private nSamples = 0;
33- private cols: Float32Array; // power (ring of columns)
34- private sum: Float64Array; // sum of the stored columns
35- private nCols = 0;
36- private headCol = 0;
37- private hann: Float64Array;
38- private fft: FFT;
39- private re: Float64Array;
40- private im: Float64Array;
41- private fmax = 1;
42- private peak = 0; // power at the top of the color scale
43- private image: ImageData;
44- private newCols = 0; // columns added since the last draw
45- private sinceFull = Infinity; // columns drawn since the last full render
46-
47- constructor(opts: SpectrogramOptions) {
48- this.width = opts.width ?? 480;
49- this.dt = opts.dt;
50- this.n = opts.n;
51- this.hop = opts.hop;
52- this.bins = opts.nfft / 2 + 1;
53- this.refFreqs = opts.refFreqs;
54- this.buf = new Float64Array(this.n);
55- this.cols = new Float32Array(this.width * this.bins);
56- this.sum = new Float64Array(this.bins);
57- this.hann = new Float64Array(this.n);
58- for (let i = 0; i < this.n; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.n - 1));
59- this.fft = new FFT(opts.nfft);
60- this.re = new Float64Array(opts.nfft);
61- this.im = new Float64Array(opts.nfft);
62- this.canvas = document.createElement("canvas");
63- this.canvas.width = this.width;
64- this.canvas.height = HEIGHT;
65- this.image = this.canvas.getContext("2d")!.createImageData(this.width, HEIGHT);
66- }
67-
68- clear(): void {
69- this.nSamples = 0;
70- this.nCols = 0;
71- this.headCol = 0;
72- this.sum.fill(0);
73- this.newCols = 0;
74- this.sinceFull = Infinity;
75- }
76-
77- setRefFreqs(f: number[]): void {
78- this.refFreqs = f;
79- this.sinceFull = Infinity; // redraw
80- }
81-
82- push(x: number): void {
83- this.buf[this.nSamples % this.n] = x;
84- this.nSamples++;
85- if (this.nSamples >= this.n && this.nSamples % this.hop === 0) this.addColumn();
86- }
87-
88- private addColumn(): void {
89- const { buf, re, im, hann, cols, sum, n, bins } = this;
90- let mean = 0;
91- for (let i = 0; i < n; i++) mean += buf[i];
92- mean /= n;
93- const start = this.nSamples % n; // oldest sample
94- re.fill(0);
95- im.fill(0);
96- for (let i = 0; i < n; i++) re[i] = (buf[(start + i) % n] - mean) * hann[i];
97- this.fft.transform(re, im);
98- const off = this.headCol * bins;
99- for (let k = 0; k < bins; k++) {
100- if (this.nCols === this.width) sum[k] -= cols[off + k]; // evicted column
101- cols[off + k] = re[k] * re[k] + im[k] * im[k];
102- sum[k] += cols[off + k];
103- }
104- this.headCol = (this.headCol + 1) % this.width;
105- this.nCols = Math.min(this.nCols + 1, this.width);
106- this.newCols++;
107- }
108-
109- // Draws only when columns have been added. New columns scroll in; the
110- // whole image is re-rendered only when the frequency range changes, a peak
111- // 3 dB louder arrives, or every FULL_EVERY columns (so the color scale can
112- // relax after a loud passage).
113- draw(): void {
114- if (this.newCols === 0 && this.sinceFull !== Infinity) return;
115- const { sum, cols, bins } = this;
116- const df = this.df();
117-
118- // Highest bin of the averaged spectrum within CONTENT_DB of its peak sets
119- // the frequency range.
120- let sumPeak = 0;
121- for (let k = 1; k < bins; k++) if (sum[k] > sumPeak) sumPeak = sum[k];
122- let top = bins - 1;
123- while (top > 1 && sum[top] < sumPeak * 10 ** (-CONTENT_DB / 10)) top--;
124- const fmax = this.nCols > 0 ? Math.min(niceCeil(1.2 * top * df), (bins - 1) * df) : this.fmax;
125-
126- let newPeak = 0;
127- for (let a = 1; a <= this.newCols; a++) {
128- const off = ((this.headCol - a + this.width) % this.width) * bins;
129- for (let k = 1; k < bins; k++) if (cols[off + k] > newPeak) newPeak = cols[off + k];
130- }
131- const full =
132- fmax !== this.fmax || newPeak > 2 * this.peak || // 3 dB louder
133- this.sinceFull + this.newCols >= FULL_EVERY || this.newCols >= this.width;
134- this.fmax = fmax;
135-
136- const data = this.image.data;
137- if (full) {
138- this.peak = 0;
139- for (let i = 0; i < this.nCols * bins; i++) if (cols[i] > this.peak) this.peak = cols[i];
140- for (let x = 0; x < this.width; x++) this.renderColumn(x, this.width - 1 - x);
141- this.sinceFull = 0;
142- } else {
143- // Scroll left by the number of new columns, then render them.
144- const shift = this.newCols;
145- for (let y = 0; y < HEIGHT; y++) {
146- const row = 4 * y * this.width;
147- data.copyWithin(row, row + 4 * shift, row + 4 * this.width);
148- }
149- for (let x = this.width - shift; x < this.width; x++) this.renderColumn(x, this.width - 1 - x);
150- this.sinceFull += shift;
151- }
152- this.newCols = 0;
153-
154- const ctx = this.canvas.getContext("2d")!;
155- ctx.putImageData(this.image, 0, 0);
156- const py = (f: number) => (HEIGHT - 1) * (1 - f / this.fmax);
157- ctx.strokeStyle = "rgba(255,255,255,0.7)";
158- ctx.setLineDash([4, 4]);
159- for (const f of this.refFreqs) {
160- if (f >= this.fmax) continue;
161- ctx.beginPath();
162- ctx.moveTo(0, py(f));
163- ctx.lineTo(this.width, py(f));
164- ctx.stroke();
165- }
166- ctx.setLineDash([]);
167- ctx.font = "12px ui-monospace, monospace";
168- label(ctx, `${this.fmax} Hz`, 2, 2);
169- label(ctx, "0", 2, HEIGHT - 18);
170- }
171-
172- private df(): number {
173- return 1 / (2 * (this.bins - 1) * this.dt); // bin spacing (Hz)
174- }
175-
176- // Paint pixel column x with the stored column `age` columns old (light gray
177- // if there is none).
178- private renderColumn(x: number, age: number): void {
179- const data = this.image.data;
180- const { bins } = this;
181- const df = this.df();
182- const peakDb = 10 * Math.log10(this.peak + 1e-30);
183- const off = age < this.nCols ? ((this.headCol - 1 - age + 2 * this.width) % this.width) * bins : -1;
184- for (let y = 0; y < HEIGHT; y++) {
185- const p = 4 * (y * this.width + x);
186- if (off < 0) {
187- data[p] = data[p + 1] = data[p + 2] = 246; // no data yet
188- } else {
189- const f = (this.fmax * (HEIGHT - 1 - y)) / (HEIGHT - 1);
190- const k = Math.min(bins - 1, Math.round(f / df));
191- const db = 10 * Math.log10(this.cols[off + k] + 1e-30);
192- colormap((db - (peakDb - RANGE_DB)) / RANGE_DB, data, p);
193- }
194- data[p + 3] = 255;
195- }
196- }
197-}
198-
19922 // White text on a dark box, readable over any part of the image.
20023 function label(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
20124 ctx.fillStyle = "rgba(0,0,0,0.6)";
src/traces.tsmodified+13−2View file
@@ -1,8 +1,10 @@
11 // Live scrolling time traces. Quantities are arranged in groups; each group is
22 // one chart (shared y axis, one line per series) shown or hidden by a
33 // toggle chip. The y range fits the samples in the visible window (linear or
4-// log scale), optionally leaving out an initial transient (`skip`). Each pixel column draws only the min and max of its samples, so
5-// cost does not grow with the sample rate and short spikes are kept.
4+// log scale), optionally leaving out an initial transient (`skip`). The time
5+// span is shown at the bottom right. Each pixel column draws only the min and
6+// max of its samples, so cost does not grow with the sample rate and short
7+// spikes are kept.
68 // Non-finite values (NaN) leave a gap. Hovering shows a time cursor (shared by
79 // the panel's charts, and optionally with other charts) and the values there;
810 // a click pins it.
@@ -286,6 +288,15 @@ export class TracePanel {
286288 const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y, 3));
287289 ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, this.width - 4, 3);
288290 }
291+ // Time span, at the bottom right: the right edge's time for a fixed
292+ // range, the window length when scrolling.
293+ const secs = `${Number.isInteger(win) ? win : win.toFixed(1)} s`;
294+ const span = this.fixed ? secs : `last ${secs}`;
295+ ctx.textBaseline = "bottom";
296+ ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
297+ ctx.fillRect(this.width - ctx.measureText(span).width - 6, HEIGHT - 14, ctx.measureText(span).width + 6, 14);
298+ ctx.fillStyle = MUTED;
299+ ctx.fillText(span, this.width - 4, HEIGHT - 1);
289300 ctx.textAlign = "left";
290301 });
291302 }