/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Move spectrogram and traces to src/common/ (fput's versions, a superset of the pendulum's)
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 596254000ca6 parent 5ae1944 Browse files
9 changed files+11−622
src/fput-chain/spectrogram.ts →src/common/spectrogram.tsrenamed+1−1View file
@@ -5,7 +5,7 @@
55 // time (a time average would hide the broadband start of a chaotic note under
66 // its long, quiet, tonal tail).
77
8-import { attachCursor, type Cursor } from "../common/cursor";
8+import { attachCursor, type Cursor } from "./cursor";
99
1010 const HEIGHT = 200;
1111 const RANGE_DB = 60; // color range below the peak
src/fput-chain/traces.ts →src/common/traces.tsrenamed+1−1View file
@@ -9,7 +9,7 @@
99 // the panel's charts, and optionally with other charts) and the values there;
1010 // a click pins it.
1111
12-import { attachCursor, newCursor, type Cursor } from "../common/cursor";
12+import { attachCursor, newCursor, type Cursor } from "./cursor";
1313
1414 export interface Series {
1515 label: string;
src/double-pendulum/README.mdmodified+1−1View file
@@ -20,4 +20,4 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/doub
2020
2121 Audio: a Web Worker (`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 (`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.
2222
23-Code: `pendulum.ts` (physics), `sim.ts` (running simulation with energy target), `audio.ts` (audio setup), `signals.ts` (audible quantities), `lyapunov.ts` (Lyapunov exponent), `../common/scatter.ts` (lambda vs energy), `../common/cursor.ts` (shared hover/pin cursor), `poincare-core.ts`, `poincare-worker.ts`, `poincare.ts` (Poincare section), `atlas.ts` (cached sections for following the sound), `audio-shared.ts` (constants shared with the worker), `traces.ts` (strip charts), `spectrogram.ts` (whole-note spectrogram), `main.ts` (page).
23+Code: `pendulum.ts` (physics), `sim.ts` (running simulation with energy target), `audio.ts` (audio setup), `signals.ts` (audible quantities), `lyapunov.ts` (Lyapunov exponent), `../common/scatter.ts` (lambda vs energy), `../common/cursor.ts` (shared hover/pin cursor), `poincare-core.ts`, `poincare-worker.ts`, `poincare.ts` (Poincare section), `atlas.ts` (cached sections for following the sound), `audio-shared.ts` (constants shared with the worker), `../common/traces.ts` (strip charts), `../common/spectrogram.ts` (whole-note spectrogram), `main.ts` (page).
src/double-pendulum/main.tsmodified+2−2View file
@@ -10,8 +10,8 @@ import {
1010 } from "./pendulum";
1111 import { SIGNALS, signalByKey } from "./signals";
1212 import { Sim } from "./sim";
13-import { NoteSpectrogram } from "./spectrogram";
14-import { TracePanel } from "./traces";
13+import { NoteSpectrogram } from "../common/spectrogram";
14+import { TracePanel } from "../common/traces";
1515 import { saveFile, wavBlob } from "../common/wav";
1616
1717 setupHelp();
src/double-pendulum/spectrogram.tsdeleted+0−301View file
@@ -1,301 +0,0 @@
1-// Spectrogram of one whole note. Samples arrive at a fixed spacing dt (s);
2-// every `hop` samples a Hann-windowed FFT of the last `n` samples (zero
3-// padded to `nfft`) becomes one column. The frequency axis tops out just
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).
7-
8-import { attachCursor, type Cursor } from "../common/cursor";
9-
10-const HEIGHT = 200;
11-const RANGE_DB = 60; // color range below the peak
12-const CONTENT_DB = 40; // "significant" power for the frequency auto scale
13-
14-export interface SpectrogramOptions {
15- dt: number; // sample spacing (s)
16- n: number; // samples per FFT window
17- nfft: number; // zero-padded FFT length (power of two)
18- hop: number; // samples between columns
19- refFreqs: number[]; // frequencies to mark with dashed lines (Hz)
20- width?: number; // columns kept and drawn (canvas width), default 480
21-}
22-
23-// White text on a dark box, readable over any part of the image.
24-function label(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
25- ctx.fillStyle = "rgba(0,0,0,0.6)";
26- ctx.fillRect(x, y, ctx.measureText(text).width + 6, 16);
27- ctx.fillStyle = "#fff";
28- ctx.textBaseline = "top";
29- ctx.fillText(text, x + 3, y + 2);
30-}
31-
32-// Smallest 1, 2 or 5 times a power of ten that is >= x.
33-function niceCeil(x: number): number {
34- const p = Math.pow(10, Math.floor(Math.log10(x)));
35- for (const m of [1, 2, 5, 10]) if (m * p >= x) return +(m * p).toPrecision(1);
36- return 10 * p;
37-}
38-
39-// Inferno-like colormap, u in [0, 1] (clamped).
40-const STOPS = [
41- [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99],
42- [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
43-];
44-function colormap(u: number, out: Uint8ClampedArray, p: number): void {
45- const s = Math.max(0, Math.min(1, u)) * (STOPS.length - 1);
46- const i = Math.min(STOPS.length - 2, Math.floor(s)), w = s - i;
47- for (let ch = 0; ch < 3; ch++) out[p + ch] = STOPS[i][ch] * (1 - w) + STOPS[i + 1][ch] * w;
48-}
49-
50-// In-place radix-2 complex FFT of a fixed power-of-two length, with
51-// precomputed twiddle factors.
52-class FFT {
53- private n: number;
54- private cos: Float64Array;
55- private sin: Float64Array;
56-
57- constructor(n: number) {
58- this.n = n;
59- this.cos = new Float64Array(n / 2);
60- this.sin = new Float64Array(n / 2);
61- for (let k = 0; k < n / 2; k++) {
62- this.cos[k] = Math.cos((-2 * Math.PI * k) / n);
63- this.sin[k] = Math.sin((-2 * Math.PI * k) / n);
64- }
65- }
66-
67- transform(re: Float64Array, im: Float64Array): void {
68- const n = this.n;
69- for (let i = 1, j = 0; i < n; i++) {
70- let bit = n >> 1;
71- for (; j & bit; bit >>= 1) j ^= bit;
72- j ^= bit;
73- if (i < j) {
74- let tmp = re[i]; re[i] = re[j]; re[j] = tmp;
75- tmp = im[i]; im[i] = im[j]; im[j] = tmp;
76- }
77- }
78- for (let len = 2; len <= n; len <<= 1) {
79- const half = len >> 1, step = n / len;
80- for (let i = 0; i < n; i += len) {
81- for (let k = 0; k < half; k++) {
82- const wr = this.cos[k * step], wi = this.sin[k * step];
83- const a = i + k, b = a + half;
84- const xr = re[b] * wr - im[b] * wi, xi = re[b] * wi + im[b] * wr;
85- re[b] = re[a] - xr;
86- im[b] = im[a] - xi;
87- re[a] += xr;
88- im[a] += xi;
89- }
90- }
91- }
92- }
93-}
94-
95-// Spectrogram of one whole note, from its start (left edge) to its end (right
96-// edge), without scrolling. The time axis spans `span` seconds, set at the
97-// start (predicted length) and changed with setSpan (extended if the note
98-// runs longer, fitted when it ends). All columns are kept; past MAX_COLS,
99-// adjacent columns are averaged in pairs (halving the time resolution) so
100-// memory stays bounded. Colors are relative to the loudest column so far, so
101-// the note's decay shows. Only new pixel columns are drawn, except when the
102-// axis, the frequency range, or the color scale changes. An optional shared
103-// time cursor (see cursor.ts) is drawn as a vertical line.
104-const MAX_COLS = 4096;
105-
106-export class NoteSpectrogram {
107- canvas: HTMLCanvasElement;
108- private cursor: Cursor | null;
109- private lastCursor = "";
110- private width: number;
111- private dt: number;
112- private n: number;
113- private hop: number;
114- private bins: number;
115- private refFreqs: number[];
116- private buf: Float64Array;
117- private nSamples = 0;
118- private hann: Float64Array;
119- private fft: FFT;
120- private re: Float64Array;
121- private im: Float64Array;
122- private cols: Float32Array[] = []; // stored columns (power)
123- private merge = 1; // raw columns per stored column
124- private pending: Float32Array; // raw columns being averaged into the next stored one
125- private pendingCount = 0;
126- private hold: Float64Array; // largest power in each bin over all raw columns
127- private peak = 0;
128- private fmax = 1;
129- private span = 10; // s shown across the width
130- private drawnX = 0; // pixel columns already drawn
131- private dirty = true; // full redraw needed
132- private image: ImageData;
133-
134- constructor(opts: SpectrogramOptions, cursor: Cursor | null = null) {
135- this.cursor = cursor;
136- this.width = opts.width ?? 960;
137- this.dt = opts.dt;
138- this.n = opts.n;
139- this.hop = opts.hop;
140- this.bins = opts.nfft / 2 + 1;
141- this.refFreqs = opts.refFreqs;
142- this.buf = new Float64Array(this.n);
143- this.hann = new Float64Array(this.n);
144- for (let i = 0; i < this.n; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.n - 1));
145- this.fft = new FFT(opts.nfft);
146- this.re = new Float64Array(opts.nfft);
147- this.im = new Float64Array(opts.nfft);
148- this.pending = new Float32Array(this.bins);
149- this.hold = new Float64Array(this.bins);
150- this.canvas = document.createElement("canvas");
151- this.canvas.width = this.width;
152- this.canvas.height = HEIGHT;
153- this.image = this.canvas.getContext("2d")!.createImageData(this.width, HEIGHT);
154- if (cursor) attachCursor(this.canvas, cursor, (f) => f * this.span);
155- }
156-
157- // Start a new note shown over `span` seconds.
158- start(span: number): void {
159- this.nSamples = 0;
160- this.cols = [];
161- this.merge = 1;
162- this.pending.fill(0);
163- this.pendingCount = 0;
164- this.hold.fill(0);
165- this.peak = 0;
166- this.span = span;
167- this.dirty = true;
168- }
169-
170- setSpan(span: number): void {
171- this.span = span;
172- this.dirty = true;
173- }
174-
175- setRefFreqs(f: number[]): void {
176- this.refFreqs = f;
177- this.dirty = true;
178- }
179-
180- push(x: number): void {
181- this.buf[this.nSamples % this.n] = x;
182- this.nSamples++;
183- if (this.nSamples >= this.n && this.nSamples % this.hop === 0) this.addColumn();
184- }
185-
186- private addColumn(): void {
187- const { buf, re, im, hann, n, bins } = this;
188- let mean = 0;
189- for (let i = 0; i < n; i++) mean += buf[i];
190- mean /= n;
191- const start = this.nSamples % n;
192- re.fill(0);
193- im.fill(0);
194- for (let i = 0; i < n; i++) re[i] = (buf[(start + i) % n] - mean) * hann[i];
195- this.fft.transform(re, im);
196- for (let k = 0; k < bins; k++) {
197- const p = re[k] * re[k] + im[k] * im[k];
198- this.pending[k] += p / this.merge;
199- if (p > this.hold[k]) this.hold[k] = p;
200- }
201- if (++this.pendingCount < this.merge) return;
202- const col = this.pending;
203- this.pending = new Float32Array(bins);
204- this.pendingCount = 0;
205- this.cols.push(col);
206- let colPeak = 0;
207- for (let k = 1; k < bins; k++) if (col[k] > colPeak) colPeak = col[k];
208- if (colPeak > 2 * this.peak) this.dirty = true; // 3 dB louder: rescale colors
209- this.peak = Math.max(this.peak, colPeak);
210- if (this.cols.length === MAX_COLS) {
211- const merged: Float32Array[] = [];
212- for (let c = 0; c < MAX_COLS; c += 2) {
213- const a = this.cols[c], b = this.cols[c + 1], m = new Float32Array(bins);
214- for (let k = 0; k < bins; k++) m[k] = 0.5 * (a[k] + b[k]);
215- merged.push(m);
216- }
217- this.cols = merged;
218- this.merge *= 2;
219- this.dirty = true;
220- }
221- }
222-
223- draw(): void {
224- const { bins, cols } = this;
225- const df = 1 / (2 * (bins - 1) * this.dt);
226- let holdPeak = 0;
227- for (let k = 1; k < bins; k++) if (this.hold[k] > holdPeak) holdPeak = this.hold[k];
228- let top = bins - 1;
229- while (top > 1 && this.hold[top] < holdPeak * 10 ** (-CONTENT_DB / 10)) top--;
230- if (cols.length > 0) {
231- const fmax = Math.min(niceCeil(1.2 * top * df), (bins - 1) * df);
232- if (fmax !== this.fmax) this.dirty = true;
233- this.fmax = fmax;
234- }
235- // Pixel columns whose nearest stored column exists (column c is centered
236- // at t0 + c * colDt; the first one also covers the note's first t0).
237- const colDt = this.merge * this.hop * this.dt; // s per stored column
238- const t0 = this.n * this.dt; // the first column ends here
239- const tReady = cols.length > 0 ? t0 + (cols.length - 0.5) * colDt : 0;
240- const ready = Math.min(this.width, Math.max(0, Math.floor((tReady / this.span) * this.width)));
241- if (this.dirty) this.drawnX = 0;
242- const cursorKey = this.cursor ? `${this.cursor.t}|${this.cursor.pinned}` : "";
243- if (!this.dirty && ready <= this.drawnX && cursorKey === this.lastCursor) return;
244- this.lastCursor = cursorKey;
245-
246- const data = this.image.data;
247- const peakDb = 10 * Math.log10(this.peak + 1e-30);
248- const from = this.dirty ? 0 : this.drawnX;
249- const to = this.dirty ? this.width : ready;
250- for (let x = from; x < to; x++) {
251- const t = ((x + 0.5) / this.width) * this.span;
252- const c = Math.min(cols.length - 1, Math.max(0, Math.round((t - t0) / colDt)));
253- const col = x < ready ? cols[c] : null;
254- for (let y = 0; y < HEIGHT; y++) {
255- const p = 4 * (y * this.width + x);
256- if (!col) {
257- data[p] = data[p + 1] = data[p + 2] = 246; // no data (yet)
258- } else {
259- const f = (this.fmax * (HEIGHT - 1 - y)) / (HEIGHT - 1);
260- const k = Math.min(bins - 1, Math.round(f / df));
261- const db = 10 * Math.log10(col[k] + 1e-30);
262- colormap((db - (peakDb - RANGE_DB)) / RANGE_DB, data, p);
263- }
264- data[p + 3] = 255;
265- }
266- }
267- this.drawnX = ready;
268- this.dirty = false;
269-
270- const ctx = this.canvas.getContext("2d")!;
271- ctx.putImageData(this.image, 0, 0);
272- const py = (f: number) => (HEIGHT - 1) * (1 - f / this.fmax);
273- ctx.strokeStyle = "rgba(255,255,255,0.7)";
274- ctx.setLineDash([4, 4]);
275- for (const f of this.refFreqs) {
276- if (f >= this.fmax) continue;
277- ctx.beginPath();
278- ctx.moveTo(0, py(f));
279- ctx.lineTo(this.width, py(f));
280- ctx.stroke();
281- }
282- ctx.setLineDash([]);
283- ctx.font = "12px ui-monospace, monospace";
284- if (cols.length > 0) label(ctx, `${this.fmax} Hz`, 2, 2);
285- label(ctx, "0", 2, HEIGHT - 18);
286- const end = `${this.span.toFixed(1)} s`;
287- label(ctx, end, this.width - ctx.measureText(end).width - 8, HEIGHT - 18);
288- const ct = this.cursor?.t ?? null;
289- if (ct !== null && ct >= 0 && ct <= this.span) {
290- const cx = (ct / this.span) * this.width;
291- ctx.strokeStyle = this.cursor!.pinned ? "#fff" : "rgba(255,255,255,0.6)";
292- ctx.lineWidth = 1;
293- ctx.beginPath();
294- ctx.moveTo(cx, 0);
295- ctx.lineTo(cx, HEIGHT);
296- ctx.stroke();
297- const at = `at ${ct.toFixed(2)} s`;
298- label(ctx, at, this.width - ctx.measureText(at).width - 8, 2);
299- }
300- }
301-}
src/double-pendulum/traces.tsdeleted+0−310View file
@@ -1,310 +0,0 @@
1-// Live scrolling time traces. Quantities are arranged in groups; each group is
2-// one chart (shared y axis, one line per series) shown or hidden by a
3-// toggle chip. The y range fits the samples in the visible window (linear or
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.
8-// Non-finite values (NaN) leave a gap. Hovering shows a time cursor (shared by
9-// the panel's charts, and optionally with other charts) and the values there;
10-// a click pins it.
11-
12-import { attachCursor, newCursor, type Cursor } from "../common/cursor";
13-
14-export interface Series {
15- label: string;
16- value: () => number;
17- period?: number; // for wrapped quantities: break the line at wraps
18-}
19-
20-export interface Group {
21- label: string; // toggle text
22- unit?: string; // shown after the legend
23- series: Series[];
24- shown?: boolean; // initially shown
25- window?: number; // visible time span for this chart (default: the panel's)
26- log?: boolean; // log10 y axis (non-positive values leave a gap); range in whole decades
27- zero?: boolean; // non-negative quantity: the y range starts at 0
28- skip?: () => number; // time after the first sample (since clear) left out of the y range
29-}
30-
31-const CAPACITY = 16384; // samples kept per series (ring buffer)
32-const HEIGHT = 92; // CSS pixels; drawn at devicePixelRatio
33-const TOP = 18; // legend row height; the plot is below it
34-const COLORS = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
35-const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
36-const MUTED = "#999";
37-
38-export class TracePanel {
39- private groups: Group[];
40- private window: number; // visible time span
41- private t = new Float64Array(CAPACITY);
42- private v: Float64Array[][]; // [group][series]
43- private count = 0;
44- private head = 0; // next write index
45- private tFirst = 0; // time of the first sample since clear
46- private canvases: HTMLCanvasElement[];
47- private chips: HTMLElement[] = [];
48- private boxes: HTMLInputElement[] = [];
49- private dpr = window.devicePixelRatio || 1;
50- private width: number; // chart width, CSS pixels
51- private fixed: { t0: number; span: number } | null = null; // fixed x range instead of scrolling
52- readonly cursor: Cursor;
53- private ranges: { t0: number; win: number }[]; // time range of each chart at its last draw
54- // Per draw: buffer index and pixel column of each visible sample, oldest first.
55- private idx = new Int32Array(CAPACITY);
56- private col = new Int32Array(CAPACITY);
57-
58- // Toggle chips go into `toggles`, charts into `charts`.
59- constructor(
60- toggles: HTMLElement,
61- charts: HTMLElement,
62- groups: Group[],
63- window: number,
64- width = 480,
65- cursor: Cursor = newCursor(),
66- ) {
67- this.width = width;
68- this.cursor = cursor;
69- this.ranges = groups.map(() => ({ t0: 0, win: window }));
70- this.groups = groups;
71- this.window = window;
72- this.v = groups.map((g) => g.series.map(() => new Float64Array(CAPACITY)));
73- this.canvases = groups.map((g, j) => {
74- const canvas = document.createElement("canvas");
75- canvas.width = this.width * this.dpr;
76- canvas.height = HEIGHT * this.dpr;
77- canvas.hidden = !g.shown;
78- const box = document.createElement("input");
79- box.type = "checkbox";
80- box.checked = !!g.shown;
81- box.addEventListener("change", () => (canvas.hidden = !box.checked));
82- this.boxes.push(box);
83- const text = document.createElement("span");
84- text.textContent = g.label;
85- const chip = document.createElement("label");
86- chip.className = "chip";
87- chip.append(box, text);
88- toggles.appendChild(chip);
89- this.chips.push(chip);
90- attachCursor(canvas, cursor, (f) => this.ranges[j].t0 + f * this.ranges[j].win);
91- return canvas;
92- });
93- for (const c of this.canvases) charts.appendChild(c);
94- }
95-
96- anyShown(): boolean {
97- return this.canvases.some((c) => !c.hidden);
98- }
99-
100- // Offer a chart or not: when unavailable its chip and chart are hidden.
101- setAvailable(label: string, available: boolean): void {
102- const j = this.groups.findIndex((g) => g.label === label);
103- this.chips[j].hidden = !available;
104- this.canvases[j].hidden = !available || !this.boxes[j].checked;
105- }
106-
107- setWindow(window: number): void {
108- this.window = window;
109- }
110-
111- // Show times t0 .. t0 + span across the width (no scrolling); null to scroll.
112- setFixed(range: { t0: number; span: number } | null): void {
113- this.fixed = range;
114- }
115-
116- clear(): void {
117- this.count = 0;
118- this.head = 0;
119- }
120-
121- // Record the current value of every series at time t.
122- sample(t: number): void {
123- if (this.count === 0) this.tFirst = t;
124- this.t[this.head] = t;
125- this.groups.forEach((g, j) =>
126- g.series.forEach((s, k) => (this.v[j][k][this.head] = s.value())),
127- );
128- this.head = (this.head + 1) % CAPACITY;
129- this.count = Math.min(this.count + 1, CAPACITY);
130- }
131-
132- // Indices and pixel columns of the samples in the last `win` seconds.
133- private tStart = 0;
134- private layout(win: number): number {
135- const newest = (this.head - 1 + CAPACITY) % CAPACITY;
136- const tStart = (this.tStart = this.fixed ? this.fixed.t0 : this.t[newest] - win);
137- let n = 0;
138- while (n < this.count && this.t[(newest - n + CAPACITY) % CAPACITY] >= tStart) n++;
139- const first = (newest - n + 1 + CAPACITY) % CAPACITY;
140- for (let m = 0; m < n; m++) {
141- const i = first + m < CAPACITY ? first + m : first + m - CAPACITY;
142- this.idx[m] = i;
143- this.col[m] = Math.floor(((this.t[i] - tStart) / win) * this.width);
144- }
145- return n;
146- }
147-
148- draw(): void {
149- if (this.count === 0) return;
150- const newest = (this.head - 1 + CAPACITY) % CAPACITY;
151- const { idx, col } = this;
152- let laidOut = NaN, n = 0;
153-
154- this.groups.forEach((g, j) => {
155- const canvas = this.canvases[j];
156- if (canvas.hidden) return;
157- const win = this.fixed ? this.fixed.span : (g.window ?? this.window);
158- if (win !== laidOut) n = this.layout((laidOut = win));
159- this.ranges[j] = { t0: this.tStart, win };
160- // Sample nearest the cursor, if the cursor is on this chart's time range.
161- const ct = this.cursor.t;
162- let cm = -1;
163- if (ct !== null && ct >= this.tStart && ct <= this.tStart + win && n > 0) {
164- let a = 0, b = n - 1;
165- while (b - a > 1) {
166- const mid = (a + b) >> 1;
167- if (this.t[idx[mid]] < ct) a = mid;
168- else b = mid;
169- }
170- cm = Math.abs(this.t[idx[a]] - ct) <= Math.abs(this.t[idx[b]] - ct) ? a : b;
171- }
172- const tv = g.log ? (x: number) => (x > 0 ? Math.log10(x) : NaN) : (x: number) => x;
173- const ctx = canvas.getContext("2d")!;
174- ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
175- // Range from the samples after the transient (all of them while
176- // there are none yet).
177- let m0 = 0;
178- if (g.skip) {
179- const tSkip = this.tFirst + g.skip();
180- while (m0 < n && this.t[idx[m0]] < tSkip) m0++;
181- if (m0 === n) m0 = 0;
182- }
183- let lo = Infinity, hi = -Infinity;
184- for (const vals of this.v[j]) {
185- for (let m = m0; m < n; m++) {
186- const x = tv(vals[idx[m]]);
187- if (x < lo) lo = x;
188- if (x > hi) hi = x;
189- }
190- }
191- const empty = !(hi >= lo);
192- if (empty) { lo = 0; hi = 1; }
193- if (g.zero) lo = 0;
194- if (g.log) { lo = Math.floor(lo); hi = Math.ceil(hi); }
195- if (hi - lo <= 1e-300) { lo -= 1; hi += 1; } // constant trace
196- const pad = g.log ? 0 : 0.1 * (hi - lo);
197- const yMin = g.zero || g.log ? lo : lo - pad, yMax = hi + pad;
198- const py = (x: number) => HEIGHT - ((x - yMin) / (yMax - yMin)) * (HEIGHT - TOP);
199-
200- ctx.clearRect(0, 0, this.width, HEIGHT);
201- // Lines stay below the legend row (a skipped transient can go off scale).
202- ctx.save();
203- ctx.beginPath();
204- ctx.rect(0, TOP - 2, this.width, HEIGHT - TOP + 2);
205- ctx.clip();
206- ctx.lineWidth = 1.25;
207- ctx.lineJoin = "round";
208- g.series.forEach((s, k) => {
209- const vals = this.v[j][k];
210- const jump = s.period === undefined ? Infinity : s.period / 2;
211- ctx.strokeStyle = COLORS[k % COLORS.length];
212- ctx.beginPath();
213- // Current pixel column: its min and max, and whether the min came first.
214- let cur = -1, cLo = 0, cHi = 0, loFirst = true, penDown = false;
215- const emit = (v: number) => {
216- if (penDown) ctx.lineTo(cur, py(v));
217- else ctx.moveTo(cur, py(v));
218- penDown = true;
219- };
220- const flush = () => {
221- if (cur < 0) return;
222- emit(loFirst ? cLo : cHi);
223- if (cHi !== cLo) emit(loFirst ? cHi : cLo);
224- };
225- const liftPen = () => {
226- flush();
227- penDown = false;
228- cur = -1;
229- };
230- let prev = NaN;
231- for (let m = 0; m < n; m++) {
232- const v = tv(vals[idx[m]]);
233- if (!Number.isFinite(v)) {
234- liftPen();
235- prev = NaN;
236- continue;
237- }
238- if (Math.abs(v - prev) > jump) liftPen(); // wrap
239- prev = v;
240- if (col[m] !== cur) {
241- flush();
242- cur = col[m];
243- cLo = cHi = v;
244- loFirst = true;
245- } else if (v < cLo) {
246- cLo = v;
247- loFirst = false;
248- } else if (v > cHi) {
249- cHi = v;
250- loFirst = true;
251- }
252- }
253- flush();
254- ctx.stroke();
255- });
256- ctx.restore();
257-
258- if (cm >= 0) {
259- const cx = ((ct! - this.tStart) / win) * this.width;
260- ctx.strokeStyle = this.cursor.pinned ? "#555" : "#aaa";
261- ctx.lineWidth = 1;
262- ctx.beginPath();
263- ctx.moveTo(cx, TOP);
264- ctx.lineTo(cx, HEIGHT);
265- ctx.stroke();
266- }
267-
268- ctx.font = FONT;
269- ctx.textBaseline = "top";
270- let x = 4;
271- const at = cm >= 0 ? idx[cm] : newest; // sample whose values the legend shows
272- g.series.forEach((s, k) => {
273- const v = this.v[j][k][at];
274- const text = `${s.label} ${Number.isFinite(v) ? fmt(v) : "–"}`;
275- ctx.fillStyle = COLORS[k % COLORS.length];
276- ctx.fillText(text, x, 3);
277- x += ctx.measureText(text).width + 12;
278- });
279- if (g.unit) {
280- ctx.fillStyle = MUTED;
281- ctx.fillText(g.unit, x, 3);
282- }
283- ctx.fillStyle = MUTED;
284- ctx.textAlign = "right";
285- if (cm >= 0) {
286- ctx.fillText(`at ${this.t[at].toFixed(2)} s${this.cursor.pinned ? " (pinned)" : ""}`, this.width - 4, 3);
287- } else if (!empty) {
288- const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y, 3));
289- ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, this.width - 4, 3);
290- }
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);
300- ctx.textAlign = "left";
301- });
302- }
303-}
304-
305-// x to `digits` significant digits (exponential when very small or large).
306-function fmt(x: number, digits = 4): string {
307- const a = Math.abs(x);
308- if (a !== 0 && (a < 1e-3 || a >= 1e5)) return x.toExponential(digits - 1);
309- return a >= 10 ** digits ? x.toFixed(0) : x.toPrecision(digits);
310-}
src/fput-chain/README.mdmodified+3−3View file
@@ -2,9 +2,9 @@
22
33 Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-chaos). Work in progress.
44
5-Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`lyapunov.ts`, two-trajectory Benettin method as in the pendulum app: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `traces.ts`, adapted from the pendulum app, and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
5+Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`lyapunov.ts`, two-trajectory Benettin method as in the pendulum app: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
66
7-"Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; pendulum's `NoteSpectrogram`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, drawn only while at least 6 px apart, since crowded together their dashes merged into a solid on-off bar at the bottom), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
7+"Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; `NoteSpectrogram` in `../common/spectrogram.ts`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, drawn only while at least 6 px apart, since crowded together their dashes merged into a solid on-off bar at the bottom), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
88
99 Poincaré section (shown only for N = 2 and N = 3, below the sped-up panel, open; adapted from the pendulum app): its own energy slider (log, 10 µJ to 100 J, default 2.51 J), same parameters, no damping. N = 2: each time mass 1 passes its rest position moving right, mass 2's (u2, p2) is plotted. N = 3: only the symmetric family (u1 = u3, p1 = p3; only modes 1 and 3 move, since the cubic force couples odd modes only to odd modes), which is two-dimensional: each time mass 2 passes its rest position moving right, mass 1's (u1, p1) is plotted. On either section the springs are stretched by 0 or +-x, so V = vFactor (k x^2 / 2 + k3 x^4 / 4) (vFactor 2 or 4), the plotted momentum counts once (N = 2) or twice (N = 3) in the kinetic energy, the outline is where the crossing mass's momentum vanishes, and that momentum follows from E. 42 orbits seeded on a grid, 300 crossings each, in a worker (`poincare-core.ts`, `poincare-worker.ts`, `poincare.ts`; integrator step stepFor(E), energy error < 5e-5; ~1 s per section offline, a few s in the pane), drawn as they arrive; per-orbit colors or Color by λ (each orbit's Lyapunov exponent over its run, log scale); hover to pick out an orbit with its λ; click inside the outline to add an orbit there, and with Click to strike (on) also strike the chain from that exact state (and play it, if the sound panel is open). Follow (as in the pendulum app): "real time" makes the section's energy track the chain's as it decays (recomputed after a 3% change, at most every 2 s, and at each strike); "sped up" (which opens the sped-up panel) shows the playing note's crossings (found in the render worker, for N = 3 only while exactly symmetric, and 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 (refit every 2%) and each dot fitted from its own energy; nothing is computed live. "Precompute sections" (shown only with Follow "sped up" until complete) builds an atlas (`atlas.ts`): sections at 32 energies log-spaced from 1 mJ to 100 J (a factor ~1.45 apart), 42 orbits x 150 crossings, highest first, in its own worker (~22 s in the pane for N = 3), stored in IndexedDB per N, m, k, k3 and deleted after 7 days; the sound view shows the atlas section nearest the current energy, fitted to its allowed region (exact in the linear regime, below ~25 mJ, where sections are scaled copies; an approximation across the gaps above). Without an atlas only the outline is drawn behind the trail. Setting the section's energy by hand pauses everything and clears the rings and the trail; Follow takes over again on Resume. The chain's own crossings show as dark rings when within 5% of the section's energy and, for N = 3, only while the chain is exactly symmetric (else the legend says "chain not symmetric, not shown"; strikes on mass 2 or on mode 1 or 3 are symmetric, mode shapes are made exactly mirror-symmetric for this). Findings: the two-mass chain appears regular at every energy tried (10 mJ to 100 J): nested closed curves and island chains, no chaotic sea; the grid orbits' λ falls as the run lengthens (at 10 J, median 0.045 /s over 300 crossings, 0.007 /s over 3000), as for regular orbits. A likely reason: in mode coordinates (u = S Q) the potential is (k/2) rho^2 + (k3/8) rho^4 with rho^2 = Q1^2 + 3 Q2^2, a function of one combination of the two modes. The N = 3 symmetric family is chaotic: grid orbits with λ > 0.1 /s: 0 of 42 at 25 mJ, 10 at 0.25 J (islands in a chaotic sea), 37 at 2.51 J. It is unstable to breaking the symmetry: an exactly symmetric start stays symmetric (the arithmetic is exactly mirror-symmetric), but an asymmetry of 1e-12 grows to full size within ~100 s at 0.25 to 25 J. The ghost and the Lyapunov shadow offset all masses equally, so after a symmetric strike they too stay symmetric and measure the exponent within the family.
1010
@@ -26,4 +26,4 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/fput
2626 - Chaos for N = 4 (two-trajectory Lyapunov, 5000 / sqrt(k/m) s): none below E ~ 1 k d0^2; onset near 3 k d0^2; at the default 10 k d0^2, lambda ~0.1 sqrt(k/m) = 0.32 /s, energy shared by ~2.5 modes.
2727 - Symmetry: the quartic force couples odd modes only to odd modes, so a strike in an odd mode leaves the even modes at exactly zero (they stay off the bottom of the chart). A strike on one mass, other than the middle one, excites all modes.
2828
29-Code: `chain.ts` (physics, modes, strikes), `main.ts` (page), `traces.ts` and `../common/cursor.ts` (charts), `signals.ts`, `render-worker.ts`, `sound.ts`, `spectrogram.ts`, `../common/scatter.ts` (sound), `lyapunov.ts` (Lyapunov exponent, animation and sound), `poincare*.ts` (section, N = 2 and 3).
29+Code: `chain.ts` (physics, modes, strikes), `main.ts` (page), `../common/traces.ts` and `../common/cursor.ts` (charts), `signals.ts`, `render-worker.ts`, `sound.ts`, `../common/spectrogram.ts`, `../common/scatter.ts` (sound), `lyapunov.ts` (Lyapunov exponent, animation and sound), `poincare*.ts` (section, N = 2 and 3).
src/fput-chain/main.tsmodified+2−2View file
@@ -11,8 +11,8 @@ import { END_RATIO, DIAG_EVERY, UNDAMPED_LENGTH, type RenderRequest, type Render
1111 import { ScatterPlot } from "../common/scatter";
1212 import { SIGNALS, signalByKey } from "./signals";
1313 import { Player } from "./sound";
14-import { NoteSpectrogram } from "./spectrogram";
15-import { TracePanel, type Group } from "./traces";
14+import { NoteSpectrogram } from "../common/spectrogram";
15+import { TracePanel, type Group } from "../common/traces";
1616 import { saveFile, wavBlob } from "../common/wav";
1717
1818 setupHelp();
src/fput-chain/modehistory.tsmodified+1−1View file
@@ -6,7 +6,7 @@
66 // as the audio clock reaches them. An optional shared time cursor is drawn.
77
88 import { attachCursor, type Cursor } from "../common/cursor";
9-import { colormap } from "./spectrogram";
9+import { colormap } from "../common/spectrogram";
1010
1111 const HEIGHT = 160;
1212 const DECADES = 6;
↑↓ move↵openescclose