/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Driven plate: spectrogram of the displacement at a listening point (click the plate to move it), whole run, log frequency in driven pitches, per-time or whole-run colour, mode pitch ticks; shared FFT exported
Jeremy Magland <jmagland@flatironinstitute.org> committed commit ea9e7ca909d8 parent b22731f Browse files
7 changed files+328−3
src/common/spectrogram.tsmodified+1−1View file
@@ -64,7 +64,7 @@ export function colormap(u: number, out: Uint8ClampedArray, p: number): void {
6464
6565 // In-place radix-2 complex FFT of a fixed power-of-two length, with
6666 // precomputed twiddle factors.
67-class FFT {
67+export class FFT {
6868 private n: number;
6969 private cos: Float64Array;
7070 private sin: Float64Array;
src/driven-plate/README.mdmodified+5−1View file
@@ -1,6 +1,6 @@
11 # driven-plate
22
3-A rectangular plate driven in one mode, without damping, its energy raised slowly, to watch the energy move from the driven mode into the others. Step 1 (2026-10-09): simply supported Nx × Ny plate with von Kármán stretching, modal drive with an energy-target controller, log ramp, height map, energy chart. Step 2 (2026-10-09): mode map (energy of every mode against time). Loss option (2026-10-09): a per-mode sink at the top of the band.
3+A rectangular plate driven in one mode, without damping, its energy raised slowly, to watch the energy move from the driven mode into the others. Step 1 (2026-10-09): simply supported Nx × Ny plate with von Kármán stretching, modal drive with an energy-target controller, log ramp, height map, energy chart. Step 2 (2026-10-09): mode map (energy of every mode against time). Loss option (2026-10-09): a per-mode sink at the top of the band. Step 3 (2026-10-09): spectrogram at a listening point.
44
55 Plate (`plate.ts`): the square plate of `../plate` (simply supported, flat) generalized to a rectangle, with spacings ax and ay. Nx follows from the masses across the short side and the aspect so the cells are nearly square (default 8 across, aspect 1.3: 11 × 8). Units scaled away (D = ρh = h = 1, short side 1): with energy given against the nonlinear scale, displacement against h and time in driven periods, only the aspect and ν = 0.3 remain. Bending on the five-point Laplacian, exact sine modes ω_mn = l_m / ax² + l_n / ay²; stretching by the stress function, L⁻² by two sine transforms (plain matrix products, no mirror trick yet), force the exact gradient.
66
@@ -21,3 +21,7 @@ Loss (`drive.ts`, Plate and drive card; off by default, switchable during a run)
2121 Checks (2026-10-09): decay times of (11, 8) and (6, 4) at tiny amplitude, drive off, against prediction: 9.97 / 10.00, 21.27 / 21.34 (k = 1), 45.39 / 45.53 (k = 2), 207.0 / 207.3 (k = 4) periods (the first try gave twice the prediction: damping only the momentum halves the amplitude's rate, now in γ). Default ramp with the loss (τ top 10, k = 2): energy error 4e-3 at 100 E_nl relative to E (where ~150 E_nl had been dissipated in the last 100 periods), drive and loss power balance at the top (3.7 E_nl per period at 100 E_nl).
2222
2323 With and without the loss (2026-10-09; default plate and ramp, then held 100 + 200 periods; mean energy per mode in octaves of pitch above the driven mode, 2-4, 4-8, 8-16, 16-32, 32-64): at 10 E_nl without loss nearly flat, 0.06, 0.08, 0.11, 0.09, 0.09 E_nl (close to equipartition, the pile-up), the driven mode 0.46; with the loss (τ top 10, k = 2) falling, 0.30, 0.19, 0.067, 0.035, 0.022 (about ω⁻¹ per mode), the driven mode 3.0. At 1 E_nl with the loss the fall is steep: 1.4e-2, 5.5e-3, 1.0e-3, 1.4e-4, 1.6e-6 (without: 9.6e-3, 1.6e-2, 1.3e-2, 1.6e-3, 9.9e-4, not yet spread to the top). The onset is the same with or without the loss at this τ top: (1, 3) takes off near 0.3 E_nl in both.
24+
25+Spectrogram (step 3, `livespec.ts`; `drive.ts` gives the signal): displacement at a listening point (fractions of the sides, default (0.37, 0.29), off the low modes' nodes; bilinear between nodes, 0 at the supports; click the plate to move it, the run goes on), averaged over each of 256 sample intervals per driven period (crude anti-alias). Hann window of 32 periods (8192 samples), zero padded to 16384 (bins 1/64 of the driven pitch), a column every 4 periods, the power kept in 240 log-spaced bands from 0.25 to 128 driven pitches (loudest bin per band); 1000 columns, then merged in pairs. Colour 80 dB, relative to each column's peak (Per time, default) or to the run's. Ticks on the right: the modes' linear pitches. Uses the shared FFT (`../common/spectrogram.ts`, now exported); the shared NoteSpectrogram itself is built around a whole note of known length in Hz, so it is not used.
26+
27+Seen (default settings, no loss, 2026-10-09): one line at the driven pitch to about 0.1 E_nl, with odd multiples (3, 5, ...) coming in as the energy rises (the bound harmonics); from about 0.3 E_nl the driven line bends up (stiffening), more lines enter through the jump, and by 10 to 100 E_nl the spectrum is a dense band of lines up to the top, the driven line near 2.9 times its linear pitch. A faint line falling below the driven pitch around 1000 periods is not identified yet. In that run the driven mode held only 0.06% of the energy at 100 E_nl (other runs: 20 to 27%): with no loss and the target reached, nothing makes the drive keep it filled.
src/driven-plate/drive.tsmodified+44−0View file
@@ -24,6 +24,10 @@
2424 // ../plate gives (rate proportional to omega for a plate); 2 and 4 leave the
2525 // low modes nearly untouched. The kinetic energy removed goes into Wl.
2626 // Time: velocity Verlet at the step of plate.ts, recomputed from the energy.
27+// Signal (for the spectrogram): the displacement at a listening point
28+// (bilinear between the nodes, 0 at the supports), averaged over each
29+// sample interval, 1 / SIGNAL_RATE of a driven period (the average is a crude
30+// anti-alias filter: content above the sample rate is mostly averaged out).
2731 // Mode energies for the displays are averaged over time (sampled every
2832 // 1 / SAMPLES_PER_PERIOD of a driven period): a mode's linear energy swings
2933 // within each cycle when it is driven off resonance by the others.
@@ -35,6 +39,7 @@ const G_MAX = 0.2; // cap on G, times the driven mode's omega
3539 const PERTURB = 1e-6; // energy of the perturbation, relative to the starting energy
3640 const SAMPLES_PER_PERIOD = 16;
3741 const LOSS_EVERY = 8;
42+export const SIGNAL_RATE = 256; // samples per driven period
3843
3944 export interface Loss {
4045 tauTop: number; // amplitude decay time of the top mode, in driven periods; 0 = off
@@ -110,6 +115,7 @@ export class DrivenPlate {
110115 this.Estart = this.E;
111116 this.readMode();
112117 this.setStep();
118+ this.setListen(this.listen.fx, this.listen.fy);
113119 }
114120
115121 // Random phases, equal energy e / (count - 1) in every mode but the driven
@@ -188,6 +194,13 @@ export class DrivenPlate {
188194 this.lastCross = tc;
189195 }
190196 this.t += h;
197+ this.sigSum += this.listenValue();
198+ this.sigN++;
199+ if (this.t >= this.nextSig) {
200+ this.sig.push(this.sigSum / this.sigN);
201+ this.sigSum = this.sigN = 0;
202+ this.nextSig += this.period / SIGNAL_RATE;
203+ }
191204 if (this.t >= this.nextSample) {
192205 this.modalEnergies(this.sample);
193206 for (let k = 0; k < n; k++) this.acc[k] += this.sample[k];
@@ -219,6 +232,37 @@ export class DrivenPlate {
219232 if (!(this.loss.tauTop > 0)) return Infinity;
220233 return this.loss.tauTop * (this.modes.omegaMax / omegaOf(this.g, m, n)) ** this.loss.power;
221234 }
235+ // Listening point, as a fraction of each side; the four nodes around it and
236+ // their weights.
237+ listen = { fx: 0.37, fy: 0.29 };
238+ private lk = new Int32Array(4);
239+ private lw = new Float64Array(4);
240+ setListen(fx: number, fy: number): void {
241+ const g = this.g;
242+ this.listen = { fx, fy };
243+ const x = fx * (g.Nx + 1) - 1, y = fy * (g.Ny + 1) - 1, i0 = Math.floor(x), j0 = Math.floor(y), ax = x - i0, ay = y - j0;
244+ const corners: [number, number, number][] = [[i0, j0, (1 - ax) * (1 - ay)], [i0 + 1, j0, ax * (1 - ay)], [i0, j0 + 1, (1 - ax) * ay], [i0 + 1, j0 + 1, ax * ay]];
245+ corners.forEach(([i, j, w], c) => {
246+ const inside = i >= 0 && j >= 0 && i < g.Nx && j < g.Ny;
247+ this.lk[c] = inside ? i + g.Nx * j : 0;
248+ this.lw[c] = inside ? w : 0; // supports: 0
249+ });
250+ }
251+ private listenValue(): number {
252+ const u = this.u, k = this.lk, w = this.lw;
253+ return w[0] * u[k[0]] + w[1] * u[k[1]] + w[2] * u[k[2]] + w[3] * u[k[3]];
254+ }
255+ private sigSum = 0;
256+ private sigN = 0;
257+ private nextSig = 0;
258+ private sig: number[] = [];
259+ // Signal samples since the last call.
260+ takeSignal(): number[] {
261+ const s = this.sig;
262+ this.sig = [];
263+ return s;
264+ }
265+
222266 private lossHat: Float64Array | null = null;
223267 private applyLoss(dt: number): void {
224268 const gam = this.gamma!, n = this.g.n, P = (this.lossHat ??= new Float64Array(n));
src/driven-plate/index.htmlmodified+12−1View file
@@ -66,6 +66,12 @@
6666 </section>
6767 </div>
6868 </div>
69+ <section class="card wide">
70+ <div class="card-head">Spectrogram
71+ <label class="chip" style="margin-left: 12px" title="Colour each time relative to its own loudest frequency (off: relative to the loudest of the whole run)"><input id="spec-per-time" type="checkbox" checked /><span>Per time</span></label>
72+ <button type="button" class="help" data-help="spec" title="About the spectrogram" aria-label="About the spectrogram">?</button></div>
73+ <canvas id="spec"></canvas>
74+ </section>
6975 <section class="card wide">
7076 <div class="card-head">Modes <button type="button" class="help" data-help="modes" title="About the mode map" aria-label="About the mode map">?</button></div>
7177 <canvas id="modemap"></canvas>
@@ -78,7 +84,7 @@
7884
7985 <template id="help-drive">
8086 <h2>The driven plate</h2>
81- <p>A thin rectangular plate, simply supported at its edges, seen from above: red is up, blue down, white at rest (scaled to the recent largest displacement). It is a grid of equal masses with bending stiffness and von Kármán stretching: a bent plate stretches in its own plane, which stiffens it once it moves by about its thickness h. There is no damping.</p>
87+ <p>A thin rectangular plate, simply supported at its edges, seen from above: red is up, blue down, white at rest (scaled to the recent largest displacement). The circle is the listening point for the spectrogram; click the plate to move it. It is a grid of equal masses with bending stiffness and von Kármán stretching: a bent plate stretches in its own plane, which stiffens it once it moves by about its thickness h. There is no damping.</p>
8288 <p>A force in the shape of one normal mode (the driven mode) pushes the plate. In a linear plate such a force moves that mode and nothing else, so any other mode that moves got its energy through the stretching. The force is always in phase with the driven mode's own velocity, so it adds energy whatever the mode's frequency (which rises as the plate stiffens), and a controller sets its strength so that the plate's total energy follows the target.</p>
8389 <p>The target is given against E<sub>nl</sub>, the energy of the driven mode at which its stretching energy equals its bending energy: well below 1 the plate is nearly linear. Ramp raises the target steadily, the same number of periods for each factor of ten, up to the top of the slider; untick it to hold the energy where it is. Reset starts again in the driven mode at 10<sup>−3</sup> E<sub>nl</sub>. Time is counted in periods of the driven mode (linear), and Speed sets how many pass per second; at high energies the plate needs many more steps per period, and the actual speed (shown) can fall below the setting. The space bar pauses.</p>
8490 </template>
@@ -93,6 +99,11 @@
9399 <h2>Energy</h2>
94100 <p>Against time in driven periods, on a log scale in units of E<sub>nl</sub>: the total energy (black), the target (dashed), the driven mode's energy (blue) and the sum over all other modes (orange). The mode energies are the linear ones, (velocity² + ω² displacement²) / 2 per mode, averaged over the time steps of the mode map below; once stretching matters they no longer add up exactly to the total.</p>
95101 </template>
102+ <template id="help-spec">
103+ <h2>Spectrogram</h2>
104+ <p>The frequencies in the plate's motion at the listening point (the circle on the plate; click the plate to move it), as a microphone or a pickup there would see them: what one would hear, rather than the energy of each mode. A mode with a node at the point is missing here even when it holds energy; the mode map below sees every mode. Frequency is in units of the driven mode's linear pitch, on a log scale; the ticks on the right mark every mode's linear pitch (the driven mode's in blue). Time runs across, the whole run, as in the charts above.</p>
105+ <p>Each column is the spectrum of the last 32 driven periods (so lines 1/32 of the driven pitch apart are just told apart, and changes show with a lag of about 16 periods), one every 4 periods. Per time colours each column relative to its own loudest frequency, so the shape of the spectrum shows at every energy; off, colours are relative to the loudest moment of the run, so the quiet start is dark. 80 dB from black to pale yellow. A line that moves up as the energy rises is a mode stiffened by the stretching; lines at sums and multiples of the driven pitch are its harmonics, bound to it; a broad smear is the plate's motion no longer made of a few clean tones.</p>
106+ </template>
96107 <template id="help-modes">
97108 <h2>Modes</h2>
98109 <p>Every normal mode of the plate, one row each, ordered by pitch with the lowest at the bottom; the label gives (m, n) and the pitch relative to the driven mode (marked ▸). Time runs across, the whole run as in the energy chart. Colour is the mode's linear energy, (velocity² + ω² displacement²) / 2, averaged over each time step of the map (sampled 16 times per driven period), on a log scale reaching 12 factors of ten below the largest so far: dark is next to nothing, pale yellow the most. Averaging matters because a mode pushed off its own pitch by the others swings in energy within each cycle.</p>
src/driven-plate/livespec.tsadded+238−0View file
@@ -0,0 +1,238 @@
1+// Spectrogram of the listening point's displacement over the whole run, time
2+// across as in the energy chart and the mode map. Samples come at
3+// SIGNAL_RATE per driven period; every HOP periods a Hann window of WINDOW
4+// periods is transformed (zero padded to twice its length), and the power is
5+// kept in ROWS bands spaced evenly in log frequency, from F_LO times the
6+// driven mode's linear pitch up to the Nyquist frequency (each band keeps the
7+// loudest bin in it, so narrow lines are not lost). Frequencies are in units
8+// of the driven mode's linear pitch. As the bins of the history, columns are
9+// merged in pairs when there are MAX_COLS. Colour: dB, RANGE_DB deep, below
10+// each column's own peak (Per time, the default: the spectrum's shape at
11+// each moment, whatever the level) or below the peak of the whole run.
12+// Reference ticks on the right edge mark the modes' linear pitches.
13+
14+import { fit } from "../common/canvas";
15+import { FFT, colormap } from "../common/spectrogram";
16+import { SIGNAL_RATE, type DrivenPlate } from "./drive";
17+
18+const WINDOW = 32; // periods
19+const HOP = 4; // periods
20+const N = WINDOW * SIGNAL_RATE; // samples per window (a power of two)
21+const NFFT = 2 * N;
22+const ROWS = 240;
23+const F_LO = 0.25;
24+const MAX_COLS = 1000;
25+const RANGE_DB = 80;
26+const L = 52, R = 14, T = 44, B = 22; // margins (px)
27+
28+export class LiveSpectrogram {
29+ private canvas: HTMLCanvasElement;
30+ private img: HTMLCanvasElement;
31+ private ictx: CanvasRenderingContext2D;
32+ private data: ImageData;
33+ private fft = new FFT(NFFT);
34+ private re = new Float64Array(NFFT);
35+ private im = new Float64Array(NFFT);
36+ private hann = new Float64Array(N);
37+ private buf = new Float64Array(N);
38+ private n = 0; // samples so far
39+ private rowLo = new Int32Array(ROWS); // FFT bins in each band
40+ private rowHi = new Int32Array(ROWS);
41+ private fHi = SIGNAL_RATE / 2; // top of the axis (Nyquist), in driven pitches
42+ private cols: Float32Array[] = [];
43+ private times: number[] = []; // column centres (periods)
44+ private colPeak: number[] = [];
45+ private merge = 1; // raw columns per stored column
46+ private pending: Float32Array | null = null;
47+ private pendingN = 0;
48+ private pendingT = 0;
49+ private painted = 0;
50+ private paintKey = "";
51+ private sim: DrivenPlate | null = null;
52+ private hover: { x: number; y: number } | null = null;
53+ perTime = true;
54+
55+ constructor(canvas: HTMLCanvasElement) {
56+ this.canvas = canvas;
57+ this.img = document.createElement("canvas");
58+ this.img.width = MAX_COLS;
59+ this.img.height = ROWS;
60+ this.ictx = this.img.getContext("2d")!;
61+ this.data = this.ictx.createImageData(MAX_COLS, ROWS);
62+ for (let i = 0; i < N; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1));
63+ // Bands: row r (0 at the bottom) spans [f(r), f(r + 1)) with f log spaced.
64+ const df = SIGNAL_RATE / NFFT; // bin spacing, in driven pitches
65+ const f = (r: number) => F_LO * (this.fHi / F_LO) ** (r / ROWS);
66+ for (let r = 0; r < ROWS; r++) {
67+ let lo = Math.ceil(f(r) / df), hi = Math.ceil(f(r + 1) / df) - 1;
68+ if (hi < lo) lo = hi = Math.round(Math.sqrt(f(r) * f(r + 1)) / df);
69+ this.rowLo[r] = Math.min(NFFT / 2, lo);
70+ this.rowHi[r] = Math.min(NFFT / 2, hi);
71+ }
72+ canvas.addEventListener("mousemove", (e) => {
73+ const b = canvas.getBoundingClientRect();
74+ this.hover = { x: e.clientX - b.left, y: e.clientY - b.top };
75+ });
76+ canvas.addEventListener("mouseleave", () => (this.hover = null));
77+ }
78+
79+ reset(sim: DrivenPlate): void {
80+ this.sim = sim;
81+ this.n = 0;
82+ this.buf.fill(0);
83+ this.cols = [];
84+ this.times = [];
85+ this.colPeak = [];
86+ this.merge = 1;
87+ this.pending = null;
88+ this.pendingN = 0;
89+ this.painted = 0;
90+ this.paintKey = "";
91+ }
92+
93+ // New samples; `tEnd` is the time (periods) of the last one.
94+ push(samples: number[], tEnd: number): void {
95+ const hopN = HOP * SIGNAL_RATE;
96+ for (let i = 0; i < samples.length; i++) {
97+ this.buf[this.n % N] = samples[i];
98+ this.n++;
99+ if (this.n >= N && (this.n - N) % hopN === 0) this.column(tEnd - (samples.length - 1 - i) / SIGNAL_RATE - WINDOW / 2);
100+ }
101+ }
102+
103+ private column(t: number): void {
104+ const { re, im, buf, hann } = this, start = this.n % N;
105+ let mean = 0;
106+ for (let i = 0; i < N; i++) mean += buf[i];
107+ mean /= N;
108+ re.fill(0);
109+ im.fill(0);
110+ for (let i = 0; i < N; i++) re[i] = (buf[(start + i) % N] - mean) * hann[i];
111+ this.fft.transform(re, im);
112+ const col = (this.pending ??= new Float32Array(ROWS));
113+ for (let r = 0; r < ROWS; r++) {
114+ let p = 0;
115+ for (let k = this.rowLo[r]; k <= this.rowHi[r]; k++) p = Math.max(p, re[k] * re[k] + im[k] * im[k]);
116+ col[r] += p / this.merge;
117+ }
118+ this.pendingT += t / this.merge;
119+ if (++this.pendingN < this.merge) return;
120+ this.cols.push(col);
121+ this.times.push(this.pendingT);
122+ this.colPeak.push(col.reduce((a, b) => Math.max(a, b), 0));
123+ this.pending = null;
124+ this.pendingN = 0;
125+ this.pendingT = 0;
126+ if (this.cols.length >= MAX_COLS) {
127+ const c: Float32Array[] = [], ts: number[] = [], pk: number[] = [];
128+ for (let i = 0; i + 1 < this.cols.length; i += 2) {
129+ const m = this.cols[i].map((x, r) => (x + this.cols[i + 1][r]) / 2);
130+ c.push(m);
131+ ts.push((this.times[i] + this.times[i + 1]) / 2);
132+ pk.push(m.reduce((a, b) => Math.max(a, b), 0));
133+ }
134+ this.cols = c;
135+ this.times = ts;
136+ this.colPeak = pk;
137+ this.merge *= 2;
138+ this.painted = 0;
139+ }
140+ }
141+
142+ draw(tNow: number): void {
143+ const sim = this.sim!, n = this.cols.length;
144+ const peakAll = this.colPeak.reduce((a, b) => Math.max(a, b), 0);
145+ // Repaint everything when the colouring changes: mode, or (absolute) a
146+ // louder peak by 3 dB.
147+ const key = this.perTime ? "t" : `a${Math.floor(10 * Math.log10(peakAll + 1e-300) / 3)}`;
148+ if (key !== this.paintKey) {
149+ this.paintKey = key;
150+ this.painted = 0;
151+ }
152+ const d = this.data.data;
153+ for (let c = this.painted; c < n; c++) {
154+ const col = this.cols[c], ref = 10 * Math.log10((this.perTime ? this.colPeak[c] : peakAll) + 1e-300);
155+ for (let r = 0; r < ROWS; r++) {
156+ const db = 10 * Math.log10(col[r] + 1e-300);
157+ colormap((db - (ref - RANGE_DB)) / RANGE_DB, d, 4 * (c + MAX_COLS * (ROWS - 1 - r)));
158+ d[4 * (c + MAX_COLS * (ROWS - 1 - r)) + 3] = 255;
159+ }
160+ }
161+ if (this.painted < n) this.ictx.putImageData(this.data, 0, 0, this.painted, 0, n - this.painted, ROWS);
162+ this.painted = n;
163+
164+ const ctx = fit(this.canvas), W = this.canvas.clientWidth, H = this.canvas.clientHeight;
165+ ctx.clearRect(0, 0, W, H);
166+ const pw = W - L - R, ph = H - T - B, tMax = Math.max(tNow, 1);
167+ const X = (t: number) => L + (t / tMax) * pw;
168+ const Y = (f: number) => T + ph * (1 - Math.log(f / F_LO) / Math.log(this.fHi / F_LO));
169+ ctx.fillStyle = "#f2f2ef";
170+ ctx.fillRect(L, T, pw, ph);
171+ if (n > 0) {
172+ // Columns are evenly spaced in time; the image spans half a hop
173+ // beyond the first and last centres.
174+ const dtc = n > 1 ? (this.times[n - 1] - this.times[0]) / (n - 1) : HOP;
175+ const x0 = X(this.times[0] - dtc / 2), x1 = X(this.times[n - 1] + dtc / 2);
176+ ctx.imageSmoothingEnabled = false;
177+ ctx.drawImage(this.img, 0, 0, n, ROWS, x0, T, x1 - x0, ph);
178+ }
179+ ctx.strokeStyle = "#e3e2dc";
180+ ctx.strokeRect(L - 0.5, T - 0.5, pw + 1, ph + 1);
181+ // Frequency axis: 1, 2, 5 steps, in driven pitches.
182+ ctx.font = "11px system-ui, sans-serif";
183+ ctx.fillStyle = "#7a7d82";
184+ ctx.textAlign = "right";
185+ for (let p = -1; p <= 3; p++) {
186+ for (const m of [1, 2, 5]) {
187+ const f = m * 10 ** p;
188+ if (f < F_LO || f > this.fHi) continue;
189+ ctx.fillText(`${+f.toPrecision(1)}`, L - 6, Y(f) + 4);
190+ ctx.fillRect(L - 4, Y(f), 3, 1);
191+ }
192+ }
193+ ctx.save();
194+ ctx.translate(12, T + ph / 2);
195+ ctx.rotate(-Math.PI / 2);
196+ ctx.textAlign = "center";
197+ ctx.fillText("× driven pitch", 0, 0);
198+ ctx.restore();
199+ // Modes' linear pitches as ticks on the right; the driven one in accent.
200+ const M = sim.modes;
201+ for (let k = 0; k < M.count; k++) {
202+ const f = M.omega[k] / sim.omega, driven = M.m[k] === sim.opts.m && M.n[k] === sim.opts.n;
203+ ctx.fillStyle = driven ? "#2f5d8a" : "#9a9c9f";
204+ ctx.fillRect(L + pw + 2, Y(f), driven ? 10 : 6, 1);
205+ }
206+ ctx.textAlign = "center";
207+ ctx.fillStyle = "#7a7d82";
208+ ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)} · window ${WINDOW}`, L + pw / 2, H - 6);
209+ // Colour bar.
210+ const cbW = Math.min(160, pw / 3), cbX = L + pw - cbW - 70, cbY = 24, tmp = new Uint8ClampedArray(4);
211+ for (let i = 0; i < cbW; i++) {
212+ colormap(i / (cbW - 1), tmp, 0);
213+ ctx.fillStyle = `rgb(${tmp[0]}, ${tmp[1]}, ${tmp[2]})`;
214+ ctx.fillRect(cbX + i, cbY, 1.5, 10);
215+ }
216+ ctx.fillStyle = "#7a7d82";
217+ ctx.textAlign = "right";
218+ ctx.fillText(`−${RANGE_DB} dB`, cbX - 4, cbY + 9);
219+ ctx.textAlign = "left";
220+ ctx.fillText(this.perTime ? "0 dB (each time)" : "0 dB (whole run)", cbX + cbW + 4, cbY + 9);
221+ // Hover: frequency, time, level, and the nearest mode by linear pitch.
222+ const h = this.hover;
223+ ctx.fillStyle = "#23262b";
224+ if (h && h.x >= L && h.x < L + pw && h.y >= T && h.y < T + ph && n > 0) {
225+ const t = ((h.x - L) / pw) * tMax, f = F_LO * (this.fHi / F_LO) ** ((T + ph - h.y) / ph);
226+ let c = 0;
227+ for (let i = 1; i < n; i++) if (Math.abs(this.times[i] - t) < Math.abs(this.times[c] - t)) c = i;
228+ const r = Math.min(ROWS - 1, Math.max(0, Math.floor(((T + ph - h.y) / ph) * ROWS)));
229+ const ref = this.perTime ? this.colPeak[c] : peakAll, db = 10 * Math.log10((this.cols[c][r] + 1e-300) / (ref + 1e-300));
230+ let k = 0;
231+ for (let i = 1; i < M.count; i++) if (Math.abs(Math.log(M.omega[i] / sim.omega / f)) < Math.abs(Math.log(M.omega[k] / sim.omega / f))) k = i;
232+ ctx.fillText(`f ${f.toPrecision(3)} × driven · t ${this.times[c].toFixed(0)} · ${db.toFixed(0)} dB · nearest mode (${M.m[k]}, ${M.n[k]}) at ${(M.omega[k] / sim.omega).toFixed(2)}`, L, 13);
233+ } else {
234+ ctx.fillStyle = "#7a7d82";
235+ ctx.fillText("at the listening point ○ · ticks: modes' linear pitches · hover to read", L, 13);
236+ }
237+ }
238+}
src/driven-plate/main.tsmodified+26−0View file
@@ -4,6 +4,7 @@ import { fit } from "../common/canvas";
44 import { setupHelp } from "../common/help";
55 import { DrivenPlate } from "./drive";
66 import { History } from "./history";
7+import { LiveSpectrogram } from "./livespec";
78 import { ModeMap } from "./modemap";
89 import { Modes, gridFor, type Params } from "./plate";
910
@@ -16,6 +17,8 @@ const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as
1617 const plateCanvas = $<HTMLCanvasElement>("plate");
1718 const chartCanvas = $<HTMLCanvasElement>("chart");
1819 const modeMap = new ModeMap($<HTMLCanvasElement>("modemap"));
20+const spec = new LiveSpectrogram($<HTMLCanvasElement>("spec"));
21+const perTimeBox = $<HTMLInputElement>("spec-per-time");
1922 const readout = $("readout");
2023 const runButton = $<HTMLButtonElement>("run");
2124 const rampBox = $<HTMLInputElement>("ramp");
@@ -41,6 +44,7 @@ let logTarget = LOG_START;
4144 let actualSpeed = 0; // periods per second, smoothed
4245 let ampl = 0; // colour scale: recent largest |u|
4346 let hist: History;
47+let listen = { fx: 0.37, fy: 0.29 }; // listening point, fractions of the sides
4448 // Drive and loss power (E_nl per driven period), smoothed.
4549 let drivePower = 0, lossPower = 0;
4650
@@ -65,12 +69,14 @@ function fillModes(): void {
6569 function reset(): void {
6670 const [m, n] = modeSelect.value.split(",").map(Number);
6771 sim = new DrivenPlate(P, { m, n, E0: 10 ** LOG_START, perturb: perturbBox.checked });
72+ sim.setListen(listen.fx, listen.fy);
6873 logTarget = LOG_START;
6974 targetSlider.value = String(LOG_START);
7075 applyLoss();
7176 drivePower = lossPower = 0;
7277 hist = new History(sim);
7378 modeMap.reset(sim);
79+ spec.reset(sim);
7480 ampl = 0;
7581 showParams();
7682 }
@@ -129,6 +135,7 @@ function frame(now: number): void {
129135 }
130136 actualSpeed = 0.9 * actualSpeed + 0.1 * (wall > 0 ? periods / wall : 0);
131137 hist.update(sim);
138+ spec.push(sim.takeSignal(), sim.t / sim.period);
132139 } else actualSpeed = 0;
133140 draw();
134141 requestAnimationFrame(frame);
@@ -138,6 +145,7 @@ function draw(): void {
138145 drawPlate();
139146 drawChart();
140147 modeMap.draw(hist);
148+ spec.draw(sim.t / sim.period);
141149 speedValue.textContent = `${fmt(speed(), 2)} /s`;
142150 const { driven, others } = split(hist.last.modes), E = sim.E / sim.Enl;
143151 const T = sim.measuredPeriod / sim.period;
@@ -148,6 +156,8 @@ function draw(): void {
148156 `speed ${running ? fmt(actualSpeed, 2) : "0"} periods/s · energy error ${Math.abs(sim.energyError).toExponential(1)}`;
149157 }
150158
159+// Where the plate was drawn (px), for clicks.
160+let plateBox = { x0: 0, y0: 0, w: 1, h: 1 };
151161 // Height map, as in ../plate: u bilinear between the nodes (0 at the
152162 // supports), red up, blue down, scaled to the recent largest |u|.
153163 const RES = 200;
@@ -197,6 +207,13 @@ function drawPlate(): void {
197207 ctx.strokeStyle = "#7a7d82";
198208 ctx.lineWidth = 2;
199209 ctx.strokeRect(x0, y0, w, h);
210+ plateBox = { x0, y0, w, h };
211+ // Listening point.
212+ ctx.strokeStyle = "#23262b";
213+ ctx.lineWidth = 1.5;
214+ ctx.beginPath();
215+ ctx.arc(x0 + listen.fx * w, y0 + (1 - listen.fy) * h, 6, 0, 2 * Math.PI);
216+ ctx.stroke();
200217 ctx.fillStyle = "#7a7d82";
201218 ctx.font = "11px system-ui, sans-serif";
202219 ctx.textAlign = "right";
@@ -307,6 +324,15 @@ function applyGrid(): void {
307324 nyInput.addEventListener("change", applyGrid);
308325 aspectInput.addEventListener("change", applyGrid);
309326 modeSelect.addEventListener("change", reset);
327+// Click the plate to move the listening point (the run goes on).
328+plateCanvas.addEventListener("click", (e) => {
329+ const r = plateCanvas.getBoundingClientRect(), b = plateBox;
330+ const fx = (e.clientX - r.left - b.x0) / b.w, fy = 1 - (e.clientY - r.top - b.y0) / b.h;
331+ if (fx <= 0 || fx >= 1 || fy <= 0 || fy >= 1) return;
332+ listen = { fx, fy };
333+ sim.setListen(fx, fy);
334+});
335+perTimeBox.addEventListener("change", () => (spec.perTime = perTimeBox.checked));
310336 // Loss: applies to the running plate.
311337 function applyLoss(): void {
312338 const tau = Number(lossTauInput.value);
src/driven-plate/style.cssmodified+2−0View file
@@ -13,4 +13,6 @@ input[type="number"] { width: 4.5em; font-family: var(--mono); }
1313 .value-w { min-width: 6em; text-align: right; }
1414 #params { margin-top: 12px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
1515 .wide { margin-top: 16px; }
16+#spec { height: 380px; cursor: crosshair; }
17+#plate { cursor: crosshair; }
1618 #modemap { height: 420px; cursor: crosshair; }
↑↓ move↵openescclose