/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Save WAV
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 8e1f4d1c2327 parent 9861618 Browse files
4 changed files+69−2
src/double-pendulum/README.mdmodified+1−1View file
@@ -2,7 +2,7 @@
22
33 Interactive double pendulum, step 1 of "Listening for chaos". Work in progress.
44
5-Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause (or the space bar) pauses everything: the animation and the sound (the audio clock is suspended, so the note and every display fed by it stop where they are); Resume or Strike continues. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or "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.
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. Save WAV saves the current or last note as heard (as received from the audio worker, up to 120 s; 16-bit mono PCM, peak at 0.9; `src/wav.ts`; named e.g. double-pendulum-50.0J-x2-100Hz.wav). 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-chaos--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
88
src/double-pendulum/index.htmlmodified+2−1View file
@@ -196,6 +196,7 @@
196196 <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>
197197 <div class="row">
198198 <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
199+ <button id="save-wav" type="button" disabled title="Save the current or last note as a WAV file">Save WAV</button>
199200 <label><span class="label">Signal</span> <select id="signal"></select></label>
200201 <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
201202 <span id="audio-chips" class="chips">
@@ -265,7 +266,7 @@
265266 <template id="help-sound">
266267 <h2>Sped up · listen</h2>
267268 <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>Save WAV saves the current or last note as heard (16-bit mono, normalized). Signal chooses which coordinate is heard. Chaotic motion sounds noisy; as the energy decays it settles into steady tones.</p>
269270 <p>The charts span the whole note, strike at the left: spectrogram, Lyapunov exponent, energy, and λ against energy collected over notes.</p>
270271 </template>
271272 <template id="help-section">
src/double-pendulum/main.tsmodified+28−0View file
@@ -12,6 +12,7 @@ import { SIGNALS, signalByKey } from "./signals";
1212 import { Sim } from "./sim";
1313 import { NoteSpectrogram } from "./spectrogram";
1414 import { TracePanel } from "./traces";
15+import { saveFile, wavBlob } from "./wav";
1516
1617 setupHelp();
1718
@@ -314,6 +315,11 @@ const audio = new AudioOut(P, ALPHA, {
314315 onSamples: (data, lambda, energy, crossings) => {
315316 queue.push({ data, lambda, energy, crossings, next: 0 });
316317 received += data.length;
318+ if (recordedLength + data.length <= MAX_RECORD * audio.sampleRate) {
319+ recorded.push(data);
320+ recordedLength += data.length;
321+ }
322+ saveButton.disabled = false;
317323 },
318324 onEnded: () => {
319325 setNoteSpan(received / audio.sampleRate);
@@ -321,8 +327,30 @@ const audio = new AudioOut(P, ALPHA, {
321327 },
322328 });
323329
330+// Save WAV: the current (or last) note as heard, as received from the
331+// worker (16-bit mono, peak at 0.9), up to MAX_RECORD seconds (an undamped
332+// note never ends).
333+const MAX_RECORD = 120;
334+const saveButton = document.getElementById("save-wav") as HTMLButtonElement;
335+let recorded: Float32Array[] = [];
336+let recordedLength = 0;
337+let noteFile = "";
338+saveButton.addEventListener("click", () => {
339+ const x = new Float32Array(recordedLength);
340+ let o = 0;
341+ for (const c of recorded) {
342+ x.set(c, o);
343+ o += c.length;
344+ }
345+ saveFile(wavBlob(x, audio.sampleRate), noteFile);
346+});
347+
324348 // Start a note from the animation's current state.
325349 function startNote() {
350+ recorded = [];
351+ recordedLength = 0;
352+ const lengths = P.l1 === 1 && P.l2 === 1 ? "" : `l1-${P.l1}-l2-${P.l2}-`;
353+ noteFile = `double-pendulum-${lengths}${energy(P, sim.y).toPrecision(3)}J-${signal.key}-${pitchHz().toFixed(0)}Hz.wav`;
326354 queue.length = 0;
327355 queueOffset = fed = received = 0;
328356 audioTraces.clear();
src/double-pendulum/wav.tsadded+38−0View file
@@ -0,0 +1,38 @@
1+// WAV export: a note as a 16-bit PCM mono WAV file, scaled so its peak is at
2+// 0.9 of full scale (so every saved note is equally loud and none clips).
3+
4+export function wavBlob(x: Float32Array, sampleRate: number): Blob {
5+ let peak = 0;
6+ for (const v of x) peak = Math.max(peak, Math.abs(v));
7+ const g = peak > 0 ? 0.9 / peak : 0;
8+ const buf = new ArrayBuffer(44 + 2 * x.length);
9+ const v = new DataView(buf);
10+ const text = (o: number, s: string) => {
11+ for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
12+ };
13+ text(0, "RIFF");
14+ v.setUint32(4, 36 + 2 * x.length, true);
15+ text(8, "WAVE");
16+ text(12, "fmt ");
17+ v.setUint32(16, 16, true); // fmt chunk size
18+ v.setUint16(20, 1, true); // PCM
19+ v.setUint16(22, 1, true); // mono
20+ v.setUint32(24, sampleRate, true);
21+ v.setUint32(28, 2 * sampleRate, true); // bytes per second
22+ v.setUint16(32, 2, true); // bytes per frame
23+ v.setUint16(34, 16, true); // bits per sample
24+ text(36, "data");
25+ v.setUint32(40, 2 * x.length, true);
26+ for (let i = 0; i < x.length; i++) v.setInt16(44 + 2 * i, Math.round(32767 * Math.max(-1, Math.min(1, g * x[i]))), true);
27+ return new Blob([buf], { type: "audio/wav" });
28+}
29+
30+// Hand a file to the browser to save.
31+export function saveFile(blob: Blob, name: string): void {
32+ const url = URL.createObjectURL(blob);
33+ const a = document.createElement("a");
34+ a.href = url;
35+ a.download = name;
36+ a.click();
37+ setTimeout(() => URL.revokeObjectURL(url), 1000);
38+}
↑↓ move↵openescclose