/ listening-for-chaos / double-pendulum-chaos
Sign in
listening-for-chaos / double-pendulum-chaos
Help buttons on each panel; wider spectrogram frequency range; Signal selector; clearer muted state
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3833e7075439 parent 2a464d3 Browse files
5 changed files+105−25
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 "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.
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; when muted it is tinted red and reads "Muted") starts muted and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Signal" (which coordinate is heard), 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 (frequency axis up to just above the highest frequency that comes within 40 dB of the peak at any time in the note, so the broadband start of a chaotic note stays in view), 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. Each card and panel (and Parameters) has a small "?" button that opens a short help dialog (`src/help.ts`; the texts are `<template>`s in `index.html`). 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
index.htmlmodified+64−11View file
@@ -56,8 +56,10 @@
5656 button.primary:hover { filter: brightness(1.08); }
5757 button:disabled { color: #b5b5b0; cursor: default; }
5858 button:disabled:hover { border-color: var(--line); }
59- button.mute { width: 36px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); }
60- button.mute svg { width: 18px; height: 18px; }
59+ button.mute { min-width: 36px; height: 30px; padding: 0 9px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
60+ button.mute svg { width: 18px; height: 18px; flex: none; }
61+ button.mute.is-muted { background: #fbece4; border-color: #e8c2ad; color: #b0482a; font-size: 12px; }
62+ button.mute.is-muted:hover { border-color: #d9a68c; }
6163 button.mute.audible { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
6264
6365 #controls { margin-top: 14px; }
@@ -70,12 +72,12 @@
7072 #ghost-sep { width: 100%; }
7173
7274 details { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
73- summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; }
75+ summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; display: flex; align-items: center; gap: 4px; }
7476 summary::-webkit-details-marker { display: none; }
7577 summary::before { content: "▸ "; }
7678 details[open] summary::before { content: "▾ "; }
7779 details.panel { border-top: 1px solid var(--line); padding-top: 16px; margin-top: 16px; }
78- details.panel summary { display: flex; align-items: baseline; gap: 10px; }
80+ details.panel summary { gap: 10px; }
7981 details.panel summary .label { font-size: 12px; color: var(--text); }
8082 details.panel[open] summary { margin-bottom: 14px; }
8183 #pc-energy { max-width: 360px; }
@@ -87,7 +89,24 @@
8789 #param-status { color: #a33; font-size: 12px; }
8890 #params { margin-top: 10px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
8991
90- .card-head { color: var(--text); font-size: 12px; margin-bottom: 12px; }
92+ .card-head { color: var(--text); font-size: 12px; margin-bottom: 12px; display: flex; align-items: center; }
93+
94+ button.help {
95+ width: 20px; height: 20px; padding: 0; margin-left: auto; flex: none; border-radius: 50%;
96+ font: 600 11px/1 var(--sans); color: #a5a7aa; background: transparent; letter-spacing: 0; text-transform: none;
97+ }
98+ button.help:hover { color: var(--accent); border-color: #b9cde0; }
99+ #pendulum-card { position: relative; }
100+ #pendulum-card > button.help { position: absolute; top: 12px; right: 12px; }
101+ dialog#help {
102+ max-width: min(460px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: 10px;
103+ color: var(--text); background: var(--card); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
104+ }
105+ dialog#help::backdrop { background: rgba(35, 38, 43, 0.25); }
106+ #help-body { padding: 18px 20px 4px; font-size: 13.5px; line-height: 1.5; }
107+ #help-body h2 { font: 400 19px/1.3 var(--serif); margin: 0 0 8px; }
108+ #help-body p { margin: 0 0 10px; }
109+ #help-actions { display: flex; justify-content: flex-end; padding: 0 20px 16px; }
91110 .chips { display: flex; flex-wrap: wrap; gap: 6px; }
92111 .chip { position: relative; cursor: pointer; user-select: none; }
93112 .chip input { position: absolute; opacity: 0; pointer-events: none; }
@@ -119,7 +138,8 @@
119138 </header>
120139
121140 <div class="grid">
122- <section class="card">
141+ <section class="card" id="pendulum-card">
142+ <button type="button" class="help" data-help="pendulum" title="About the pendulum" aria-label="About the pendulum">?</button>
123143 <canvas id="canvas"></canvas>
124144 <form id="controls" class="row">
125145 <span id="energy-label" class="label">Strike energy</span>
@@ -139,7 +159,7 @@
139159 <span id="ghost-sep" class="num muted"></span>
140160 </div>
141161 <details>
142- <summary>Parameters</summary>
162+ <summary>Parameters <button type="button" class="help" data-help="params" title="About the parameters" aria-label="About the parameters">?</button></summary>
143163 <form id="param-form">
144164 <label><span>m<sub>1</sub></span> <input id="p-m1" type="number" step="any" /> <span class="unit">kg</span></label>
145165 <label><span>m<sub>2</sub></span> <input id="p-m2" type="number" step="any" /> <span class="unit">kg</span></label>
@@ -166,17 +186,17 @@
166186 </section>
167187
168188 <section class="card">
169- <div class="card-head label">Real time</div>
189+ <div class="card-head label">Real time <button type="button" class="help" data-help="realtime" title="About the real-time charts" aria-label="About the real-time charts">?</button></div>
170190 <div class="chips" id="toggles"></div>
171191 <div id="traces"></div>
172192 </section>
173193 </div>
174194
175195 <details class="card panel" id="audio">
176- <summary><span class="label">Sped up · listen</span> <span id="audio-clock" class="muted"></span></summary>
196+ <summary><span class="label">Sped up · listen</span> <span id="audio-clock" class="muted"></span> <button type="button" class="help" data-help="sound" title="About the sped-up run" aria-label="About the sped-up run">?</button></summary>
177197 <div class="row">
178198 <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
179- <label><span class="label">Sound of</span> <select id="signal"></select></label>
199+ <label><span class="label">Signal</span> <select id="signal"></select></label>
180200 <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
181201 <span id="audio-chips" class="chips">
182202 <label class="chip"><input id="audio-spec-toggle" type="checkbox" checked /><span>Spectrogram</span></label>
@@ -199,7 +219,7 @@
199219 </details>
200220
201221 <details class="card panel" id="poincare">
202- <summary><span class="label">Poincaré section</span></summary>
222+ <summary><span class="label">Poincaré section</span> <button type="button" class="help" data-help="section" title="About the Poincaré section" aria-label="About the Poincaré section">?</button></summary>
203223 <div class="row">
204224 <span class="label">Energy</span>
205225 <input id="pc-energy" type="range" value="17" min="0" max="120" step="0.1" aria-label="Section energy" />
@@ -220,6 +240,39 @@
220240 </figure>
221241 </details>
222242 </main>
243+
244+ <dialog id="help">
245+ <div id="help-body"></div>
246+ <div id="help-actions"><button id="help-close" type="button">Close</button></div>
247+ </dialog>
248+
249+ <template id="help-pendulum">
250+ <h2>The pendulum</h2>
251+ <p>A double pendulum, shown in real time. Strike starts it from hanging rest with the energy on the slider. At low energy the motion is regular; at higher energies most motions are chaotic, so a small change in the start leads to a completely different motion.</p>
252+ <p>With damping (τ under Parameters) the energy slowly decays, and the current energy is shown at the top left. Without damping the slider changes the energy as it runs.</p>
253+ <p>Ghost adds a second pendulum started a tiny angle away. In chaotic motion the two separate exponentially; in regular motion they stay together. The space bar pauses.</p>
254+ </template>
255+ <template id="help-params">
256+ <h2>Parameters</h2>
257+ <p>Masses, arm lengths, gravity, and the decay time τ of the amplitude (drag on the bobs; 0 for none). Apply restarts the pendulum at rest. They apply to everything on the page.</p>
258+ <p>The presets change the outer arm's length, which sets the ratio of the two normal-mode frequencies (listed below the form). These are the pitches heard at low energy.</p>
259+ </template>
260+ <template id="help-realtime">
261+ <h2>Real time</h2>
262+ <p>Charts of the pendulum on the left, on the same clock. Click a chip to show or hide a chart. Hover over a chart to read values; click to pin the cursor.</p>
263+ <p>Error is the integrator's relative error in the energy balance (the energy plus what damping has removed); it should stay tiny. Lyapunov is the largest Lyapunov exponent: the rate (1/s) at which nearby motions separate, positive for chaos and near 0 for regular motion. Separation, shown with the ghost on, is the ghost's distance from the pendulum on a log scale.</p>
264+ </template>
265+ <template id="help-sound">
266+ <h2>Sped up · listen</h2>
267+ <p>A copy of the same strike, run fast enough that the swinging becomes sound: Pitch sets where the lower normal mode lands. The whole decay plays in a few seconds. While this panel is open, each Strike plays a note.</p>
268+ <p>Signal chooses which coordinate is heard. Chaotic motion sounds noisy; as the energy decays it settles into steady tones.</p>
269+ <p>The charts span the whole note, strike at the left: spectrogram, Lyapunov exponent, energy, and λ against energy collected over notes.</p>
270+ </template>
271+ <template id="help-section">
272+ <h2>Poincaré section</h2>
273+ <p>A map of the motions at one energy, without damping. Each time the inner arm swings down through vertical moving right, the outer arm's angle θ2 and momentum p2 are plotted as a dot. Regular motions trace closed curves; chaotic ones scatter over a region. The outline bounds what is reachable at this energy.</p>
274+ <p>The pendulum's own crossings are dark rings. Follow makes the section's energy track the pendulum (real time) or the playing note (sped up). Hover to pick out an orbit; click to start a new one there, which with Click to strike also strikes the pendulum from that point.</p>
275+ </template>
223276 <script type="module" src="/src/main.ts"></script>
224277 </body>
225278 </html>
src/help.tsadded+21−0View file
@@ -0,0 +1,21 @@
1+// Help: each "?" button (data-help="name") opens one dialog showing the
2+// contents of <template id="help-name">. Esc, Close, or a click outside the
3+// dialog closes it. A "?" inside a <summary> does not fold its panel.
4+
5+export function setupHelp(): void {
6+ const dialog = document.getElementById("help") as HTMLDialogElement;
7+ const body = document.getElementById("help-body")!;
8+ for (const button of document.querySelectorAll<HTMLButtonElement>("button.help")) {
9+ button.addEventListener("click", (e) => {
10+ e.preventDefault();
11+ e.stopPropagation();
12+ const t = document.getElementById(`help-${button.dataset.help}`) as HTMLTemplateElement;
13+ body.replaceChildren(t.content.cloneNode(true));
14+ dialog.showModal();
15+ });
16+ }
17+ document.getElementById("help-close")!.addEventListener("click", () => dialog.close());
18+ dialog.addEventListener("click", (e) => {
19+ if (e.target === dialog) dialog.close(); // the backdrop
20+ });
21+}
src/main.tsmodified+7−2View file
@@ -1,6 +1,7 @@
11 import { AudioOut } from "./audio";
22 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
33 import { newCursor } from "./cursor";
4+import { setupHelp } from "./help";
45 import { PoincarePanel, formatEnergy } from "./poincare";
56 import { Lyapunov } from "./lyapunov";
67 import { ScatterPlot } from "./scatter";
@@ -12,6 +13,8 @@ import { Sim } from "./sim";
1213 import { NoteSpectrogram } from "./spectrogram";
1314 import { TracePanel } from "./traces";
1415
16+setupHelp();
17+
1518 const ALPHA = 0.5; // fraction of energy in the lower mode
1619 const STRIKE_JITTER = 1e-9; // random relative perturbation of each strike (see Sim.strike)
1720 // Parameters; changed with the parameter form (Apply restarts everything).
@@ -355,16 +358,18 @@ audioPanel.addEventListener("toggle", async () => {
355358 document.getElementById("pc-follow")!.addEventListener("change", (e) => {
356359 if ((e.target as HTMLSelectElement).value === "sound") audioPanel.open = true;
357360 });
358-// Speaker icon: crossed out when muted, with sound waves when audible.
361+// Speaker icon: with sound waves when audible; crossed out, tinted, and
362+// labelled "Muted" when muted, so the silence is not mistaken for a fault.
359363 const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
360364 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>`;
361365 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>`;
362366 const showMute = () => {
363367 const label = audio.muted ? "Unmute" : "Mute";
364- muteButton.innerHTML = audio.muted ? ICON_MUTED : ICON_AUDIBLE;
368+ muteButton.innerHTML = audio.muted ? `${ICON_MUTED}<span>Muted</span>` : ICON_AUDIBLE;
365369 muteButton.title = label;
366370 muteButton.setAttribute("aria-label", label);
367371 muteButton.classList.toggle("audible", !audio.muted);
372+ muteButton.classList.toggle("is-muted", audio.muted);
368373 };
369374 showMute();
370375 muteButton.addEventListener("click", () => {
src/spectrogram.tsmodified+12−11View file
@@ -1,14 +1,15 @@
11 // 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
4-// above the highest frequency with significant power in the time-averaged
5-// spectrum.
4+// above the highest frequency that comes within CONTENT_DB of the peak at any
5+// time (a time average would hide the broadband start of a chaotic note under
6+// its long, quiet, tonal tail).
67
78 import { attachCursor, type Cursor } from "./cursor";
89
9-const HEIGHT = 160;
10+const HEIGHT = 200;
1011 const RANGE_DB = 60; // color range below the peak
11-const CONTENT_DB = 30; // "significant" power for the frequency auto scale
12+const CONTENT_DB = 40; // "significant" power for the frequency auto scale
1213
1314 export interface SpectrogramOptions {
1415 dt: number; // sample spacing (s)
@@ -122,7 +123,7 @@ export class NoteSpectrogram {
122123 private merge = 1; // raw columns per stored column
123124 private pending: Float32Array; // raw columns being averaged into the next stored one
124125 private pendingCount = 0;
125- private sum: Float64Array; // sum of all raw columns (time-averaged spectrum)
126+ private hold: Float64Array; // largest power in each bin over all raw columns
126127 private peak = 0;
127128 private fmax = 1;
128129 private span = 10; // s shown across the width
@@ -145,7 +146,7 @@ export class NoteSpectrogram {
145146 this.re = new Float64Array(opts.nfft);
146147 this.im = new Float64Array(opts.nfft);
147148 this.pending = new Float32Array(this.bins);
148- this.sum = new Float64Array(this.bins);
149+ this.hold = new Float64Array(this.bins);
149150 this.canvas = document.createElement("canvas");
150151 this.canvas.width = this.width;
151152 this.canvas.height = HEIGHT;
@@ -160,7 +161,7 @@ export class NoteSpectrogram {
160161 this.merge = 1;
161162 this.pending.fill(0);
162163 this.pendingCount = 0;
163- this.sum.fill(0);
164+ this.hold.fill(0);
164165 this.peak = 0;
165166 this.span = span;
166167 this.dirty = true;
@@ -195,7 +196,7 @@ export class NoteSpectrogram {
195196 for (let k = 0; k < bins; k++) {
196197 const p = re[k] * re[k] + im[k] * im[k];
197198 this.pending[k] += p / this.merge;
198- this.sum[k] += p;
199+ if (p > this.hold[k]) this.hold[k] = p;
199200 }
200201 if (++this.pendingCount < this.merge) return;
201202 const col = this.pending;
@@ -222,10 +223,10 @@ export class NoteSpectrogram {
222223 draw(): void {
223224 const { bins, cols } = this;
224225 const df = 1 / (2 * (bins - 1) * this.dt);
225- let sumPeak = 0;
226- for (let k = 1; k < bins; k++) if (this.sum[k] > sumPeak) sumPeak = this.sum[k];
226+ let holdPeak = 0;
227+ for (let k = 1; k < bins; k++) if (this.hold[k] > holdPeak) holdPeak = this.hold[k];
227228 let top = bins - 1;
228- while (top > 1 && this.sum[top] < sumPeak * 10 ** (-CONTENT_DB / 10)) top--;
229+ while (top > 1 && this.hold[top] < holdPeak * 10 ** (-CONTENT_DB / 10)) top--;
229230 if (cols.length > 0) {
230231 const fmax = Math.min(niceCeil(1.2 * top * df), (bins - 1) * df);
231232 if (fmax !== this.fmax) this.dirty = true;
↑↓ move↵openescclose