/ listening-for-chaos / double-pendulum-chaos
Sign in
listening-for-chaos / double-pendulum-chaos
Sound panel, mute, lambda vs energy scatter, hover cursor on charts
- Collapsible Sound panel (opening it switches sound on) with a speaker mute button, muted by default; sound spectrogram on by default - Scatter of the note's Lyapunov exponent against its energy, accumulated over strikes, colored by time into the note (log scale) - Hover on any chart shows a time cursor and the values there; click pins it; charts in a card share the cursor - Fix blank pixel columns in the whole-note spectrogram
Jeremy Magland <jeremy.magland@gmail.com> committed commit d3c8c3f4cbfa parent 30a92c8 Browse files
8 changed files+411−49
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 sound card at the bottom has a Sound on/off toggle (with sound on, 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), "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), and its energy (log scale). 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 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 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 ```bash
88 npm install
@@ -21,4 +21,4 @@ npm run dev
2121
2222 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.
2323
24-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/audio-shared.ts` (constants shared with the worker), `src/traces.ts` (strip charts), `src/spectrogram.ts` (spectrogram), `src/main.ts` (page).
24+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/audio-shared.ts` (constants shared with the worker), `src/traces.ts` (strip charts), `src/spectrogram.ts` (spectrogram), `src/main.ts` (page).
index.htmlmodified+22−7View file
@@ -57,8 +57,9 @@
5757 button.primary:hover { filter: brightness(1.08); }
5858 button:disabled { color: #b5b5b0; cursor: default; }
5959 button:disabled:hover { border-color: var(--line); }
60- button.sound { min-width: 86px; }
61- button.sound.on { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
60+ button.mute { width: 36px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); }
61+ button.mute svg { width: 18px; height: 18px; }
62+ button.mute.audible { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
6263
6364 #controls { margin-top: 14px; }
6465 #energy { min-width: 70px; }
@@ -70,8 +71,13 @@
7071
7172 details { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
7273 summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; }
74+ summary::-webkit-details-marker { display: none; }
7375 summary::before { content: "▸ "; }
7476 details[open] summary::before { content: "▾ "; }
77+ details#audio { border-top: 1px solid var(--line); padding-top: 16px; }
78+ details#audio summary { display: flex; align-items: baseline; gap: 10px; }
79+ details#audio summary .label { font-size: 12px; color: var(--text); }
80+ details#audio[open] summary { margin-bottom: 14px; }
7581 #param-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px 16px; margin-top: 10px; }
7682 #param-form label { display: grid; grid-template-columns: 1.6em 5.5em auto; align-items: center; gap: 6px; font-size: 13px; }
7783 #param-form .unit { color: var(--muted); font-size: 12px; }
@@ -92,6 +98,7 @@
9298
9399 figure { margin: 14px 0 0; }
94100 figure canvas { width: 100%; height: auto; border-radius: 4px; }
101+ #scatter-fig figcaption { justify-content: space-between; flex-wrap: nowrap; }
95102 figcaption { margin-top: 4px; }
96103 #traces canvas, #audio-traces canvas { width: 100%; height: auto; margin-top: 12px; }
97104 #plots-empty { margin: 14px 0 2px; }
@@ -170,13 +177,14 @@
170177 </section>
171178 </div>
172179
173- <section class="card" id="audio">
180+ <details class="card" id="audio">
181+ <summary><span class="label">Sound</span><span class="muted">Hear the motion, sped up. Each Strike plays a note.</span></summary>
174182 <div class="row">
175- <button id="sound" type="button" class="sound" title="With sound on, every Strike plays a note">Sound off</button>
183+ <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
176184 <label><span class="label">Sound of</span> <select id="signal"></select></label>
177185 <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
178186 <span id="audio-chips" class="chips">
179- <label class="chip"><input id="audio-spec-toggle" type="checkbox" /><span>Spectrogram</span></label>
187+ <label class="chip"><input id="audio-spec-toggle" type="checkbox" checked /><span>Spectrogram</span></label>
180188 </span>
181189 </div>
182190 <div class="row">
@@ -184,12 +192,19 @@
184192 <span id="pitch-value" class="num"></span>
185193 </div>
186194 <div class="row"><span id="audio-status" class="muted"></span></div>
187- <figure id="audio-spec-fig" hidden>
195+ <figure id="audio-spec-fig">
188196 <div id="audio-spectrogram"></div>
189197 <figcaption id="audio-spec-label" class="muted"></figcaption>
190198 </figure>
191199 <div id="audio-traces"></div>
192- </section>
200+ <figure id="scatter-fig" hidden>
201+ <div id="scatter"></div>
202+ <figcaption class="muted row">
203+ <span>Each point is a moment of a note: its energy and Lyapunov exponent. Color is time into the note on a log scale: red just after the strike, then amber, blue late. Earlier notes are faded; strike several times to build up the picture.</span>
204+ <button id="scatter-clear" type="button">Clear</button>
205+ </figcaption>
206+ </figure>
207+ </details>
193208 </main>
194209 <script type="module" src="/src/main.ts"></script>
195210 </body>
src/audio.tsmodified+15−4View file
@@ -3,7 +3,8 @@
33 // worklet talk directly through a MessageChannel. Copies of the samples come
44 // back here for display; playedSamples() says how far playback has got.
55 // Sound is switched on and off with enable()/disable(); while it is on, each
6-// play() starts a new note (a damped note ends by itself).
6+// play() starts a new note (a damped note ends by itself). Muting only sets
7+// the output gain to zero: notes are still computed and played in time.
78
89 import type { Params } from "./pendulum";
910 import type { FromWorker, ToWorker } from "./audio-worker";
@@ -22,7 +23,8 @@ export interface AudioCallbacks {
2223 }
2324
2425 export class AudioOut {
25- enabled = false; // sound on
26+ enabled = false; // sound engine on
27+ muted = true;
2628 playing = false; // a note is being computed and played
2729 private P: Params;
2830 private alpha: number;
@@ -86,7 +88,7 @@ export class AudioOut {
8688 const g = this.gain!.gain, now = this.ctx.currentTime;
8789 g.cancelScheduledValues(now);
8890 g.setValueAtTime(0, now);
89- g.linearRampToValueAtTime(this.volume, now + FADE);
91+ g.linearRampToValueAtTime(this.level(), now + FADE);
9092 this.enabled = true;
9193 }
9294
@@ -142,6 +144,15 @@ export class AudioOut {
142144
143145 setVolume(v: number): void {
144146 this.volume = v;
145- if (this.ctx && this.enabled) this.gain!.gain.setTargetAtTime(v, this.ctx.currentTime, FADE);
147+ if (this.ctx && this.enabled) this.gain!.gain.setTargetAtTime(this.level(), this.ctx.currentTime, FADE);
148+ }
149+
150+ setMuted(m: boolean): void {
151+ this.muted = m;
152+ if (this.ctx && this.enabled) this.gain!.gain.setTargetAtTime(this.level(), this.ctx.currentTime, FADE);
153+ }
154+
155+ private level(): number {
156+ return this.muted ? 0 : this.volume;
146157 }
147158 }
src/cursor.tsadded+30−0View file
@@ -0,0 +1,30 @@
1+// A time cursor shared by the charts of one card: hovering sets the time,
2+// a click pins it (another click releases it), leaving clears it unless
3+// pinned. Charts read it when they draw.
4+
5+export interface Cursor {
6+ t: number | null; // time under the cursor (chart time units), or none
7+ pinned: boolean;
8+}
9+
10+export const newCursor = (): Cursor => ({ t: null, pinned: false });
11+
12+// Wire mouse events on a canvas to a cursor; toTime maps an x position (0..1
13+// of the canvas width) to a time.
14+export function attachCursor(canvas: HTMLCanvasElement, cursor: Cursor, toTime: (frac: number) => number): void {
15+ const at = (e: MouseEvent) => {
16+ const r = canvas.getBoundingClientRect();
17+ return toTime((e.clientX - r.left) / r.width);
18+ };
19+ canvas.style.cursor = "crosshair";
20+ canvas.addEventListener("mousemove", (e) => {
21+ if (!cursor.pinned) cursor.t = at(e);
22+ });
23+ canvas.addEventListener("mouseleave", () => {
24+ if (!cursor.pinned) cursor.t = null;
25+ });
26+ canvas.addEventListener("click", (e) => {
27+ cursor.pinned = !cursor.pinned;
28+ cursor.t = at(e);
29+ });
30+}
src/main.tsmodified+58−12View file
@@ -1,6 +1,8 @@
11 import { AudioOut } from "./audio";
22 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
3+import { newCursor } from "./cursor";
34 import { Lyapunov } from "./lyapunov";
5+import { ScatterPlot } from "./scatter";
46 import {
57 energy, kinetic, normalModes, potential, stepSize, topEnergy, type Params,
68 } from "./pendulum";
@@ -160,11 +162,13 @@ const traces = new TracePanel(
160162 const plotsEmpty = document.getElementById("plots-empty")!;
161163 toggles.addEventListener("change", () => (plotsEmpty.hidden = specToggle.checked || traces.anyShown()));
162164
163-// Sound: a separate, sped-up simulation. With sound on, each Strike starts a
164-// note from the same state as the animation; turning sound on while the
165-// pendulum swings starts one from its current state. A damped note ends by
165+// Sound: a separate, sped-up simulation. While the Sound panel is open, each
166+// Strike starts a note from the same state as the animation (opening it while
167+// the pendulum swings starts one from its current state); the note is
168+// computed and shown even when muted (the default). A damped note ends by
166169 // itself.
167-const soundButton = document.getElementById("sound") as HTMLButtonElement;
170+const audioPanel = document.getElementById("audio") as HTMLDetailsElement;
171+const muteButton = document.getElementById("mute") as HTMLButtonElement;
168172 const pitchInput = document.getElementById("pitch") as HTMLInputElement;
169173 const pitchValue = document.getElementById("pitch-value")!;
170174 const volumeInput = document.getElementById("volume") as HTMLInputElement;
@@ -193,8 +197,13 @@ showPitch();
193197 // if the note runs longer, and fitted to the note when it ends.
194198 // Spectrogram: 2048-sample window, new column every 512 samples. Rebuilt if
195199 // the audio device's sample rate differs.
200+// One time cursor for the sound card's charts (hover / click to pin).
201+const soundCursor = newCursor();
196202 const makeAudioSpec = (rate: number) => {
197- const s = new NoteSpectrogram({ dt: 1 / rate, n: 2048, nfft: 4096, hop: 512, refFreqs: audioModeHz(), width: 960 });
203+ const s = new NoteSpectrogram(
204+ { dt: 1 / rate, n: 2048, nfft: 4096, hop: 512, refFreqs: audioModeHz(), width: 960 },
205+ soundCursor,
206+ );
198207 document.getElementById("audio-spectrogram")!.replaceChildren(s.canvas);
199208 return s;
200209 };
@@ -226,9 +235,27 @@ const audioTraces = new TracePanel(
226235 ],
227236 10,
228237 960,
238+ soundCursor,
229239 );
230240 audioTraces.setFixed({ t0: 0, span: noteSpan });
231241
242+// Lambda against energy, accumulated over notes (chip after the trace chips).
243+const scatter = new ScatterPlot(960);
244+document.getElementById("scatter")!.appendChild(scatter.canvas);
245+const scatterFig = document.getElementById("scatter-fig")!;
246+{
247+ const box = document.createElement("input");
248+ box.type = "checkbox";
249+ box.addEventListener("change", () => (scatterFig.hidden = !box.checked));
250+ const text = document.createElement("span");
251+ text.textContent = "λ vs E";
252+ const chip = document.createElement("label");
253+ chip.className = "chip";
254+ chip.append(box, text);
255+ document.getElementById("audio-chips")!.appendChild(chip);
256+}
257+document.getElementById("scatter-clear")!.addEventListener("click", () => scatter.clear());
258+
232259 // Chunks of the current note not yet shown, fed to the displays as the audio
233260 // clock reaches them.
234261 const queue: { data: Float32Array; lambda: Float32Array; energy: Float32Array }[] = [];
@@ -246,7 +273,9 @@ function feedAudioSpec() {
246273 if ((j + 1) % LAMBDA_EVERY === 0) {
247274 noteLambda = lambda[(j + 1) / LAMBDA_EVERY - 1];
248275 noteEnergy = en[(j + 1) / LAMBDA_EVERY - 1];
249- audioTraces.sample((fed + i + 1) / audio.sampleRate);
276+ const t = (fed + i + 1) / audio.sampleRate;
277+ audioTraces.sample(t);
278+ scatter.add(noteEnergy, noteLambda, t);
250279 }
251280 }
252281 queueOffset += n;
@@ -274,6 +303,7 @@ const audio = new AudioOut(P, ALPHA, {
274303 onEnded: () => {
275304 audioStatus.textContent = "Note finished (decayed by 60 dB).";
276305 setNoteSpan(received / audio.sampleRate);
306+ scatter.setNoteLength(received / audio.sampleRate);
277307 },
278308 });
279309
@@ -285,24 +315,39 @@ function startNote() {
285315 audio.play(sim.y, sim.E0, sim.target, pitchHz(), signal.key);
286316 audioSpec.start(predictedNoteLength());
287317 setNoteSpan(predictedNoteLength());
318+ scatter.newNote(predictedNoteLength());
288319 audioStatus.textContent = "";
289320 }
290321
291-soundButton.addEventListener("click", async () => {
292- if (audio.enabled) {
322+// Opening the panel (a click, which browsers require before audio can start)
323+// switches the sound engine on; closing it switches it off.
324+audioPanel.addEventListener("toggle", async () => {
325+ if (!audioPanel.open) {
293326 audio.disable();
294- soundButton.textContent = "Sound off";
295- soundButton.classList.remove("on");
296327 audioStatus.textContent = "";
297328 return;
298329 }
299330 await audio.enable();
300- soundButton.textContent = "Sound on";
301- soundButton.classList.add("on");
302331 if (audio.sampleRate !== audioSpecRate) audioSpec = makeAudioSpec((audioSpecRate = audio.sampleRate));
303332 if (running && energy(P, sim.y) > 0) startNote();
304333 else audioStatus.textContent = "Press Strike to play a note.";
305334 });
335+// Speaker icon: crossed out when muted, with sound waves when audible.
336+const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
337+const ICON_MUTED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9l5 6M21 9l-5 6"/></svg>`;
338+const ICON_AUDIBLE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10"/></svg>`;
339+const showMute = () => {
340+ const label = audio.muted ? "Unmute" : "Mute";
341+ muteButton.innerHTML = audio.muted ? ICON_MUTED : ICON_AUDIBLE;
342+ muteButton.title = label;
343+ muteButton.setAttribute("aria-label", label);
344+ muteButton.classList.toggle("audible", !audio.muted);
345+};
346+showMute();
347+muteButton.addEventListener("click", () => {
348+ audio.setMuted(!audio.muted);
349+ showMute();
350+});
306351 pitchInput.addEventListener("input", () => {
307352 showPitch();
308353 audio.setPitch(pitchHz());
@@ -549,6 +594,7 @@ function frame(now: number) {
549594 feedAudioSpec();
550595 if (!audioSpecFig.hidden) audioSpec.draw();
551596 audioTraces.draw();
597+ if (!scatterFig.hidden) scatter.draw();
552598 requestAnimationFrame(frame);
553599 }
554600 requestAnimationFrame(frame);
src/scatter.tsadded+199−0View file
@@ -0,0 +1,199 @@
1+// Scatter plot of the sound's Lyapunov exponent against its energy, one point
2+// per diagnostic sample, accumulated over notes. Points are colored by time
3+// into their note on a log scale (red just after the strike, then amber, blue
4+// 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.
6+// Earlier notes are drawn once into a cached image (rebuilt when the axes
7+// change); each frame adds only the current note.
8+
9+const HEIGHT = 220; // CSS pixels
10+const TOP = 18; // legend row
11+const PAD = 6;
12+const MAX_POINTS = 200000;
13+const RAMP = [[194, 65, 45], [227, 162, 26], [47, 93, 138]]; // red -> amber -> blue
14+const LOG_T1 = 0.01; // log time scale: u = ln(1 + t/(LOG_T1 L)) / ln(1 + 1/LOG_T1), L = note length
15+const DOT = 3; // CSS pixels
16+const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
17+const BINS = 16; // color steps along the note
18+
19+export class ScatterPlot {
20+ canvas: HTMLCanvasElement;
21+ private width: number;
22+ private dpr = window.devicePixelRatio || 1;
23+ private E = new Float32Array(MAX_POINTS);
24+ private L = new Float32Array(MAX_POINTS);
25+ private T = new Float32Array(MAX_POINTS); // s into the note
26+ private note = new Int32Array(MAX_POINTS);
27+ private n = 0;
28+ private noteIndex = 0;
29+ private noteStart = 0; // index of the current note's first point
30+ private cache: HTMLCanvasElement;
31+ private cacheKey = "";
32+ private noteLength: number[] = []; // per note, for coloring (s)
33+ private changed = true;
34+ private hover: { x: number; y: number } | null = null;
35+ // Current axes (data -> device pixels), set at draw.
36+ private eMax = 1;
37+ private lMin = -0.5;
38+ private lMax = 2;
39+
40+ constructor(width = 960) {
41+ this.width = width;
42+ this.canvas = document.createElement("canvas");
43+ this.canvas.width = width * this.dpr;
44+ this.canvas.height = HEIGHT * this.dpr;
45+ this.canvas.style.cursor = "crosshair";
46+ this.cache = document.createElement("canvas");
47+ this.cache.width = this.canvas.width;
48+ this.cache.height = this.canvas.height;
49+ this.canvas.addEventListener("mousemove", (e) => {
50+ const r = this.canvas.getBoundingClientRect();
51+ this.hover = { x: ((e.clientX - r.left) / r.width) * this.width, y: ((e.clientY - r.top) / r.height) * HEIGHT };
52+ this.changed = true;
53+ });
54+ this.canvas.addEventListener("mouseleave", () => {
55+ this.hover = null;
56+ this.changed = true;
57+ });
58+ }
59+
60+ clear(): void {
61+ this.n = 0;
62+ this.noteStart = 0;
63+ this.noteLength = [];
64+ this.noteIndex = 0;
65+ this.changed = true;
66+ }
67+
68+ // Start a new note expected to last about `length` seconds (for coloring).
69+ newNote(length: number): void {
70+ this.noteIndex = this.noteLength.length;
71+ this.noteLength.push(length);
72+ this.noteStart = this.n;
73+ this.changed = true;
74+ }
75+
76+ // Final length of the current note.
77+ setNoteLength(length: number): void {
78+ if (this.noteLength.length) this.noteLength[this.noteIndex] = length;
79+ this.changed = true;
80+ }
81+
82+ add(E: number, lambda: number, t: number): void {
83+ if (!Number.isFinite(E) || !Number.isFinite(lambda) || this.n >= MAX_POINTS || !this.noteLength.length) return;
84+ this.E[this.n] = E;
85+ this.L[this.n] = lambda;
86+ this.T[this.n] = t;
87+ this.note[this.n] = this.noteIndex;
88+ this.n++;
89+ this.changed = true;
90+ }
91+
92+ private px(E: number): number {
93+ return PAD + (E / this.eMax) * (this.width - 2 * PAD);
94+ }
95+
96+ private py(l: number): number {
97+ return HEIGHT - PAD - ((l - this.lMin) / (this.lMax - this.lMin)) * (HEIGHT - TOP - 2 * PAD);
98+ }
99+
100+ // Points from..to, colored by time into their note, binned into BINS colors.
101+ private drawPoints(ctx: CanvasRenderingContext2D, from: number, to: number, alpha: number): void {
102+ const { E, L, T, note } = this;
103+ const norm = Math.log(1 + 1 / LOG_T1);
104+ const bin = (i: number) => {
105+ const f = Math.min(1, T[i] / (this.noteLength[note[i]] || 1));
106+ return Math.min(BINS - 1, Math.floor((Math.log(1 + f / LOG_T1) / norm) * BINS));
107+ };
108+ for (let b = 0; b < BINS; b++) {
109+ const c = rampColor((b + 0.5) / BINS);
110+ ctx.fillStyle = `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
111+ for (let i = from; i < to; i++) {
112+ if (bin(i) === b) ctx.fillRect(this.px(E[i]) - DOT / 2, this.py(L[i]) - DOT / 2, DOT, DOT);
113+ }
114+ }
115+ }
116+
117+ draw(): void {
118+ if (!this.changed) return;
119+ this.changed = false;
120+ const { n, E, L, T, dpr } = this;
121+ // Axes: energy from 0; lambda including 0, padded.
122+ let eMax = 0, lMin = 0, lMax = 0;
123+ for (let i = 0; i < n; i++) {
124+ if (E[i] > eMax) eMax = E[i];
125+ if (L[i] < lMin) lMin = L[i];
126+ if (L[i] > lMax) lMax = L[i];
127+ }
128+ this.eMax = eMax > 0 ? 1.05 * eMax : 1;
129+ const span = lMax - lMin || 1;
130+ this.lMin = lMin - 0.05 * span;
131+ this.lMax = lMax + 0.05 * span;
132+
133+ const ctx = this.canvas.getContext("2d")!;
134+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
135+ ctx.clearRect(0, 0, this.width, HEIGHT);
136+ // lambda = 0 line.
137+ ctx.strokeStyle = "#ddd";
138+ ctx.lineWidth = 1;
139+ ctx.beginPath();
140+ ctx.moveTo(0, this.py(0));
141+ ctx.lineTo(this.width, this.py(0));
142+ ctx.stroke();
143+
144+ // Earlier notes (faded) from the cache, then the current note.
145+ const key = `${this.eMax}|${this.lMin}|${this.lMax}|${this.noteStart}|${this.noteLength.length}`;
146+ if (key !== this.cacheKey) {
147+ this.cacheKey = key;
148+ const cc = this.cache.getContext("2d")!;
149+ cc.setTransform(dpr, 0, 0, dpr, 0, 0);
150+ cc.clearRect(0, 0, this.width, HEIGHT);
151+ this.drawPoints(cc, 0, this.noteStart, 0.35);
152+ }
153+ ctx.setTransform(1, 0, 0, 1, 0, 0);
154+ ctx.drawImage(this.cache, 0, 0);
155+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
156+ this.drawPoints(ctx, this.noteStart, n, 0.85);
157+
158+ // Nearest point to the mouse.
159+ let near = -1;
160+ if (this.hover) {
161+ let best = 15 * 15; // within 15 px
162+ for (let i = 0; i < n; i++) {
163+ const dx = this.px(E[i]) - this.hover.x, dy = this.py(L[i]) - this.hover.y;
164+ if (dx * dx + dy * dy < best) {
165+ best = dx * dx + dy * dy;
166+ near = i;
167+ }
168+ }
169+ }
170+ if (near >= 0) {
171+ ctx.strokeStyle = "#23262b";
172+ ctx.lineWidth = 1.25;
173+ ctx.beginPath();
174+ ctx.arc(this.px(E[near]), this.py(L[near]), 4, 0, 2 * Math.PI);
175+ ctx.stroke();
176+ }
177+
178+ ctx.font = FONT;
179+ ctx.textBaseline = "top";
180+ ctx.fillStyle = "#2f5d8a";
181+ const legend =
182+ near >= 0
183+ ? `E ${E[near].toFixed(2)} J λ ${L[near].toFixed(3)} 1/s ${T[near].toFixed(2)} s into note`
184+ : `λ (1/s, 10 s window) vs E (J) · ${this.noteLength.length} note${this.noteLength.length === 1 ? "" : "s"}`;
185+ ctx.fillText(legend, 4, 3);
186+ ctx.fillStyle = "#999";
187+ ctx.textAlign = "right";
188+ ctx.fillText(`E 0 … ${this.eMax.toFixed(1)} · λ ${this.lMin.toFixed(2)} … ${this.lMax.toFixed(2)}`, this.width - 4, 3);
189+ ctx.textAlign = "left";
190+ ctx.fillText("λ = 0", 4, this.py(0) + 2);
191+ }
192+}
193+
194+// Color at u in [0, 1] along RAMP.
195+function rampColor(u: number): number[] {
196+ const s = u * (RAMP.length - 1);
197+ const i = Math.min(RAMP.length - 2, Math.floor(s)), w = s - i;
198+ return RAMP[i].map((a, k) => Math.round(a + (RAMP[i + 1][k] - a) * w));
199+}
src/spectrogram.tsmodified+33−15View file
@@ -4,6 +4,8 @@
44 // above the highest frequency with significant power in the time-averaged
55 // spectrum of the visible history.
66
7+import { attachCursor, type Cursor } from "./cursor";
8+
79 const HEIGHT = 160;
810 const RANGE_DB = 60; // color range below the peak
911 const CONTENT_DB = 30; // "significant" power for the frequency auto scale
@@ -273,11 +275,14 @@ class FFT {
273275 // adjacent columns are averaged in pairs (halving the time resolution) so
274276 // memory stays bounded. Colors are relative to the loudest column so far, so
275277 // the note's decay shows. Only new pixel columns are drawn, except when the
276-// axis, the frequency range, or the color scale changes.
278+// axis, the frequency range, or the color scale changes. An optional shared
279+// time cursor (see cursor.ts) is drawn as a vertical line.
277280 const MAX_COLS = 4096;
278281
279282 export class NoteSpectrogram {
280283 canvas: HTMLCanvasElement;
284+ private cursor: Cursor | null;
285+ private lastCursor = "";
281286 private width: number;
282287 private dt: number;
283288 private n: number;
@@ -302,7 +307,8 @@ export class NoteSpectrogram {
302307 private dirty = true; // full redraw needed
303308 private image: ImageData;
304309
305- constructor(opts: SpectrogramOptions) {
310+ constructor(opts: SpectrogramOptions, cursor: Cursor | null = null) {
311+ this.cursor = cursor;
306312 this.width = opts.width ?? 960;
307313 this.dt = opts.dt;
308314 this.n = opts.n;
@@ -321,6 +327,7 @@ export class NoteSpectrogram {
321327 this.canvas.width = this.width;
322328 this.canvas.height = HEIGHT;
323329 this.image = this.canvas.getContext("2d")!.createImageData(this.width, HEIGHT);
330+ if (cursor) attachCursor(this.canvas, cursor, (f) => f * this.span);
324331 }
325332
326333 // Start a new note shown over `span` seconds.
@@ -352,11 +359,6 @@ export class NoteSpectrogram {
352359 if (this.nSamples >= this.n && this.nSamples % this.hop === 0) this.addColumn();
353360 }
354361
355- // Seconds of note covered by the stored columns.
356- private covered(): number {
357- return (this.cols.length * this.merge * this.hop + this.n) * this.dt;
358- }
359-
360362 private addColumn(): void {
361363 const { buf, re, im, hann, n, bins } = this;
362364 let mean = 0;
@@ -406,21 +408,25 @@ export class NoteSpectrogram {
406408 if (fmax !== this.fmax) this.dirty = true;
407409 this.fmax = fmax;
408410 }
409- // Pixel columns whose time is covered by stored columns.
410- const ready = Math.min(this.width, Math.floor((this.covered() / this.span) * this.width));
411+ // Pixel columns whose nearest stored column exists (column c is centered
412+ // at t0 + c * colDt; the first one also covers the note's first t0).
413+ const colDt = this.merge * this.hop * this.dt; // s per stored column
414+ const t0 = this.n * this.dt; // the first column ends here
415+ const tReady = cols.length > 0 ? t0 + (cols.length - 0.5) * colDt : 0;
416+ const ready = Math.min(this.width, Math.max(0, Math.floor((tReady / this.span) * this.width)));
411417 if (this.dirty) this.drawnX = 0;
412- if (!this.dirty && ready <= this.drawnX) return;
418+ const cursorKey = this.cursor ? `${this.cursor.t}|${this.cursor.pinned}` : "";
419+ if (!this.dirty && ready <= this.drawnX && cursorKey === this.lastCursor) return;
420+ this.lastCursor = cursorKey;
413421
414422 const data = this.image.data;
415423 const peakDb = 10 * Math.log10(this.peak + 1e-30);
416- const colDt = this.merge * this.hop * this.dt; // s per stored column
417- const t0 = this.n * this.dt; // the first column ends here
418424 const from = this.dirty ? 0 : this.drawnX;
419425 const to = this.dirty ? this.width : ready;
420426 for (let x = from; x < to; x++) {
421427 const t = ((x + 0.5) / this.width) * this.span;
422- const c = Math.round((t - t0) / colDt);
423- const col = x < ready && c >= 0 && c < cols.length ? cols[c] : null;
428+ const c = Math.min(cols.length - 1, Math.max(0, Math.round((t - t0) / colDt)));
429+ const col = x < ready ? cols[c] : null;
424430 for (let y = 0; y < HEIGHT; y++) {
425431 const p = 4 * (y * this.width + x);
426432 if (!col) {
@@ -451,9 +457,21 @@ export class NoteSpectrogram {
451457 }
452458 ctx.setLineDash([]);
453459 ctx.font = "12px ui-monospace, monospace";
454- label(ctx, `${this.fmax} Hz`, 2, 2);
460+ if (cols.length > 0) label(ctx, `${this.fmax} Hz`, 2, 2);
455461 label(ctx, "0", 2, HEIGHT - 18);
456462 const end = `${this.span.toFixed(1)} s`;
457463 label(ctx, end, this.width - ctx.measureText(end).width - 8, HEIGHT - 18);
464+ const ct = this.cursor?.t ?? null;
465+ if (ct !== null && ct >= 0 && ct <= this.span) {
466+ const cx = (ct / this.span) * this.width;
467+ ctx.strokeStyle = this.cursor!.pinned ? "#fff" : "rgba(255,255,255,0.6)";
468+ ctx.lineWidth = 1;
469+ ctx.beginPath();
470+ ctx.moveTo(cx, 0);
471+ ctx.lineTo(cx, HEIGHT);
472+ ctx.stroke();
473+ const at = `at ${ct.toFixed(2)} s`;
474+ label(ctx, at, this.width - ctx.measureText(at).width - 8, 2);
475+ }
458476 }
459477 }
src/traces.tsmodified+52−9View file
@@ -3,7 +3,11 @@
33 // toggle chip. The y range fits the samples in the visible window (linear or
44 // log scale). Each pixel column draws only the min and max of its samples, so
55 // cost does not grow with the sample rate and short spikes are kept.
6-// Non-finite values (NaN) leave a gap.
6+// Non-finite values (NaN) leave a gap. Hovering shows a time cursor (shared by
7+// the panel's charts, and optionally with other charts) and the values there;
8+// a click pins it.
9+
10+import { attachCursor, newCursor, type Cursor } from "./cursor";
711
812 export interface Series {
913 label: string;
@@ -39,17 +43,28 @@ export class TracePanel {
3943 private dpr = window.devicePixelRatio || 1;
4044 private width: number; // chart width, CSS pixels
4145 private fixed: { t0: number; span: number } | null = null; // fixed x range instead of scrolling
46+ readonly cursor: Cursor;
47+ private ranges: { t0: number; win: number }[]; // time range of each chart at its last draw
4248 // Per draw: buffer index and pixel column of each visible sample, oldest first.
4349 private idx = new Int32Array(CAPACITY);
4450 private col = new Int32Array(CAPACITY);
4551
4652 // Toggle chips go into `toggles`, charts into `charts`.
47- constructor(toggles: HTMLElement, charts: HTMLElement, groups: Group[], window: number, width = 480) {
53+ constructor(
54+ toggles: HTMLElement,
55+ charts: HTMLElement,
56+ groups: Group[],
57+ window: number,
58+ width = 480,
59+ cursor: Cursor = newCursor(),
60+ ) {
4861 this.width = width;
62+ this.cursor = cursor;
63+ this.ranges = groups.map(() => ({ t0: 0, win: window }));
4964 this.groups = groups;
5065 this.window = window;
5166 this.v = groups.map((g) => g.series.map(() => new Float64Array(CAPACITY)));
52- this.canvases = groups.map((g) => {
67+ this.canvases = groups.map((g, j) => {
5368 const canvas = document.createElement("canvas");
5469 canvas.width = this.width * this.dpr;
5570 canvas.height = HEIGHT * this.dpr;
@@ -64,6 +79,7 @@ export class TracePanel {
6479 chip.className = "chip";
6580 chip.append(box, text);
6681 toggles.appendChild(chip);
82+ attachCursor(canvas, cursor, (f) => this.ranges[j].t0 + f * this.ranges[j].win);
6783 return canvas;
6884 });
6985 for (const c of this.canvases) charts.appendChild(c);
@@ -98,9 +114,10 @@ export class TracePanel {
98114 }
99115
100116 // Indices and pixel columns of the samples in the last `win` seconds.
117+ private tStart = 0;
101118 private layout(win: number): number {
102119 const newest = (this.head - 1 + CAPACITY) % CAPACITY;
103- const tStart = this.fixed ? this.fixed.t0 : this.t[newest] - win;
120+ const tStart = (this.tStart = this.fixed ? this.fixed.t0 : this.t[newest] - win);
104121 let n = 0;
105122 while (n < this.count && this.t[(newest - n + CAPACITY) % CAPACITY] >= tStart) n++;
106123 const first = (newest - n + 1 + CAPACITY) % CAPACITY;
@@ -123,6 +140,19 @@ export class TracePanel {
123140 if (canvas.hidden) return;
124141 const win = this.fixed ? this.fixed.span : (g.window ?? this.window);
125142 if (win !== laidOut) n = this.layout((laidOut = win));
143+ this.ranges[j] = { t0: this.tStart, win };
144+ // Sample nearest the cursor, if the cursor is on this chart's time range.
145+ const ct = this.cursor.t;
146+ let cm = -1;
147+ if (ct !== null && ct >= this.tStart && ct <= this.tStart + win && n > 0) {
148+ let a = 0, b = n - 1;
149+ while (b - a > 1) {
150+ const mid = (a + b) >> 1;
151+ if (this.t[idx[mid]] < ct) a = mid;
152+ else b = mid;
153+ }
154+ cm = Math.abs(this.t[idx[a]] - ct) <= Math.abs(this.t[idx[b]] - ct) ? a : b;
155+ }
126156 const tv = g.log ? (x: number) => (x > 0 ? Math.log10(x) : NaN) : (x: number) => x;
127157 const ctx = canvas.getContext("2d")!;
128158 ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
@@ -195,11 +225,22 @@ export class TracePanel {
195225 ctx.stroke();
196226 });
197227
228+ if (cm >= 0) {
229+ const cx = ((ct! - this.tStart) / win) * this.width;
230+ ctx.strokeStyle = this.cursor.pinned ? "#555" : "#aaa";
231+ ctx.lineWidth = 1;
232+ ctx.beginPath();
233+ ctx.moveTo(cx, TOP);
234+ ctx.lineTo(cx, HEIGHT);
235+ ctx.stroke();
236+ }
237+
198238 ctx.font = FONT;
199239 ctx.textBaseline = "top";
200240 let x = 4;
241+ const at = cm >= 0 ? idx[cm] : newest; // sample whose values the legend shows
201242 g.series.forEach((s, k) => {
202- const v = this.v[j][k][newest];
243+ const v = this.v[j][k][at];
203244 const text = `${s.label} ${Number.isFinite(v) ? fmt(v) : "–"}`;
204245 ctx.fillStyle = COLORS[k % COLORS.length];
205246 ctx.fillText(text, x, 3);
@@ -209,13 +250,15 @@ export class TracePanel {
209250 ctx.fillStyle = MUTED;
210251 ctx.fillText(g.unit, x, 3);
211252 }
212- if (!empty) {
253+ ctx.fillStyle = MUTED;
254+ ctx.textAlign = "right";
255+ if (cm >= 0) {
256+ ctx.fillText(`at ${this.t[at].toFixed(2)} s${this.cursor.pinned ? " (pinned)" : ""}`, this.width - 4, 3);
257+ } else if (!empty) {
213258 const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y));
214- ctx.fillStyle = MUTED;
215- ctx.textAlign = "right";
216259 ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, this.width - 4, 3);
217- ctx.textAlign = "left";
218260 }
261+ ctx.textAlign = "left";
219262 });
220263 }
221264 }
↑↓ move↵openescclose