/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Driven plate: listening (records displacement, velocity, acceleration at the listening point, 512 per period; plays back from any time at a set pitch while the run computes; steady or fixed level; start and playhead marks on the time charts); spectrogram follows the chosen signal; ramp-rate slider; target up to 1e3 E_nl
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 206d3e9b33d0 parent ea9e7ca Browse files
9 changed files+386−43
src/driven-plate/README.mdmodified+6−2View 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. Step 3 (2026-10-09): spectrogram at a listening point.
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. Step 4 (2026-10-09): listening (playback of the recorded run from any time, while it computes); ramp-rate slider; target up to 10³ E_nl.
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
@@ -22,6 +22,10 @@ Checks (2026-10-09): decay times of (11, 8) and (6, 4) at tiny amplitude, drive
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.
2424
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.
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 (512 since step 4) sample intervals per driven period (crude anti-alias). Hann window of 32 periods, zero padded to twice that (bins 1/64 of the driven pitch), a column every 4 periods, the power kept in 240 log-spaced bands from 0.25 driven pitches to the Nyquist frequency (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.
2626
2727 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.
28+
29+Listening (step 4, `recording.ts`, `player.ts`, `marks.ts`; Jeremy: always compute ahead and play back, from any point, while the run goes on). The run computes as before (main thread, Speed); `drive.ts` records displacement, velocity and acceleration (with the drive force) at the listening point, 512 samples per driven period, each the mean over its interval, kept whole (`Recording`, Float32 chunks, 12 MB per 2000 periods). Playback (`LivePlayer`) maps one driven period to 1 / pitch s (pitch 20 to 160 Hz, default 60), resamples by a windowed sinc (Hann, 8 zero crossings, cutoff 0.9 of the lower Nyquist), renders 0.2 s chunks scheduled back to back 0.6 s ahead of the audio clock, and stops when it reaches the computed end (status says so). Level: steady (divide by a peak follower, instant attack, 1.5 s release; default) or as computed (one scale from the loudest recorded so far, tanh limit). Clicking the energy chart, spectrogram or mode map sets the start (dashed line on all three; the playhead is a solid line); changing signal, pitch or level restarts from the playhead. The spectrogram shows the signal chosen for listening (velocity by default) and is remade from the recording when it changes. Ramp rate: log slider, 5 to 2000 periods per decade (default 400); target slider to 10³ E_nl.
30+
31+Checks (2026-10-09): resampler offline (bun, fake audio context): a recorded tone at the driven pitch plays at 60.000 Hz at pitch 60; a 37 x component keeps its 0.1 ratio (0.0999); steady level holds 0.500 per chunk while the recorded amplitude rises; the largest step between output samples (0.0168) stays below the signal's own bound (0.0185), so chunk joins are seamless. In the browser: start set by a click, playback at 60 periods per second, the playhead on all three charts, switching to displacement remakes the spectrogram, no console errors. Sample rate: velocity spectrum at the listening point (sampled 2048 per period, held energies) by octave of the driven pitch: at 100 E_nl −37 dB at 128-256 x, −64 dB above 256 x; at 10³ E_nl −31 and −46 dB; so 256 samples per period (first version) would fold the 128-256 band back at about −40 dB after the averaging; 512 was chosen. Cost at 10³ E_nl: about 10000 steps per period, 10 periods per second on the main thread.
src/driven-plate/drive.tsmodified+24−19View file
@@ -24,14 +24,16 @@
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).
27+// Signal (for the spectrogram and playback): displacement, velocity and
28+// acceleration at a listening point (bilinear between the nodes, 0 at the
29+// supports), each averaged over every sample interval, 1 / SIGNAL_RATE of a
30+// driven period (the average is a crude anti-alias filter: content above the
31+// sample rate is mostly averaged out), kept whole in a Recording.
3132 // Mode energies for the displays are averaged over time (sampled every
3233 // 1 / SAMPLES_PER_PERIOD of a driven period): a mode's linear energy swings
3334 // within each cycle when it is driven off resonance by the others.
3435
36+import { Recording } from "./recording";
3537 import { Modes, Plate, gridFor, modeShape, nonlinearScale, omegaOf, stepFor, type Grid, type Params } from "./plate";
3638
3739 const TAU_C = 2; // controller time constant, in driven periods
@@ -39,7 +41,10 @@ const G_MAX = 0.2; // cap on G, times the driven mode's omega
3941 const PERTURB = 1e-6; // energy of the perturbation, relative to the starting energy
4042 const SAMPLES_PER_PERIOD = 16;
4143 const LOSS_EVERY = 8;
42-export const SIGNAL_RATE = 256; // samples per driven period
44+// Samples per driven period: 512, so the recording reaches 256 x the driven
45+// pitch. At 1e3 E_nl the velocity spectrum is -31 dB at 128-256 x and -46 dB
46+// above 256 x (2026-10-09), so 256 per period would fold the 128-256 band back.
47+export const SIGNAL_RATE = 512;
4348
4449 export interface Loss {
4550 tauTop: number; // amplitude decay time of the top mode, in driven periods; 0 = off
@@ -194,11 +199,15 @@ export class DrivenPlate {
194199 this.lastCross = tc;
195200 }
196201 this.t += h;
197- this.sigSum += this.listenValue();
202+ // Signal: velocity p / M; acceleration (F + drive) / M.
203+ this.sumU += this.at(u);
204+ this.sumV += this.at(p) / M;
205+ this.sumA += (this.at(F) + f * this.at(S)) / M;
198206 this.sigN++;
199207 if (this.t >= this.nextSig) {
200- this.sig.push(this.sigSum / this.sigN);
201- this.sigSum = this.sigN = 0;
208+ const c = 1 / this.sigN;
209+ this.rec.push(this.sumU * c, this.sumV * c, this.sumA * c);
210+ this.sumU = this.sumV = this.sumA = this.sigN = 0;
202211 this.nextSig += this.period / SIGNAL_RATE;
203212 }
204213 if (this.t >= this.nextSample) {
@@ -248,20 +257,16 @@ export class DrivenPlate {
248257 this.lw[c] = inside ? w : 0; // supports: 0
249258 });
250259 }
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]];
260+ private at(x: Float64Array): number {
261+ const k = this.lk, w = this.lw;
262+ return w[0] * x[k[0]] + w[1] * x[k[1]] + w[2] * x[k[2]] + w[3] * x[k[3]];
254263 }
255- private sigSum = 0;
264+ rec = new Recording();
265+ private sumU = 0;
266+ private sumV = 0;
267+ private sumA = 0;
256268 private sigN = 0;
257269 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- }
265270
266271 private lossHat: Float64Array | null = null;
267272 private applyLoss(dt: number): void {
src/driven-plate/index.htmlmodified+37−4View file
@@ -17,12 +17,16 @@
1717 <button id="run" class="primary">Pause</button>
1818 <button id="reset">Reset</button>
1919 <label class="chip" title="Raise the target energy steadily (log scale) up to the top of the slider"><input id="ramp" type="checkbox" checked /><span>Ramp</span></label>
20- <span class="muted"><input id="per-decade" type="number" min="10" step="10" value="400" /> periods per decade</span>
2120 <button type="button" class="help" data-help="drive" title="About the driven plate" aria-label="About the driven plate">?</button>
2221 </div>
22+ <div class="row">
23+ <span class="muted label-w" title="Driven periods for each factor of ten in energy">Ramp</span>
24+ <input id="ramp-rate" type="range" min="0.699" max="3.301" step="0.01" value="2.602" />
25+ <span id="ramp-value" class="num value-w"></span>
26+ </div>
2327 <div class="row">
2428 <span class="muted label-w">Target E / E<sub>nl</sub></span>
25- <input id="target" type="range" min="-4" max="2" step="0.01" value="-3" />
29+ <input id="target" type="range" min="-4" max="3" step="0.01" value="-3" />
2630 <span id="target-value" class="num value-w"></span>
2731 </div>
2832 <div class="row">
@@ -66,6 +70,29 @@
6670 </section>
6771 </div>
6872 </div>
73+ <section class="card wide">
74+ <div class="card-head">Listen <button type="button" class="help" data-help="listen" title="About listening" aria-label="About listening">?</button></div>
75+ <div class="row">
76+ <button id="play" class="primary">Play</button>
77+ <button id="mute" type="button" class="mute"></button>
78+ <select id="signal" title="What is heard (and shown in the spectrogram), at the listening point">
79+ <option value="v" selected>velocity</option>
80+ <option value="u">displacement</option>
81+ <option value="a">acceleration</option>
82+ </select>
83+ <select id="level" title="auto: steady level as the energy rises; as computed: one fixed scale">
84+ <option value="auto" selected>steady level</option>
85+ <option value="fixed">as computed</option>
86+ </select>
87+ <span class="muted">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" style="max-width: 110px" />
88+ </div>
89+ <div class="row">
90+ <span class="muted label-w" title="The pitch at which the driven mode sounds">Driven mode at</span>
91+ <input id="pitch" type="range" min="1.301" max="2.204" step="0.001" value="1.778" />
92+ <span id="pitch-value" class="num value-w"></span>
93+ </div>
94+ <div class="row"><span id="listen-status" class="muted"></span></div>
95+ </section>
6996 <section class="card wide">
7097 <div class="card-head">Spectrogram
7198 <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>
@@ -86,7 +113,7 @@
86113 <h2>The driven plate</h2>
87114 <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>
88115 <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>
89- <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>
116+ <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 (set by the Ramp slider, 5 to 2000), up to the top of the slider (10<sup>3</sup> E<sub>nl</sub>); untick it to hold the energy where it is. With very fast ramps the controller (time constant 2 periods) falls behind the target. 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>
90117 </template>
91118 <template id="help-params">
92119 <h2>Plate and drive</h2>
@@ -99,9 +126,15 @@
99126 <h2>Energy</h2>
100127 <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>
101128 </template>
129+ <template id="help-listen">
130+ <h2>Listen</h2>
131+ <p>The run is recorded at the listening point (the circle on the plate) as it is computed, and can be played back from any time while the computation goes on. Time is sped up so the driven mode sounds at the chosen pitch: one driven period of the plate lasts 1/60 s of sound at 60 Hz, so playback goes through 60 periods each second, usually faster than they are computed; if it reaches the end of what has been computed it stops and says so. Raise Speed, or start earlier, to hear more.</p>
132+ <p>Click the energy chart, the spectrogram or the mode map to choose where playback starts (dashed line); the solid line is what is being heard. Velocity is heard by default (closer to the sound a plate radiates, brighter than displacement); the spectrogram shows the same signal. Steady level divides by a slowly released peak follower, so the sound stays at one level while the energy rises a hundred thousand times and what you hear change is the timbre; as computed uses one scale for the whole run (set by the loudest part recorded when Play is pressed), so the start is faint.</p>
133+ <p>The recording keeps 512 samples per driven period, so frequencies up to 256 times the driven pitch are heard (15 kHz at 60 Hz).</p>
134+ </template>
102135 <template id="help-spec">
103136 <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>
137+ <p>The signal chosen under Listen (velocity by default) at the listening point (the circle on the plate; click the plate to move it), as a pickup there would see it: what one hears, 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>
105138 <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>
106139 </template>
107140 <template id="help-modes">
src/driven-plate/livespec.tsmodified+33−10View file
@@ -1,4 +1,5 @@
1-// Spectrogram of the listening point's displacement over the whole run, time
1+// Spectrogram of the signal at the listening point (the one chosen for
2+// listening: velocity, displacement or acceleration) over the whole run, time
23 // across as in the energy chart and the mode map. Samples come at
34 // SIGNAL_RATE per driven period; every HOP periods a Hann window of WINDOW
45 // periods is transformed (zero padded to twice its length), and the power is
@@ -9,11 +10,16 @@
910 // merged in pairs when there are MAX_COLS. Colour: dB, RANGE_DB deep, below
1011 // each column's own peak (Per time, the default: the spectrum's shape at
1112 // 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+// Reference ticks on the right edge mark the modes' linear pitches. The
14+// columns are made from the run's Recording as it grows; choosing another
15+// signal remakes them all from the start. Marks (playback start, playhead)
16+// are drawn as vertical lines; a click reports its time.
1317
1418 import { fit } from "../common/canvas";
1519 import { FFT, colormap } from "../common/spectrogram";
1620 import { SIGNAL_RATE, type DrivenPlate } from "./drive";
21+import type { Recording, SignalKind } from "./recording";
22+import { drawMarks, type Marks } from "./marks";
1723
1824 const WINDOW = 32; // periods
1925 const HOP = 4; // periods
@@ -35,7 +41,8 @@ export class LiveSpectrogram {
3541 private im = new Float64Array(NFFT);
3642 private hann = new Float64Array(N);
3743 private buf = new Float64Array(N);
38- private n = 0; // samples so far
44+ private n = 0; // samples used so far
45+ private kind: SignalKind = "v";
3946 private rowLo = new Int32Array(ROWS); // FFT bins in each band
4047 private rowHi = new Int32Array(ROWS);
4148 private fHi = SIGNAL_RATE / 2; // top of the axis (Nyquist), in driven pitches
@@ -74,6 +81,19 @@ export class LiveSpectrogram {
7481 this.hover = { x: e.clientX - b.left, y: e.clientY - b.top };
7582 });
7683 canvas.addEventListener("mouseleave", () => (this.hover = null));
84+ canvas.addEventListener("click", (e) => {
85+ const b = canvas.getBoundingClientRect(), x = e.clientX - b.left, pw = canvas.clientWidth - L - R;
86+ if (x >= L && x <= L + pw && this.onPick) this.onPick(((x - L) / pw) * this.tMax);
87+ });
88+ }
89+ onPick: ((t: number) => void) | null = null;
90+ private tMax = 1;
91+
92+ // Choose the signal: the columns are remade from the start.
93+ setKind(kind: SignalKind): void {
94+ if (kind === this.kind) return;
95+ this.kind = kind;
96+ if (this.sim) this.reset(this.sim);
7797 }
7898
7999 reset(sim: DrivenPlate): void {
@@ -90,13 +110,14 @@ export class LiveSpectrogram {
90110 this.paintKey = "";
91111 }
92112
93- // New samples; `tEnd` is the time (periods) of the last one.
94- push(samples: number[], tEnd: number): void {
113+ // Take in the samples recorded since the last call. Sample j is the mean
114+ // over [j, j + 1) / SIGNAL_RATE periods; a column is centred on its window.
115+ update(rec: Recording): void {
95116 const hopN = HOP * SIGNAL_RATE;
96- for (let i = 0; i < samples.length; i++) {
97- this.buf[this.n % N] = samples[i];
117+ for (; this.n < rec.length; ) {
118+ this.buf[this.n % N] = rec.get(this.kind, this.n);
98119 this.n++;
99- if (this.n >= N && (this.n - N) % hopN === 0) this.column(tEnd - (samples.length - 1 - i) / SIGNAL_RATE - WINDOW / 2);
120+ if (this.n >= N && (this.n - N) % hopN === 0) this.column((this.n - N / 2) / SIGNAL_RATE);
100121 }
101122 }
102123
@@ -139,7 +160,7 @@ export class LiveSpectrogram {
139160 }
140161 }
141162
142- draw(tNow: number): void {
163+ draw(tNow: number, marks: Marks): void {
143164 const sim = this.sim!, n = this.cols.length;
144165 const peakAll = this.colPeak.reduce((a, b) => Math.max(a, b), 0);
145166 // Repaint everything when the colouring changes: mode, or (absolute) a
@@ -164,6 +185,7 @@ export class LiveSpectrogram {
164185 const ctx = fit(this.canvas), W = this.canvas.clientWidth, H = this.canvas.clientHeight;
165186 ctx.clearRect(0, 0, W, H);
166187 const pw = W - L - R, ph = H - T - B, tMax = Math.max(tNow, 1);
188+ this.tMax = tMax;
167189 const X = (t: number) => L + (t / tMax) * pw;
168190 const Y = (f: number) => T + ph * (1 - Math.log(f / F_LO) / Math.log(this.fHi / F_LO));
169191 ctx.fillStyle = "#f2f2ef";
@@ -178,6 +200,7 @@ export class LiveSpectrogram {
178200 }
179201 ctx.strokeStyle = "#e3e2dc";
180202 ctx.strokeRect(L - 0.5, T - 0.5, pw + 1, ph + 1);
203+ drawMarks(ctx, marks, X, T, ph, "#ffffff");
181204 // Frequency axis: 1, 2, 5 steps, in driven pitches.
182205 ctx.font = "11px system-ui, sans-serif";
183206 ctx.fillStyle = "#7a7d82";
@@ -232,7 +255,7 @@ export class LiveSpectrogram {
232255 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);
233256 } else {
234257 ctx.fillStyle = "#7a7d82";
235- ctx.fillText("at the listening point ○ · ticks: modes' linear pitches · hover to read", L, 13);
258+ ctx.fillText(`${{ v: "velocity", u: "displacement", a: "acceleration" }[this.kind]} at the listening point ○ · ticks: modes' pitches · click: play from`, L, 13);
236259 }
237260 }
238261 }
src/driven-plate/main.tsmodified+65−6View file
@@ -2,10 +2,14 @@ import "../common/style.css";
22 import "./style.css";
33 import { fit } from "../common/canvas";
44 import { setupHelp } from "../common/help";
5+import { setupMute } from "../common/mute";
56 import { DrivenPlate } from "./drive";
67 import { History } from "./history";
78 import { LiveSpectrogram } from "./livespec";
89 import { ModeMap } from "./modemap";
10+import { drawMarks, type Marks } from "./marks";
11+import { LivePlayer, type Level } from "./player";
12+import type { SignalKind } from "./recording";
913 import { Modes, gridFor, type Params } from "./plate";
1014
1115 setupHelp();
@@ -22,7 +26,8 @@ const perTimeBox = $<HTMLInputElement>("spec-per-time");
2226 const readout = $("readout");
2327 const runButton = $<HTMLButtonElement>("run");
2428 const rampBox = $<HTMLInputElement>("ramp");
25-const perDecadeInput = $<HTMLInputElement>("per-decade");
29+const rampRate = $<HTMLInputElement>("ramp-rate");
30+const rampValue = $("ramp-value");
2631 const targetSlider = $<HTMLInputElement>("target");
2732 const targetValue = $("target-value");
2833 const speedSlider = $<HTMLInputElement>("speed");
@@ -36,6 +41,17 @@ const paramsDiv = $("params");
3641 const lossBox = $<HTMLInputElement>("loss");
3742 const lossTauInput = $<HTMLInputElement>("loss-tau");
3843 const lossPowerSelect = $<HTMLSelectElement>("loss-power");
44+const playButton = $<HTMLButtonElement>("play");
45+const signalSelect = $<HTMLSelectElement>("signal");
46+const pitchSlider = $<HTMLInputElement>("pitch");
47+const pitchValue = $("pitch-value");
48+const levelSelect = $<HTMLSelectElement>("level");
49+const listenStatus = $("listen-status");
50+const player = new LivePlayer();
51+setupMute($<HTMLButtonElement>("mute"), player);
52+$<HTMLInputElement>("volume").addEventListener("input", (e) => player.setVolume(Number((e.target as HTMLInputElement).value)));
53+// Playback start (set by clicking a time chart) and playhead, in periods.
54+const marks: Marks = { start: null, play: null };
3955
4056 let P: Params = { Ny: 8, aspect: 1.3 };
4157 let sim: DrivenPlate;
@@ -50,6 +66,8 @@ let drivePower = 0, lossPower = 0;
5066
5167 const fmt = (x: number, d = 3) => (x === 0 ? "0" : Math.abs(x) >= 1e-2 && Math.abs(x) < 1e4 ? x.toPrecision(d) : x.toExponential(d - 1));
5268 const speed = () => 10 ** Number(speedSlider.value);
69+const perDecade = () => 10 ** Number(rampRate.value);
70+const pitch = () => 10 ** Number(pitchSlider.value);
5371
5472 // The mode list for the current grid: lowest modes by pitch.
5573 function fillModes(): void {
@@ -77,6 +95,8 @@ function reset(): void {
7795 hist = new History(sim);
7896 modeMap.reset(sim);
7997 spec.reset(sim);
98+ player.stop();
99+ marks.start = marks.play = null;
80100 ampl = 0;
81101 showParams();
82102 }
@@ -120,7 +140,7 @@ function frame(now: number): void {
120140 periods = Math.min(periods, BUDGET_MS / msPerPeriod);
121141 if (rampBox.checked) {
122142 const top = Number(targetSlider.max);
123- logTarget = Math.min(top, logTarget + periods / Number(perDecadeInput.value || 400));
143+ logTarget = Math.min(top, logTarget + periods / perDecade());
124144 targetSlider.value = String(logTarget);
125145 }
126146 setTarget();
@@ -135,8 +155,10 @@ function frame(now: number): void {
135155 }
136156 actualSpeed = 0.9 * actualSpeed + 0.1 * (wall > 0 ? periods / wall : 0);
137157 hist.update(sim);
138- spec.push(sim.takeSignal(), sim.t / sim.period);
139158 } else actualSpeed = 0;
159+ spec.update(sim.rec);
160+ player.tick(sim.rec);
161+ marks.play = player.position();
140162 draw();
141163 requestAnimationFrame(frame);
142164 }
@@ -144,9 +166,15 @@ function frame(now: number): void {
144166 function draw(): void {
145167 drawPlate();
146168 drawChart();
147- modeMap.draw(hist);
148- spec.draw(sim.t / sim.period);
169+ modeMap.draw(hist, marks);
170+ spec.draw(sim.t / sim.period, marks);
149171 speedValue.textContent = `${fmt(speed(), 2)} /s`;
172+ rampValue.textContent = `${fmt(perDecade(), 2)} /decade`;
173+ pitchValue.textContent = `${pitch().toFixed(0)} Hz`;
174+ playButton.textContent = player.playing ? "Stop" : "Play";
175+ listenStatus.textContent = player.playing
176+ ? `playing t ${marks.play!.toFixed(0)} of ${(sim.t / sim.period).toFixed(0)} computed`
177+ : player.ended || `from t ${(marks.start ?? 0).toFixed(0)} (click a time chart to choose)`;
150178 const { driven, others } = split(hist.last.modes), E = sim.E / sim.Enl;
151179 const T = sim.measuredPeriod / sim.period;
152180 readout.textContent =
@@ -222,7 +250,8 @@ function drawPlate(): void {
222250 }
223251
224252 // Energy chart: log10(E / E_nl) against time in periods, from the history's
225-// bins (mode energies averaged over each bin).
253+// bins (mode energies averaged over each bin). Its time axis, for clicks:
254+let chartMap = { l: 40, pw: 1, tMax: 1 };
226255 const SERIES = [
227256 { key: "E", color: "#23262b", dash: [] as number[], label: "total" },
228257 { key: "Et", color: "#7a7d82", dash: [4, 4], label: "target" },
@@ -252,6 +281,7 @@ function drawChart(): void {
252281 lo = Math.max(Math.floor(lo), hi - 14);
253282 hi = Math.ceil(hi + 0.01);
254283 const X = (x: number) => l + (x / tMax) * pw, Y = (y: number) => t + ((hi - Math.max(lo, y)) / (hi - lo)) * ph;
284+ chartMap = { l, pw, tMax };
255285 ctx.font = "11px system-ui, sans-serif";
256286 ctx.fillStyle = "#7a7d82";
257287 ctx.strokeStyle = "#e3e2dc";
@@ -277,6 +307,7 @@ function drawChart(): void {
277307 ctx.stroke();
278308 }
279309 ctx.setLineDash([]);
310+ drawMarks(ctx, marks, X, t, ph, "#23262b");
280311 // Legend.
281312 ctx.textAlign = "left";
282313 let x = l;
@@ -342,6 +373,34 @@ function applyLoss(): void {
342373 for (const el of [lossBox, lossTauInput, lossPowerSelect]) el.addEventListener("change", applyLoss);
343374 perturbBox.addEventListener("change", reset);
344375
376+// Listening: play the recorded signal from the chosen start; clicking a time
377+// chart moves the start (and restarts playback there if playing).
378+function play(from: number): void {
379+ player.kind = signalSelect.value as SignalKind;
380+ player.pitch = pitch();
381+ player.level = levelSelect.value as Level;
382+ player.start(from, sim.rec);
383+}
384+playButton.addEventListener("click", () => (player.playing ? player.stop() : play(marks.start ?? 0)));
385+function pick(t: number): void {
386+ marks.start = Math.max(0, t);
387+ player.ended = "";
388+ if (player.playing) play(marks.start);
389+}
390+spec.onPick = pick;
391+modeMap.onPick = pick;
392+chartCanvas.addEventListener("click", (e) => {
393+ const x = e.clientX - chartCanvas.getBoundingClientRect().left, m = chartMap;
394+ if (x >= m.l && x <= m.l + m.pw) pick(((x - m.l) / m.pw) * m.tMax);
395+});
396+// Changing how it sounds restarts playback from where it is.
397+for (const el of [signalSelect, pitchSlider, levelSelect]) {
398+ el.addEventListener("change", () => {
399+ if (el === signalSelect) spec.setKind(signalSelect.value as SignalKind);
400+ if (player.playing) play(player.position() ?? marks.start ?? 0);
401+ });
402+}
403+
345404 fillModes();
346405 reset();
347406 setTarget();
src/driven-plate/marks.tsadded+24−0View file
@@ -0,0 +1,24 @@
1+// Marks shared by the time charts: where playback starts (dashed) and the
2+// playhead (solid), in driven periods; null when not shown.
3+
4+export interface Marks {
5+ start: number | null;
6+ play: number | null;
7+}
8+
9+// Vertical lines at the marks, between y0 and y0 + h; X maps time to px.
10+export function drawMarks(ctx: CanvasRenderingContext2D, m: Marks, X: (t: number) => number, y0: number, h: number, color: string): void {
11+ ctx.save();
12+ ctx.strokeStyle = color;
13+ ctx.lineWidth = 1.5;
14+ const lines: [number | null, number[]][] = [[m.start, [4, 3]], [m.play, []]];
15+ for (const [t, dash] of lines) {
16+ if (t === null) continue;
17+ ctx.setLineDash(dash);
18+ ctx.beginPath();
19+ ctx.moveTo(Math.round(X(t)) + 0.5, y0);
20+ ctx.lineTo(Math.round(X(t)) + 0.5, y0 + h);
21+ ctx.stroke();
22+ }
23+ ctx.restore();
24+}
src/driven-plate/modemap.tsmodified+11−2View file
@@ -9,6 +9,7 @@
99 import { fit } from "../common/canvas";
1010 import type { DrivenPlate } from "./drive";
1111 import { MAX_BINS, type History } from "./history";
12+import { drawMarks, type Marks } from "./marks";
1213
1314 const DECADES = 12;
1415 const L = 92, R = 8, T = 44, B = 22; // margins (px)
@@ -48,7 +49,13 @@ export class ModeMap {
4849 this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
4950 });
5051 canvas.addEventListener("mouseleave", () => (this.hover = null));
52+ canvas.addEventListener("click", (e) => {
53+ const b = canvas.getBoundingClientRect(), x = e.clientX - b.left, pw = canvas.clientWidth - L - R;
54+ if (x >= L && x <= L + pw && this.onPick) this.onPick(((x - L) / pw) * this.tMax);
55+ });
5156 }
57+ onPick: ((t: number) => void) | null = null;
58+ private tMax = 1;
5259
5360 // A new run (new plate or reset).
5461 reset(sim: DrivenPlate): void {
@@ -63,7 +70,7 @@ export class ModeMap {
6370 this.top = NaN;
6471 }
6572
66- draw(hist: History): void {
73+ draw(hist: History, marks: Marks): void {
6774 const sim = this.sim!, data = this.data!, bins = hist.bins, count = this.rows.length;
6875 // Colour scale: top at the decade above the largest energy so far.
6976 let mx = 0;
@@ -115,6 +122,8 @@ export class ModeMap {
115122
116123 // Time axis and colour bar.
117124 const tMax = Math.max(hist.last.t, 1);
125+ this.tMax = tMax;
126+ drawMarks(ctx, marks, (t) => L + (t / tMax) * pw, T, ph, "#ffffff");
118127 ctx.font = "11px system-ui, sans-serif";
119128 ctx.fillStyle = "#7a7d82";
120129 ctx.textAlign = "center";
@@ -144,7 +153,7 @@ export class ModeMap {
144153 ctx.strokeRect(L, yOf(r) - rowH / 2, pw, rowH);
145154 } else {
146155 ctx.fillStyle = "#7a7d82";
147- ctx.fillText("rows: modes by pitch (× driven); hover to read", L, 13);
156+ ctx.fillText("rows: modes by pitch (× driven) · hover to read · click: play from", L, 13);
148157 }
149158 }
150159 }
src/driven-plate/player.tsadded+158−0View file
@@ -0,0 +1,158 @@
1+// Playback of the run's recorded signal (see recording.ts), from any time,
2+// while the run goes on. Time is scaled so the driven mode sounds at `pitch`
3+// Hz: one driven period of the run lasts 1 / pitch s of sound. The recording
4+// (SIGNAL_RATE samples per period) is resampled to the audio rate by a
5+// windowed sinc (Hann, HALF zero crossings each side), its cutoff 0.9 of the
6+// lower of the two Nyquist frequencies. Chunks of CHUNK_S s are rendered and
7+// scheduled back to back, AHEAD_S ahead of the audio clock; when playback
8+// reaches the end of what has been computed it stops (the caller says so).
9+// Level: "auto" divides by a peak follower (instant attack, RELEASE_S
10+// release), so the level stays steady while the energy rises by many
11+// decades and what changes is the timbre; "fixed" scales by the largest
12+// value recorded so far (later, louder parts are soft-limited by tanh).
13+
14+import { SIGNAL_RATE } from "./drive";
15+import type { Recording, SignalKind } from "./recording";
16+
17+const CHUNK_S = 0.2;
18+const AHEAD_S = 0.6;
19+const HALF = 8;
20+const RELEASE_S = 1.5;
21+const FADE_S = 0.01;
22+
23+export type Level = "auto" | "fixed";
24+
25+export class LivePlayer {
26+ private ctx: AudioContext | null = null;
27+ private gain: GainNode | null = null;
28+ private volume = 0.5;
29+ muted = false;
30+ playing = false;
31+ pitch = 60; // Hz of the driven mode
32+ kind: SignalKind = "v";
33+ level: Level = "auto";
34+ ended = ""; // why playback last stopped by itself
35+ private t0 = 0; // run time (periods) at ctx time ctx0
36+ private ctx0 = 0;
37+ private nextCtx = 0; // ctx time of the next chunk
38+ private nextT = 0; // run time (periods) of the next chunk's first sample
39+ private env = 0;
40+ private fixedScale = 1;
41+ private first = true;
42+ private sources: AudioBufferSourceNode[] = [];
43+
44+ private ensure(): AudioContext {
45+ if (!this.ctx) {
46+ this.ctx = new AudioContext();
47+ this.gain = this.ctx.createGain();
48+ this.gain.connect(this.ctx.destination);
49+ this.applyGain();
50+ }
51+ return this.ctx;
52+ }
53+ private applyGain(): void {
54+ if (this.gain) this.gain.gain.value = this.muted ? 0 : this.volume;
55+ }
56+ setVolume(v: number): void {
57+ this.volume = v;
58+ this.applyGain();
59+ }
60+ setMuted(m: boolean): void {
61+ this.muted = m;
62+ this.applyGain();
63+ }
64+
65+ // Start playing from run time t (periods); call from a user gesture.
66+ start(t: number, rec: Recording): void {
67+ const ctx = this.ensure();
68+ void ctx.resume();
69+ this.stop();
70+ this.playing = true;
71+ this.ended = "";
72+ this.t0 = this.nextT = Math.max(0, t);
73+ this.ctx0 = this.nextCtx = ctx.currentTime + 0.05;
74+ this.env = 0;
75+ this.first = true;
76+ let peak = 0;
77+ for (let j = 0; j < rec.length; j++) peak = Math.max(peak, Math.abs(rec.get(this.kind, j)));
78+ this.fixedScale = peak > 0 ? 0.7 / peak : 1;
79+ }
80+
81+ stop(): void {
82+ for (const s of this.sources) {
83+ s.onended = null;
84+ s.stop();
85+ }
86+ this.sources = [];
87+ this.playing = false;
88+ }
89+
90+ // Run time (periods) being heard now, or null.
91+ position(): number | null {
92+ if (!this.playing || !this.ctx) return null;
93+ const t = this.t0 + Math.max(0, this.ctx.currentTime - this.ctx0) * this.pitch;
94+ return Math.min(t, this.nextT);
95+ }
96+
97+ // Keep chunks scheduled ahead; call every frame.
98+ tick(rec: Recording): void {
99+ if (!this.playing || !this.ctx) return;
100+ const ctx = this.ctx, fs = ctx.sampleRate, dT = this.pitch / fs; // periods per output sample
101+ const c = 0.9 * Math.min(1, fs / (SIGNAL_RATE * this.pitch)), K = Math.ceil(HALF / c);
102+ // Last run time with the whole kernel recorded.
103+ const endT = (rec.length - K - 1) / SIGNAL_RATE;
104+ while (this.nextCtx < ctx.currentTime + AHEAD_S) {
105+ const want = Math.round(CHUNK_S * fs), count = Math.min(want, Math.floor((endT - this.nextT) / dT));
106+ if (count < want) {
107+ // Out of computed signal: finish with what there is, faded, and stop
108+ // once it has played.
109+ if (count > 0) this.schedule(this.render(rec, count, dT, c, K, true), fs);
110+ this.ended = `reached the end of the computed run (t ${this.nextT.toFixed(0)}): raise Speed, or start earlier`;
111+ const last = this.sources[this.sources.length - 1];
112+ if (last) last.onended = () => (this.playing = false);
113+ else this.playing = false;
114+ this.nextCtx = Infinity;
115+ return;
116+ }
117+ this.schedule(this.render(rec, count, dT, c, K, false), fs);
118+ }
119+ }
120+
121+ private schedule(x: Float32Array, fs: number): void {
122+ const ctx = this.ctx!, buf = ctx.createBuffer(1, x.length, fs);
123+ buf.copyToChannel(x as Float32Array<ArrayBuffer>, 0);
124+ const src = ctx.createBufferSource();
125+ src.buffer = buf;
126+ src.connect(this.gain!);
127+ src.start(this.nextCtx);
128+ src.onended = () => (this.sources = this.sources.filter((s) => s !== src));
129+ this.sources.push(src);
130+ this.nextCtx += x.length / fs;
131+ }
132+
133+ private render(rec: Recording, count: number, dT: number, c: number, K: number, fadeOut: boolean): Float32Array {
134+ const out = new Float32Array(count), fs = this.ctx!.sampleRate, rel = Math.exp(-1 / (RELEASE_S * fs));
135+ const nFade = Math.round(FADE_S * fs);
136+ for (let i = 0; i < count; i++) {
137+ // Sample j is centred at (j + 0.5) / SIGNAL_RATE periods.
138+ const s = (this.nextT + i * dT) * SIGNAL_RATE - 0.5, j0 = Math.floor(s);
139+ let y = 0;
140+ for (let j = j0 - K + 1; j <= j0 + K; j++) {
141+ const d = s - j, w = 0.5 + 0.5 * Math.cos((Math.PI * d) / K);
142+ const x = Math.PI * c * d, sinc = Math.abs(x) < 1e-9 ? 1 : Math.sin(x) / x;
143+ y += rec.get(this.kind, j) * c * sinc * w;
144+ }
145+ if (this.level === "auto") {
146+ const a = Math.abs(y);
147+ this.env = a > this.env ? a : this.env * rel;
148+ y = this.env > 0 ? (0.5 * y) / this.env : 0;
149+ } else y = Math.tanh(y * this.fixedScale);
150+ if (this.first && i < nFade) y *= i / nFade;
151+ if (fadeOut && count - 1 - i < nFade) y *= (count - 1 - i) / nFade;
152+ out[i] = y;
153+ }
154+ this.first = false;
155+ this.nextT += count * dT;
156+ return out;
157+ }
158+}
src/driven-plate/recording.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+// The run's signal at the listening point, kept whole for the spectrogram
2+// and for playback: displacement u, velocity v and acceleration a, SIGNAL_RATE
3+// samples per driven period (see drive.ts), each the mean over its sample
4+// interval. Stored in chunks of Float32, so a long run grows without copying
5+// (12 bytes per sample: about 12 MB per 2000 periods).
6+
7+export type SignalKind = "v" | "u" | "a";
8+const CHUNK = 1 << 16;
9+
10+export class Recording {
11+ length = 0;
12+ private chunks: Record<SignalKind, Float32Array[]> = { u: [], v: [], a: [] };
13+
14+ push(u: number, v: number, a: number): void {
15+ const c = this.length >> 16, i = this.length & (CHUNK - 1);
16+ if (i === 0) for (const k of ["u", "v", "a"] as const) this.chunks[k].push(new Float32Array(CHUNK));
17+ this.chunks.u[c][i] = u;
18+ this.chunks.v[c][i] = v;
19+ this.chunks.a[c][i] = a;
20+ this.length++;
21+ }
22+
23+ // Sample j of a signal; 0 outside the recording.
24+ get(kind: SignalKind, j: number): number {
25+ if (j < 0 || j >= this.length) return 0;
26+ return this.chunks[kind][j >> 16][j & (CHUNK - 1)];
27+ }
28+}
↑↓ move↵openescclose