Remove the driven plate app
16 changed files+1−2039
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-export class FFT {
67+class FFT {
6868 private n: number;
6969 private cos: Float64Array;
7070 private sin: Float64Array;
src/driven-plate/README.mddeleted+0−35View file
@@ -1,35 +0,0 @@
1-# driven-plate
2-
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. Views (2026-10-09): the live height map flickered (the plate oscillates many times a second at the run speed), so the plate card now has three views, mode grid by default; the page starts paused. 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.
4-
5-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.
6-
7-Drive (`drive.ts`): force in the shape of the driven mode (unit shape S, node force Q √M S on the modal coordinate q = √M S·u), so in the linear plate only that mode moves. Q = G q̇, in phase with the mode's velocity; G = (E_target − E) / (τc Ed) every 1/32 period (τc = 2 periods, Ed the driven mode's linear energy, |G| ≤ 0.2 ω). Q held over each step, its work Q Δq summed; energy error = (E − E_start − W) / E. Start: the driven mode at rest, displaced, at 10⁻³ E_nl; Perturbation adds 10⁻⁶ of that in every other mode (equal energies, random phases, fixed seed). E_nl: energy of the driven mode's shape at which stretching equals bending, Vb(1)² / Vs(1). Step: as `../plate` (0.2 / ω at the top of the band, raised for the in-plane force).
8-
9-Page (`main.ts`): runs on the main thread, advancing speed × frame time in driven periods, at most 14 ms of compute per frame (the actual speed is shown). Ramp: log10 of the target rises by 1 per "periods per decade" (default 400) up to 10². Energy chart: total, target, driven mode's linear energy, sum of the other modes' linear energies (from the sine transforms of u and p), log scale in E_nl.
10-
11-Checks (2026-10-09, Node/bun scratch script): force against central differences of the energy, 5e-10 relative (Nx 9, Ny 6); stretching over bending of the (1, 1) shape at amplitude h on the square, 0.2243, 0.2134, 0.2094 at N = 8, 16, 64 (`../plate`: 0.224, 0.213, 0.2094; continuum 0.2091); linear period of (1, 1) against the Verlet-corrected prediction, 7e-9; undriven energy error over 200 periods at 0.1, 1, 10, 100 E_nl: 4e-6, 1e-4, 6e-4, 9e-4 (bounded); controller on the default ramp (10⁻³ to 10² over 2000 periods) tracks to 1%, energy error 6e-4. Cost 4.3 µs per step (11 × 8); steps per driven period 1700 at 0.1 E_nl, 6000 at 10, 18000 at 100 (the plate stiffens: the driven mode's period falls to 0.1 of its linear value at 100 E_nl), so the page keeps up with 20 periods/s only to a few E_nl.
12-
13-First look (default settings): the other modes' energy rises steadily with the drive as the driven mode's own nonlinear response (∝ E³), then jumps by about two decades near 0.5 E_nl, after which they hold a third or more of the energy; the driven mode's pitch rises (1.4 × linear at 2 E_nl). Without the perturbation the route differs (42% left in the driven mode at 1.7 E_nl against 70% at 2 E_nl with it).
14-
15-Mode map (step 2, `history.ts`, `modemap.ts`): `drive.ts` samples every mode's linear energy (sine transforms of u and p) 16 times per driven period and averages them; the page closes a time bin every 0.5 periods (`History`), merging neighbours in pairs and doubling the bin length when there are 1200, so a run of any length fits. The energy chart now reads the same bins (smooth: the per-frame sampling of the swinging off-resonance energies made the other-modes curve a band). The map has one row per mode, ordered by pitch, labelled (m, n) and pitch relative to the driven mode, colour log energy (inferno-like) from 12 decades below the largest so far; new bins are painted incrementally, the whole image only on a merge or a change of the colour scale's top decade. Hover reads a mode and its energy.
16-
17-Seen (default settings, 2026-10-09; the map, and a bun script listing the leading modes every 100 periods of the default ramp): up to about 0.2 E_nl the other modes that hold energy are the (odd, odd) family of (1, 1), (3, 1) first, then (1, 3), (3, 3), (5, 1), (1, 5), (5, 3), their energies growing as about E³ (the driven mode's own nonlinear response, bound to it); the rest stay at the perturbation's level. Near 0.3 E_nl (1, 3) takes off and passes (3, 1) (6e-3 E_nl at 0.31, 0.2 at 0.56), then (1, 5) (0.1 at 1 E_nl). With the perturbation, modes of other symmetry ((2, 5), (4, 3), (4, 5)) lead by 1.8 E_nl; without it the leaders at 1.8 E_nl are still all (odd, odd) ((1, 5), (3, 7), (5, 3), (5, 7)), up to then identical to the perturbed run. On the map the whole band brightens within a few tens of periods around 1100 periods (about 0.5 E_nl) and stays filled to the grid's top mode (no sink: undamped).
18-
19-Loss (`drive.ts`, Plate and drive card; off by default, switchable during a run): each mode's momentum decays at γ_k = (2 / τ top)(ω_k / ω_top)^k, so its amplitude decays in τ top (ω_top / ω_k)^k (τ top in driven periods, default 10; k = 1, 2 (default) or 4). Applied exactly in the sine basis every 8 steps (two transforms, about 10% extra per step), kinetic energy removed summed in Wl. Not the dashpot loss of `../plate` (force −c L v): on a plate that gives k = 1, which also damps the driven mode noticeably (decay 40 τ top on the default plate, against 1700 τ top with k = 2), so the per-mode form, which can concentrate the loss at the top, was chosen. The readout shows drive and loss power (E_nl per driven period, smoothed over about 20 periods); the energy error is now relative to the larger of E and the energy that has flowed through (Estart + |Wd| + Wl), since with a loss the error grows with the work done.
20-
21-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).
22-
23-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 (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.
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.
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. At the computed end it fades out and waits, resuming (faded in) once 1.5 s of sound is computed ahead (first version stopped there; once the page started paused, a Play soon after Run caught up within a second and stopped, which looked broken). 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.
32-
33-Views (2026-10-09; Jeremy: the flashing height map was annoying; something steadier, such as modes; start paused). The plate card has a view selector: modes (default, `modegrid.ts`): one cell per (m, n), m across and n up, colour log energy of the mode (the history's bin means smoothed over about 5 periods), 6 decades below a top that rises at once and falls slowly; the driven mode outlined; hover reads a cell. A 10-decade range made the spread-out state at high energy one flat colour; 6 shows which modes lead (at 200 E_nl: (1, 1), (1, 3), the m = 1 column). Vibration: RMS displacement over about 4 periods at each point (`drive.ts` keeps a running mean square per node, updated with each signal sample), white to dark red, the listening point marked (click to move). Plate: the live height map as before. The page starts paused (Run).
34-
35-Playback at the computed end (2026-10-09, Jeremy: playback broken after the views change). Cause: the page now starts paused, so there was no backlog, and playback (60 periods/s at 60 Hz) caught up with the run (20 periods/s default) within a second and stopped. Now it waits at the end and resumes when 1.5 s of sound is ready (status says when it waits, or that the run is paused), and the default Speed is 60 periods/s, matching the default pitch: checked in the browser, Run then Play after 3 s plays without a break through 0.5 E_nl (about 100 periods ahead throughout); higher up the run slows and playback comes in stretches.
src/driven-plate/drive.tsdeleted+0−340View file
@@ -1,340 +0,0 @@
1-// The driven plate: the plate of plate.ts pushed by a force in the shape of
2-// one mode (the driven mode), with a controller that steers the total energy
3-// toward a target, and no damping.
4-// Modal force: with the driven mode's unit shape S (sum of squares 1) and
5-// the node mass M, the modal coordinate is q = sqrt(M) S.u and its velocity
6-// qd = S.p / sqrt(M); a generalized force Q on q is the node force Q sqrt(M)
7-// S. In the linear plate it moves the driven mode and nothing else, so any
8-// other mode that moves got its energy through the stretching.
9-// Controller: Q = G qd, a push in phase with the mode's own velocity (a
10-// negative damping), so it keeps adding energy whatever the mode's
11-// frequency, which rises as the plate stiffens with amplitude. Over a cycle
12-// the mean of qd^2 is the driven mode's energy Ed = (qd^2 + omega^2 q^2) / 2
13-// (linear), so G = (Et - E) / (TAU_C Ed) brings E to the target Et at rate
14-// 1 / TAU_C; G is capped at G_MAX omega (and Ed floored) so the push stays
15-// gentle when the driven mode holds little of the energy. G is set every
16-// control interval from E; Q is held fixed over each step, and its work Q dq
17-// is added to Wd, so E - Wd stays constant up to the integrator's error.
18-// Loss (optional, off by default): each mode's momentum decays at its own
19-// rate, gamma_k = (2 / tauTop) (omega_k / omega_top)^power, so the mode's
20-// amplitude decays at gamma_k / 2 (half its time is kinetic): decay time
21-// tauTop for the top mode, longer below. Applied exactly in the sine
22-// basis (p -> S diag(exp(-gamma_k dt)) S p) every LOSS_EVERY steps, as a
23-// sink at the top of the band. power = 1 is what the dashpot loss of
24-// ../plate gives (rate proportional to omega for a plate); 2 and 4 leave the
25-// low modes nearly untouched. The kinetic energy removed goes into Wl.
26-// Time: velocity Verlet at the step of plate.ts, recomputed from the energy.
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.
32-// Mode energies for the displays are averaged over time (sampled every
33-// 1 / SAMPLES_PER_PERIOD of a driven period): a mode's linear energy swings
34-// within each cycle when it is driven off resonance by the others.
35-
36-import { Recording } from "./recording";
37-import { Modes, Plate, gridFor, modeShape, nonlinearScale, omegaOf, stepFor, type Grid, type Params } from "./plate";
38-
39-const TAU_C = 2; // controller time constant, in driven periods
40-const G_MAX = 0.2; // cap on G, times the driven mode's omega
41-const PERTURB = 1e-6; // energy of the perturbation, relative to the starting energy
42-const SAMPLES_PER_PERIOD = 16;
43-const LOSS_EVERY = 8;
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;
48-const RMS_PERIODS = 4;
49-const RMS_A = 1 / (RMS_PERIODS * SIGNAL_RATE);
50-
51-export interface Loss {
52- tauTop: number; // amplitude decay time of the top mode, in driven periods; 0 = off
53- power: number; // gamma ~ omega^power
54-}
55-
56-export interface DriveOptions {
57- m: number; // driven mode (m, n)
58- n: number;
59- E0: number; // starting energy, in units of the nonlinear scale
60- perturb: boolean; // add a tiny random motion in every other mode
61-}
62-
63-export class DrivenPlate {
64- P: Params;
65- g: Grid;
66- plate: Plate;
67- modes: Modes;
68- opts: DriveOptions;
69- omega: number; // the driven mode's (linear) omega
70- period: number; // its period
71- Enl: number; // its nonlinear scale
72- u: Float64Array;
73- p: Float64Array;
74- t = 0; // time
75- E = 0; // energy now (kinetic + potential)
76- Et: number; // target energy (absolute units)
77- Wd = 0; // work done by the drive
78- Wl = 0; // energy removed by the loss
79- Estart: number; // energy at the start, for the energy error
80- G = 0; // controller gain
81- driveOn = true; // false: no drive (G = 0)
82- q = 0; // driven mode's coordinate and velocity
83- qd = 0;
84- private S: Float64Array; // driven mode's unit shape
85- private F: Float64Array;
86- private V = 0; // potential energy at u
87- private h = 0;
88- private sqM: number;
89- // Measured period of the driven mode: upward zero crossings of q.
90- private lastCross = NaN;
91- measuredPeriod = NaN;
92- // Sum of the mode energies over the samples since the last takeMean.
93- private acc: Float64Array;
94- private sample: Float64Array;
95- private accN = 0;
96- private nextSample = 0;
97-
98- constructor(P: Params, opts: DriveOptions) {
99- this.P = P;
100- this.g = gridFor(P);
101- this.plate = new Plate(this.g);
102- this.modes = new Modes(this.g);
103- this.opts = opts;
104- const { m, n } = opts;
105- this.omega = omegaOf(this.g, m, n);
106- this.period = (2 * Math.PI) / this.omega;
107- this.Enl = nonlinearScale(this.plate, m, n);
108- this.S = modeShape(this.g, m, n);
109- this.sqM = Math.sqrt(this.g.mass);
110- this.u = new Float64Array(this.g.n);
111- this.p = new Float64Array(this.g.n);
112- this.F = new Float64Array(this.g.n);
113- this.ms = new Float64Array(this.g.n);
114- this.acc = new Float64Array(this.g.n);
115- this.sample = new Float64Array(this.g.n);
116- // Start in the driven mode at E0 (at rest, displaced), plus the perturbation.
117- const E0 = opts.E0 * this.Enl, q0 = Math.sqrt(2 * E0) / this.omega;
118- for (let k = 0; k < this.g.n; k++) this.u[k] = (q0 * this.S[k]) / this.sqM;
119- if (opts.perturb) this.addPerturbation(PERTURB * E0);
120- this.Et = E0;
121- this.V = this.plate.forces(this.u, this.F);
122- this.E = this.kinetic() + this.V;
123- this.Estart = this.E;
124- this.readMode();
125- this.setStep();
126- this.setListen(this.listen.fx, this.listen.fy);
127- for (let k = 0; k < this.g.n; k++) this.ms[k] = (this.u[k] * this.u[k]) / 2; // as for a cosine
128- }
129-
130- // Random phases, equal energy e / (count - 1) in every mode but the driven
131- // one (linear energies; tiny, so the stretching is negligible). A fixed
132- // seed, so runs repeat.
133- private addPerturbation(e: number): void {
134- const g = this.g, M = this.modes, each = e / (M.count - 1);
135- let seed = 12345;
136- const rand = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 2 ** 32);
137- for (let k = 0; k < M.count; k++) {
138- if (M.m[k] === this.opts.m && M.n[k] === this.opts.n) continue;
139- const S = modeShape(g, M.m[k], M.n[k]), w = M.omega[k], a = Math.sqrt(2 * each), ph = 2 * Math.PI * rand();
140- const q = (a / w) * Math.cos(ph), qd = a * Math.sin(ph);
141- for (let i = 0; i < g.n; i++) {
142- this.u[i] += (q * S[i]) / this.sqM;
143- this.p[i] += qd * S[i] * this.sqM;
144- }
145- }
146- }
147-
148- kinetic(): number {
149- let T = 0;
150- for (let k = 0; k < this.g.n; k++) T += this.p[k] * this.p[k];
151- return T / (2 * this.g.mass);
152- }
153- // Driven mode's linear energy.
154- get Ed(): number {
155- return (this.qd * this.qd + this.omega * this.omega * this.q * this.q) / 2;
156- }
157- private readMode(): void {
158- let a = 0, b = 0;
159- for (let k = 0; k < this.g.n; k++) {
160- a += this.S[k] * this.u[k];
161- b += this.S[k] * this.p[k];
162- }
163- this.q = a * this.sqM;
164- this.qd = b / this.sqM;
165- }
166- private setStep(): void {
167- this.h = stepFor(this.g, this.modes, Math.max(this.E, this.Et));
168- }
169- // Controller gain from the energy now and the target.
170- private setGain(): void {
171- const Ed = Math.max(this.Ed, 1e-3 * this.E, 1e-300);
172- const G = (this.Et - this.E) / (TAU_C * this.period * Ed), cap = G_MAX * this.omega;
173- this.G = this.driveOn ? Math.max(-cap, Math.min(cap, G)) : 0;
174- }
175-
176- // Advance by about dt (whole steps); returns the steps taken.
177- advance(dt: number): number {
178- this.setStep();
179- const steps = Math.max(1, Math.round(dt / this.h)), h = dt / steps;
180- const { u, p, F, S } = this, n = this.g.n, M = this.g.mass, sqM = this.sqM;
181- // Gain update every control interval (a few per driven period).
182- const every = Math.max(1, Math.round(this.period / 32 / h));
183- let pending = 0; // steps since the loss was last applied
184- for (let s = 0; s < steps; s++) {
185- if (s % every === 0) {
186- this.E = this.kinetic() + this.V;
187- this.setGain();
188- }
189- const Q = this.G * this.qd, f = Q * sqM, qBefore = this.q;
190- for (let k = 0; k < n; k++) p[k] += 0.5 * h * (F[k] + f * S[k]);
191- for (let k = 0; k < n; k++) u[k] += (h * p[k]) / M;
192- this.V = this.plate.forces(u, F);
193- for (let k = 0; k < n; k++) p[k] += 0.5 * h * (F[k] + f * S[k]);
194- this.readMode();
195- this.Wd += Q * (this.q - qBefore);
196- if (this.gamma && ++pending === LOSS_EVERY) {
197- this.applyLoss(pending * h);
198- pending = 0;
199- }
200- if (qBefore < 0 && this.q >= 0) {
201- const tc = this.t + (h * -qBefore) / (this.q - qBefore);
202- if (Number.isFinite(this.lastCross)) this.measuredPeriod = tc - this.lastCross;
203- this.lastCross = tc;
204- }
205- this.t += h;
206- // Signal: velocity p / M; acceleration (F + drive) / M.
207- this.sumU += this.at(u);
208- this.sumV += this.at(p) / M;
209- this.sumA += (this.at(F) + f * this.at(S)) / M;
210- this.sigN++;
211- if (this.t >= this.nextSig) {
212- const c = 1 / this.sigN;
213- this.rec.push(this.sumU * c, this.sumV * c, this.sumA * c);
214- const ms = this.ms;
215- for (let q = 0; q < n; q++) ms[q] += RMS_A * (u[q] * u[q] - ms[q]);
216- this.sumU = this.sumV = this.sumA = this.sigN = 0;
217- this.nextSig += this.period / SIGNAL_RATE;
218- }
219- if (this.t >= this.nextSample) {
220- this.modalEnergies(this.sample);
221- for (let k = 0; k < n; k++) this.acc[k] += this.sample[k];
222- this.accN++;
223- this.nextSample = this.t + this.period / SAMPLES_PER_PERIOD;
224- }
225- }
226- if (this.gamma && pending > 0) this.applyLoss(pending * h);
227- this.E = this.kinetic() + this.V;
228- return steps;
229- }
230-
231- // The loss: decay rates per mode (grid index), null when off.
232- private gamma: Float64Array | null = null;
233- loss: Loss = { tauTop: 0, power: 2 };
234- setLoss(loss: Loss): void {
235- this.loss = loss;
236- if (!(loss.tauTop > 0)) {
237- this.gamma = null;
238- return;
239- }
240- const g = this.g, top = this.modes.omegaMax, rate = 2 / (loss.tauTop * this.period);
241- this.gamma = new Float64Array(g.n);
242- for (let b = 0; b < g.Ny; b++) for (let a = 0; a < g.Nx; a++) this.gamma[a + g.Nx * b] = rate * (omegaOf(g, a + 1, b + 1) / top) ** loss.power;
243- }
244- // Amplitude decay time of mode (m, n) from the loss, in driven periods
245- // (Infinity when off).
246- decayTime(m: number, n: number): number {
247- if (!(this.loss.tauTop > 0)) return Infinity;
248- return this.loss.tauTop * (this.modes.omegaMax / omegaOf(this.g, m, n)) ** this.loss.power;
249- }
250- // Listening point, as a fraction of each side; the four nodes around it and
251- // their weights.
252- listen = { fx: 0.37, fy: 0.29 };
253- private lk = new Int32Array(4);
254- private lw = new Float64Array(4);
255- setListen(fx: number, fy: number): void {
256- const g = this.g;
257- this.listen = { fx, fy };
258- 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;
259- 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]];
260- corners.forEach(([i, j, w], c) => {
261- const inside = i >= 0 && j >= 0 && i < g.Nx && j < g.Ny;
262- this.lk[c] = inside ? i + g.Nx * j : 0;
263- this.lw[c] = inside ? w : 0; // supports: 0
264- });
265- }
266- private at(x: Float64Array): number {
267- const k = this.lk, w = this.lw;
268- return w[0] * x[k[0]] + w[1] * x[k[1]] + w[2] * x[k[2]] + w[3] * x[k[3]];
269- }
270- rec = new Recording();
271- // Mean square displacement of each node, a running mean over about
272- // RMS_PERIODS driven periods (updated with each signal sample), for the
273- // vibration view.
274- ms: Float64Array = new Float64Array(0);
275- private sumU = 0;
276- private sumV = 0;
277- private sumA = 0;
278- private sigN = 0;
279- private nextSig = 0;
280-
281- private lossHat: Float64Array | null = null;
282- private applyLoss(dt: number): void {
283- const gam = this.gamma!, n = this.g.n, P = (this.lossHat ??= new Float64Array(n));
284- this.plate.sine2(this.p, P);
285- let dT = 0;
286- for (let k = 0; k < n; k++) {
287- const f = Math.exp(-gam[k] * dt), p1 = P[k] * f;
288- dT += P[k] * P[k] - p1 * p1;
289- P[k] = p1;
290- }
291- this.plate.sine2(P, this.p);
292- this.Wl += dT / (2 * this.g.mass);
293- this.readMode();
294- }
295-
296- // Relative energy error: E - Estart - Wd + Wl, relative to the energy
297- // handled (E, or with the loss on, the larger energy that has flowed
298- // through: the error grows with the work done, not with E).
299- get energyError(): number {
300- return (this.E - this.Estart - this.Wd + this.Wl) / Math.max(this.E, this.Estart + Math.abs(this.Wd) + this.Wl);
301- }
302- get step(): number {
303- return this.h;
304- }
305- // Linear energy of every mode, (qd^2 + omega^2 q^2) / 2, into out at index
306- // (m - 1) + Nx (n - 1), from the sine transforms of u and p.
307- private Uhat: Float64Array | null = null;
308- private Phat: Float64Array | null = null;
309- private omegaGrid: Float64Array | null = null;
310- modalEnergies(out: Float64Array): void {
311- const g = this.g, n = g.n;
312- const U = (this.Uhat ??= new Float64Array(n)), Pp = (this.Phat ??= new Float64Array(n));
313- if (!this.omegaGrid) {
314- this.omegaGrid = new Float64Array(n);
315- for (let b = 0; b < g.Ny; b++) for (let a = 0; a < g.Nx; a++) this.omegaGrid[a + g.Nx * b] = omegaOf(g, a + 1, b + 1);
316- }
317- this.plate.sine2(this.u, U);
318- this.plate.sine2(this.p, Pp);
319- const W = this.omegaGrid, M = g.mass;
320- for (let k = 0; k < n; k++) out[k] = (Pp[k] * Pp[k]) / (2 * M) + (W[k] * W[k] * M * U[k] * U[k]) / 2;
321- }
322- // Mean mode energies since the last call into out (as modalEnergies; the
323- // energies now if there was no sample), and start a new mean.
324- takeMean(out: Float64Array): void {
325- if (this.accN === 0) this.modalEnergies(out);
326- else for (let k = 0; k < this.g.n; k++) out[k] = this.acc[k] / this.accN;
327- this.acc.fill(0);
328- this.accN = 0;
329- }
330- // Index of the driven mode in modalEnergies' output.
331- get drivenIndex(): number {
332- return this.opts.m - 1 + this.g.Nx * (this.opts.n - 1);
333- }
334- // Largest |u| (in thicknesses).
335- maxAbsU(): number {
336- let a = 0;
337- for (let k = 0; k < this.g.n; k++) a = Math.max(a, Math.abs(this.u[k]));
338- return a;
339- }
340-}
src/driven-plate/history.tsdeleted+0−57View file
@@ -1,57 +0,0 @@
1-// History of a run, for the energy chart and the mode map: time bins, each
2-// with the time at its end (driven periods), the total and target energies
3-// then, and every mode's linear energy averaged over the bin (by grid index,
4-// (m - 1) + Nx (n - 1)), all in units of E_nl. Bins start BIN0 periods long;
5-// when there are MAX_BINS, neighbours are merged in pairs (energies
6-// averaged) and the bin length doubles, so a run of any length fits.
7-
8-import type { DrivenPlate } from "./drive";
9-
10-const BIN0 = 0.5;
11-export const MAX_BINS = 1200;
12-
13-export interface Bin {
14- t: number;
15- E: number;
16- Et: number;
17- modes: Float32Array;
18-}
19-
20-export class History {
21- bins: Bin[] = [];
22- span = BIN0; // bin length (periods)
23- merges = 0; // how many times the bins were merged (the map repaints)
24- private start = 0; // start of the open bin (periods)
25- private tmp: Float64Array;
26- constructor(sim: DrivenPlate) {
27- this.tmp = new Float64Array(sim.g.n);
28- this.push(sim);
29- }
30- // Close the open bin if it is long enough.
31- update(sim: DrivenPlate): void {
32- if (sim.t / sim.period - this.start >= this.span) this.push(sim);
33- }
34- private push(sim: DrivenPlate): void {
35- const t = sim.t / sim.period, s = 1 / sim.Enl;
36- sim.takeMean(this.tmp);
37- this.bins.push({ t, E: sim.E * s, Et: sim.Et * s, modes: Float32Array.from(this.tmp, (x) => x * s) });
38- this.start = t;
39- if (this.bins.length >= MAX_BINS) this.merge();
40- }
41- private merge(): void {
42- const out: Bin[] = [], b = this.bins;
43- // Pair from the end, so the newest bin stays whole if the count is odd.
44- let i = b.length % 2;
45- if (i) out.push(b[0]);
46- for (; i + 1 < b.length; i += 2) {
47- const m = b[i].modes.map((x, k) => (x + b[i + 1].modes[k]) / 2);
48- out.push({ ...b[i + 1], modes: m });
49- }
50- this.bins = out;
51- this.span *= 2;
52- this.merges++;
53- }
54- get last(): Bin {
55- return this.bins[this.bins.length - 1];
56- }
57-}
src/driven-plate/index.htmldeleted+0−154View file
@@ -1,154 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>Driven plate</title>
7- </head>
8- <body>
9- <main>
10- <header><h1>Driven plate</h1></header>
11- <div class="grid">
12- <section class="card">
13- <div class="row view-row">
14- <select id="view" title="What the card shows">
15- <option value="modes" selected>modes: energy per (m, n)</option>
16- <option value="rms">vibration: RMS displacement</option>
17- <option value="plate">plate: live height map</option>
18- </select>
19- </div>
20- <canvas id="plate"></canvas>
21- <div id="readout" class="num"></div>
22- <div id="controls">
23- <div class="row">
24- <button id="run" class="primary">Run</button>
25- <button id="reset">Reset</button>
26- <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>
27- <button type="button" class="help" data-help="drive" title="About the driven plate" aria-label="About the driven plate">?</button>
28- </div>
29- <div class="row">
30- <span class="muted label-w" title="Driven periods for each factor of ten in energy">Ramp</span>
31- <input id="ramp-rate" type="range" min="0.699" max="3.301" step="0.01" value="2.602" />
32- <span id="ramp-value" class="num value-w"></span>
33- </div>
34- <div class="row">
35- <span class="muted label-w">Target E / E<sub>nl</sub></span>
36- <input id="target" type="range" min="-4" max="3" step="0.01" value="-3" />
37- <span id="target-value" class="num value-w"></span>
38- </div>
39- <div class="row">
40- <span class="muted label-w">Speed</span>
41- <input id="speed" type="range" min="0" max="2.5" step="0.01" value="1.778" />
42- <span id="speed-value" class="num value-w"></span>
43- </div>
44- </div>
45- </section>
46- <div class="column">
47- <section class="card">
48- <div class="card-head">Plate and drive <button type="button" class="help" data-help="params" title="About the parameters" aria-label="About the parameters">?</button></div>
49- <div class="row">
50- <span class="muted">Masses across</span>
51- <input id="ny" type="number" min="2" max="32" step="1" value="8" />
52- <span class="muted" style="margin-left: 8px">Aspect</span>
53- <input id="aspect" type="number" min="1" max="3" step="0.05" value="1.3" />
54- <span id="grid-info" class="muted"></span>
55- </div>
56- <div class="row">
57- <span class="muted">Driven mode</span>
58- <select id="mode"></select>
59- <label class="chip" title="Start with a tiny random motion (a millionth of the starting energy) in every other mode; off, the plate starts in the driven mode exactly"><input id="perturb" type="checkbox" checked /><span>Perturbation</span></label>
60- </div>
61- <div class="row">
62- <label class="chip" title="A loss that drains the highest modes, as a sink for the energy that reaches the top of the band"><input id="loss" type="checkbox" /><span>Loss</span></label>
63- <span class="muted">τ top</span>
64- <input id="loss-tau" type="number" min="0.1" step="any" value="10" />
65- <span class="muted">periods, rate ∝</span>
66- <select id="loss-power">
67- <option value="1">ω</option>
68- <option value="2" selected>ω²</option>
69- <option value="4">ω⁴</option>
70- </select>
71- </div>
72- <div id="params"></div>
73- </section>
74- <section class="card">
75- <div class="card-head">Energy <button type="button" class="help" data-help="energy" title="About the energy chart" aria-label="About the energy chart">?</button></div>
76- <canvas id="chart"></canvas>
77- </section>
78- </div>
79- </div>
80- <section class="card wide">
81- <div class="card-head">Listen <button type="button" class="help" data-help="listen" title="About listening" aria-label="About listening">?</button></div>
82- <div class="row">
83- <button id="play" class="primary">Play</button>
84- <button id="mute" type="button" class="mute"></button>
85- <select id="signal" title="What is heard (and shown in the spectrogram), at the listening point">
86- <option value="v" selected>velocity</option>
87- <option value="u">displacement</option>
88- <option value="a">acceleration</option>
89- </select>
90- <select id="level" title="auto: steady level as the energy rises; as computed: one fixed scale">
91- <option value="auto" selected>steady level</option>
92- <option value="fixed">as computed</option>
93- </select>
94- <span class="muted">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" style="max-width: 110px" />
95- </div>
96- <div class="row">
97- <span class="muted label-w" title="The pitch at which the driven mode sounds">Driven mode at</span>
98- <input id="pitch" type="range" min="1.301" max="2.204" step="0.001" value="1.778" />
99- <span id="pitch-value" class="num value-w"></span>
100- </div>
101- <div class="row"><span id="listen-status" class="muted"></span></div>
102- </section>
103- <section class="card wide">
104- <div class="card-head">Spectrogram
105- <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>
106- <button type="button" class="help" data-help="spec" title="About the spectrogram" aria-label="About the spectrogram">?</button></div>
107- <canvas id="spec"></canvas>
108- </section>
109- <section class="card wide">
110- <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>
111- <canvas id="modemap"></canvas>
112- </section>
113- </main>
114- <dialog id="help">
115- <div id="help-body"></div>
116- <div id="help-actions"><button id="help-close" type="button">Close</button></div>
117- </dialog>
118-
119- <template id="help-drive">
120- <h2>The driven plate</h2>
121- <p>A thin rectangular plate, simply supported at its edges. The card shows one of three views. Modes (the default) has one cell per normal mode, m half waves along x and n along y, coloured by the mode's energy (log scale, smoothed over a few periods), the driven mode outlined; hover to read a cell. Vibration shows how much each point of the plate moves, the RMS displacement over the last few periods, white to dark red. Plate shows the plate from above as it moves, red up, blue down, white at rest; it changes many times a second and flickers. In the last two the circle is the listening point for the spectrogram and the sound; click to move it. The plate starts paused: press Run. 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>
122- <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>
123- <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>
124- </template>
125- <template id="help-params">
126- <h2>Plate and drive</h2>
127- <p>Masses across is the number of masses across the short side; the long side gets as many as keep the cells nearly square. Aspect is the ratio of the sides. A square plate has pairs of modes at the same pitch ((1, 2) and (2, 1)), which trade energy easily; a non-square plate splits them. The default 1.3 keeps the lowest modes at least 1% apart in pitch.</p>
128- <p>The modes are products of sines, numbered (m, n) by their half waves along x and y. The list gives each one's pitch relative to the lowest. Perturbation starts the plate with a tiny random motion in every other mode, a millionth of the starting energy in all; without it the plate starts in the driven mode exactly, and by symmetry only modes with the driven mode's symmetry can then grow (for (1, 1): odd m and odd n), at first; round-off seeds the rest. Any change here starts again.</p>
129- <p>Loss drains energy from the modes, fastest at the top of the band, as a sink for the energy the cascade brings there (without it the energy has nowhere to go and spreads over every mode up to the grid's highest). Each mode's amplitude decays on its own, the top mode in τ top driven periods and a mode of pitch ω in τ top (ω<sub>top</sub> / ω)<sup>k</sup>, k from the rate's power of ω: with ω (k = 1, the dashpot loss of the other apps) the driven mode decays in about 40 τ top on the default plate, with ω² in about 1700 τ top, with ω⁴ practically never. The drive makes up what the loss takes. The loss can be switched and changed during a run.</p>
130- <p>Units are scaled away: for the von Kármán plate only the aspect ratio and Poisson's ratio (0.3) matter once energy is given against E<sub>nl</sub>, displacement against the thickness h and time in periods.</p>
131- </template>
132- <template id="help-energy">
133- <h2>Energy</h2>
134- <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>
135- </template>
136- <template id="help-listen">
137- <h2>Listen</h2>
138- <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 waits there and goes on (with a short fade) once about 1.5 s of sound is ready, so it is heard in stretches; to hear it unbroken, let the run compute ahead first (or raise Speed) and then play.</p>
139- <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>
140- <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>
141- </template>
142- <template id="help-spec">
143- <h2>Spectrogram</h2>
144- <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>
145- <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>
146- </template>
147- <template id="help-modes">
148- <h2>Modes</h2>
149- <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>
150- <p>Hover over the map to read a mode and its energy at that time. The time steps start at half a period and double whenever the map fills, so a long run still fits.</p>
151- </template>
152- <script type="module" src="./main.ts"></script>
153- </body>
154-</html>
src/driven-plate/livespec.tsdeleted+0−261View file
@@ -1,261 +0,0 @@
1-// Spectrogram of the signal at the listening point (the one chosen for
2-// listening: velocity, displacement or acceleration) over the whole run, time
3-// across as in the energy chart and the mode map. Samples come at
4-// SIGNAL_RATE per driven period; every HOP periods a Hann window of WINDOW
5-// periods is transformed (zero padded to twice its length), and the power is
6-// kept in ROWS bands spaced evenly in log frequency, from F_LO times the
7-// driven mode's linear pitch up to the Nyquist frequency (each band keeps the
8-// loudest bin in it, so narrow lines are not lost). Frequencies are in units
9-// of the driven mode's linear pitch. As the bins of the history, columns are
10-// merged in pairs when there are MAX_COLS. Colour: dB, RANGE_DB deep, below
11-// each column's own peak (Per time, the default: the spectrum's shape at
12-// each moment, whatever the level) or below the peak of the whole run.
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.
17-
18-import { fit } from "../common/canvas";
19-import { FFT, colormap } from "../common/spectrogram";
20-import { SIGNAL_RATE, type DrivenPlate } from "./drive";
21-import type { Recording, SignalKind } from "./recording";
22-import { drawMarks, type Marks } from "./marks";
23-
24-const WINDOW = 32; // periods
25-const HOP = 4; // periods
26-const N = WINDOW * SIGNAL_RATE; // samples per window (a power of two)
27-const NFFT = 2 * N;
28-const ROWS = 240;
29-const F_LO = 0.25;
30-const MAX_COLS = 1000;
31-const RANGE_DB = 80;
32-const L = 52, R = 14, T = 44, B = 22; // margins (px)
33-
34-export class LiveSpectrogram {
35- private canvas: HTMLCanvasElement;
36- private img: HTMLCanvasElement;
37- private ictx: CanvasRenderingContext2D;
38- private data: ImageData;
39- private fft = new FFT(NFFT);
40- private re = new Float64Array(NFFT);
41- private im = new Float64Array(NFFT);
42- private hann = new Float64Array(N);
43- private buf = new Float64Array(N);
44- private n = 0; // samples used so far
45- private kind: SignalKind = "v";
46- private rowLo = new Int32Array(ROWS); // FFT bins in each band
47- private rowHi = new Int32Array(ROWS);
48- private fHi = SIGNAL_RATE / 2; // top of the axis (Nyquist), in driven pitches
49- private cols: Float32Array[] = [];
50- private times: number[] = []; // column centres (periods)
51- private colPeak: number[] = [];
52- private merge = 1; // raw columns per stored column
53- private pending: Float32Array | null = null;
54- private pendingN = 0;
55- private pendingT = 0;
56- private painted = 0;
57- private paintKey = "";
58- private sim: DrivenPlate | null = null;
59- private hover: { x: number; y: number } | null = null;
60- perTime = true;
61-
62- constructor(canvas: HTMLCanvasElement) {
63- this.canvas = canvas;
64- this.img = document.createElement("canvas");
65- this.img.width = MAX_COLS;
66- this.img.height = ROWS;
67- this.ictx = this.img.getContext("2d")!;
68- this.data = this.ictx.createImageData(MAX_COLS, ROWS);
69- for (let i = 0; i < N; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1));
70- // Bands: row r (0 at the bottom) spans [f(r), f(r + 1)) with f log spaced.
71- const df = SIGNAL_RATE / NFFT; // bin spacing, in driven pitches
72- const f = (r: number) => F_LO * (this.fHi / F_LO) ** (r / ROWS);
73- for (let r = 0; r < ROWS; r++) {
74- let lo = Math.ceil(f(r) / df), hi = Math.ceil(f(r + 1) / df) - 1;
75- if (hi < lo) lo = hi = Math.round(Math.sqrt(f(r) * f(r + 1)) / df);
76- this.rowLo[r] = Math.min(NFFT / 2, lo);
77- this.rowHi[r] = Math.min(NFFT / 2, hi);
78- }
79- canvas.addEventListener("mousemove", (e) => {
80- const b = canvas.getBoundingClientRect();
81- this.hover = { x: e.clientX - b.left, y: e.clientY - b.top };
82- });
83- 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);
97- }
98-
99- reset(sim: DrivenPlate): void {
100- this.sim = sim;
101- this.n = 0;
102- this.buf.fill(0);
103- this.cols = [];
104- this.times = [];
105- this.colPeak = [];
106- this.merge = 1;
107- this.pending = null;
108- this.pendingN = 0;
109- this.painted = 0;
110- this.paintKey = "";
111- }
112-
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 {
116- const hopN = HOP * SIGNAL_RATE;
117- for (; this.n < rec.length; ) {
118- this.buf[this.n % N] = rec.get(this.kind, this.n);
119- this.n++;
120- if (this.n >= N && (this.n - N) % hopN === 0) this.column((this.n - N / 2) / SIGNAL_RATE);
121- }
122- }
123-
124- private column(t: number): void {
125- const { re, im, buf, hann } = this, start = this.n % N;
126- let mean = 0;
127- for (let i = 0; i < N; i++) mean += buf[i];
128- mean /= N;
129- re.fill(0);
130- im.fill(0);
131- for (let i = 0; i < N; i++) re[i] = (buf[(start + i) % N] - mean) * hann[i];
132- this.fft.transform(re, im);
133- const col = (this.pending ??= new Float32Array(ROWS));
134- for (let r = 0; r < ROWS; r++) {
135- let p = 0;
136- for (let k = this.rowLo[r]; k <= this.rowHi[r]; k++) p = Math.max(p, re[k] * re[k] + im[k] * im[k]);
137- col[r] += p / this.merge;
138- }
139- this.pendingT += t / this.merge;
140- if (++this.pendingN < this.merge) return;
141- this.cols.push(col);
142- this.times.push(this.pendingT);
143- this.colPeak.push(col.reduce((a, b) => Math.max(a, b), 0));
144- this.pending = null;
145- this.pendingN = 0;
146- this.pendingT = 0;
147- if (this.cols.length >= MAX_COLS) {
148- const c: Float32Array[] = [], ts: number[] = [], pk: number[] = [];
149- for (let i = 0; i + 1 < this.cols.length; i += 2) {
150- const m = this.cols[i].map((x, r) => (x + this.cols[i + 1][r]) / 2);
151- c.push(m);
152- ts.push((this.times[i] + this.times[i + 1]) / 2);
153- pk.push(m.reduce((a, b) => Math.max(a, b), 0));
154- }
155- this.cols = c;
156- this.times = ts;
157- this.colPeak = pk;
158- this.merge *= 2;
159- this.painted = 0;
160- }
161- }
162-
163- draw(tNow: number, marks: Marks): void {
164- const sim = this.sim!, n = this.cols.length;
165- const peakAll = this.colPeak.reduce((a, b) => Math.max(a, b), 0);
166- // Repaint everything when the colouring changes: mode, or (absolute) a
167- // louder peak by 3 dB.
168- const key = this.perTime ? "t" : `a${Math.floor(10 * Math.log10(peakAll + 1e-300) / 3)}`;
169- if (key !== this.paintKey) {
170- this.paintKey = key;
171- this.painted = 0;
172- }
173- const d = this.data.data;
174- for (let c = this.painted; c < n; c++) {
175- const col = this.cols[c], ref = 10 * Math.log10((this.perTime ? this.colPeak[c] : peakAll) + 1e-300);
176- for (let r = 0; r < ROWS; r++) {
177- const db = 10 * Math.log10(col[r] + 1e-300);
178- colormap((db - (ref - RANGE_DB)) / RANGE_DB, d, 4 * (c + MAX_COLS * (ROWS - 1 - r)));
179- d[4 * (c + MAX_COLS * (ROWS - 1 - r)) + 3] = 255;
180- }
181- }
182- if (this.painted < n) this.ictx.putImageData(this.data, 0, 0, this.painted, 0, n - this.painted, ROWS);
183- this.painted = n;
184-
185- const ctx = fit(this.canvas), W = this.canvas.clientWidth, H = this.canvas.clientHeight;
186- ctx.clearRect(0, 0, W, H);
187- const pw = W - L - R, ph = H - T - B, tMax = Math.max(tNow, 1);
188- this.tMax = tMax;
189- const X = (t: number) => L + (t / tMax) * pw;
190- const Y = (f: number) => T + ph * (1 - Math.log(f / F_LO) / Math.log(this.fHi / F_LO));
191- ctx.fillStyle = "#f2f2ef";
192- ctx.fillRect(L, T, pw, ph);
193- if (n > 0) {
194- // Columns are evenly spaced in time; the image spans half a hop
195- // beyond the first and last centres.
196- const dtc = n > 1 ? (this.times[n - 1] - this.times[0]) / (n - 1) : HOP;
197- const x0 = X(this.times[0] - dtc / 2), x1 = X(this.times[n - 1] + dtc / 2);
198- ctx.imageSmoothingEnabled = false;
199- ctx.drawImage(this.img, 0, 0, n, ROWS, x0, T, x1 - x0, ph);
200- }
201- ctx.strokeStyle = "#e3e2dc";
202- ctx.strokeRect(L - 0.5, T - 0.5, pw + 1, ph + 1);
203- drawMarks(ctx, marks, X, T, ph, "#ffffff");
204- // Frequency axis: 1, 2, 5 steps, in driven pitches.
205- ctx.font = "11px system-ui, sans-serif";
206- ctx.fillStyle = "#7a7d82";
207- ctx.textAlign = "right";
208- for (let p = -1; p <= 3; p++) {
209- for (const m of [1, 2, 5]) {
210- const f = m * 10 ** p;
211- if (f < F_LO || f > this.fHi) continue;
212- ctx.fillText(`${+f.toPrecision(1)}`, L - 6, Y(f) + 4);
213- ctx.fillRect(L - 4, Y(f), 3, 1);
214- }
215- }
216- ctx.save();
217- ctx.translate(12, T + ph / 2);
218- ctx.rotate(-Math.PI / 2);
219- ctx.textAlign = "center";
220- ctx.fillText("× driven pitch", 0, 0);
221- ctx.restore();
222- // Modes' linear pitches as ticks on the right; the driven one in accent.
223- const M = sim.modes;
224- for (let k = 0; k < M.count; k++) {
225- const f = M.omega[k] / sim.omega, driven = M.m[k] === sim.opts.m && M.n[k] === sim.opts.n;
226- ctx.fillStyle = driven ? "#2f5d8a" : "#9a9c9f";
227- ctx.fillRect(L + pw + 2, Y(f), driven ? 10 : 6, 1);
228- }
229- ctx.textAlign = "center";
230- ctx.fillStyle = "#7a7d82";
231- ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)} · window ${WINDOW}`, L + pw / 2, H - 6);
232- // Colour bar.
233- const cbW = Math.min(160, pw / 3), cbX = L + pw - cbW - 70, cbY = 24, tmp = new Uint8ClampedArray(4);
234- for (let i = 0; i < cbW; i++) {
235- colormap(i / (cbW - 1), tmp, 0);
236- ctx.fillStyle = `rgb(${tmp[0]}, ${tmp[1]}, ${tmp[2]})`;
237- ctx.fillRect(cbX + i, cbY, 1.5, 10);
238- }
239- ctx.fillStyle = "#7a7d82";
240- ctx.textAlign = "right";
241- ctx.fillText(`−${RANGE_DB} dB`, cbX - 4, cbY + 9);
242- ctx.textAlign = "left";
243- ctx.fillText(this.perTime ? "0 dB (each time)" : "0 dB (whole run)", cbX + cbW + 4, cbY + 9);
244- // Hover: frequency, time, level, and the nearest mode by linear pitch.
245- const h = this.hover;
246- ctx.fillStyle = "#23262b";
247- if (h && h.x >= L && h.x < L + pw && h.y >= T && h.y < T + ph && n > 0) {
248- const t = ((h.x - L) / pw) * tMax, f = F_LO * (this.fHi / F_LO) ** ((T + ph - h.y) / ph);
249- let c = 0;
250- for (let i = 1; i < n; i++) if (Math.abs(this.times[i] - t) < Math.abs(this.times[c] - t)) c = i;
251- const r = Math.min(ROWS - 1, Math.max(0, Math.floor(((T + ph - h.y) / ph) * ROWS)));
252- const ref = this.perTime ? this.colPeak[c] : peakAll, db = 10 * Math.log10((this.cols[c][r] + 1e-300) / (ref + 1e-300));
253- let k = 0;
254- 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;
255- 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);
256- } else {
257- ctx.fillStyle = "#7a7d82";
258- ctx.fillText(`${{ v: "velocity", u: "displacement", a: "acceleration" }[this.kind]} at the listening point ○ · ticks: modes' pitches · click: play from`, L, 13);
259- }
260- }
261-}
src/driven-plate/main.tsdeleted+0−445View file
@@ -1,445 +0,0 @@
1-import "../common/style.css";
2-import "./style.css";
3-import { fit } from "../common/canvas";
4-import { setupHelp } from "../common/help";
5-import { setupMute } from "../common/mute";
6-import { DrivenPlate } from "./drive";
7-import { History } from "./history";
8-import { LiveSpectrogram } from "./livespec";
9-import { ModeMap } from "./modemap";
10-import { ModeGrid } from "./modegrid";
11-import { drawMarks, type Marks } from "./marks";
12-import { LivePlayer, type Level } from "./player";
13-import type { SignalKind } from "./recording";
14-import { Modes, gridFor, type Params } from "./plate";
15-
16-setupHelp();
17-
18-const LOG_START = -3; // log10 of the starting energy, in E_nl
19-const BUDGET_MS = 14; // compute per frame at most
20-
21-const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
22-const plateCanvas = $<HTMLCanvasElement>("plate");
23-const chartCanvas = $<HTMLCanvasElement>("chart");
24-const modeMap = new ModeMap($<HTMLCanvasElement>("modemap"));
25-const spec = new LiveSpectrogram($<HTMLCanvasElement>("spec"));
26-const perTimeBox = $<HTMLInputElement>("spec-per-time");
27-const readout = $("readout");
28-const viewSelect = $<HTMLSelectElement>("view");
29-const modeGrid = new ModeGrid();
30-let plateHover: { x: number; y: number } | null = null;
31-plateCanvas.addEventListener("mousemove", (e) => {
32- const r = plateCanvas.getBoundingClientRect();
33- plateHover = { x: e.clientX - r.left, y: e.clientY - r.top };
34-});
35-plateCanvas.addEventListener("mouseleave", () => (plateHover = null));
36-const runButton = $<HTMLButtonElement>("run");
37-const rampBox = $<HTMLInputElement>("ramp");
38-const rampRate = $<HTMLInputElement>("ramp-rate");
39-const rampValue = $("ramp-value");
40-const targetSlider = $<HTMLInputElement>("target");
41-const targetValue = $("target-value");
42-const speedSlider = $<HTMLInputElement>("speed");
43-const speedValue = $("speed-value");
44-const nyInput = $<HTMLInputElement>("ny");
45-const aspectInput = $<HTMLInputElement>("aspect");
46-const gridInfo = $("grid-info");
47-const modeSelect = $<HTMLSelectElement>("mode");
48-const perturbBox = $<HTMLInputElement>("perturb");
49-const paramsDiv = $("params");
50-const lossBox = $<HTMLInputElement>("loss");
51-const lossTauInput = $<HTMLInputElement>("loss-tau");
52-const lossPowerSelect = $<HTMLSelectElement>("loss-power");
53-const playButton = $<HTMLButtonElement>("play");
54-const signalSelect = $<HTMLSelectElement>("signal");
55-const pitchSlider = $<HTMLInputElement>("pitch");
56-const pitchValue = $("pitch-value");
57-const levelSelect = $<HTMLSelectElement>("level");
58-const listenStatus = $("listen-status");
59-const player = new LivePlayer();
60-setupMute($<HTMLButtonElement>("mute"), player);
61-$<HTMLInputElement>("volume").addEventListener("input", (e) => player.setVolume(Number((e.target as HTMLInputElement).value)));
62-// Playback start (set by clicking a time chart) and playhead, in periods.
63-const marks: Marks = { start: null, play: null };
64-
65-let P: Params = { Ny: 8, aspect: 1.3 };
66-let sim: DrivenPlate;
67-let running = false; // starts paused
68-let logTarget = LOG_START;
69-let actualSpeed = 0; // periods per second, smoothed
70-let ampl = 0; // colour scale: recent largest |u|
71-let hist: History;
72-let listen = { fx: 0.37, fy: 0.29 }; // listening point, fractions of the sides
73-// Drive and loss power (E_nl per driven period), smoothed.
74-let drivePower = 0, lossPower = 0;
75-
76-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));
77-const speed = () => 10 ** Number(speedSlider.value);
78-const perDecade = () => 10 ** Number(rampRate.value);
79-const pitch = () => 10 ** Number(pitchSlider.value);
80-
81-// The mode list for the current grid: lowest modes by pitch.
82-function fillModes(): void {
83- const g = gridFor(P), M = new Modes(g), keep = modeSelect.value || "1,1";
84- modeSelect.replaceChildren();
85- for (let k = 0; k < Math.min(M.count, 20); k++) {
86- const o = document.createElement("option");
87- o.value = `${M.m[k]},${M.n[k]}`;
88- o.textContent = `(${M.m[k]}, ${M.n[k]}) ${(M.omega[k] / M.omega[0]).toFixed(2)}`;
89- modeSelect.append(o);
90- }
91- modeSelect.value = keep;
92- if (modeSelect.selectedIndex < 0) modeSelect.value = "1,1";
93- gridInfo.textContent = `${g.Nx} × ${g.Ny} masses`;
94-}
95-
96-function reset(): void {
97- const [m, n] = modeSelect.value.split(",").map(Number);
98- sim = new DrivenPlate(P, { m, n, E0: 10 ** LOG_START, perturb: perturbBox.checked });
99- sim.setListen(listen.fx, listen.fy);
100- logTarget = LOG_START;
101- targetSlider.value = String(LOG_START);
102- applyLoss();
103- drivePower = lossPower = 0;
104- hist = new History(sim);
105- modeGrid.reset();
106- modeMap.reset(sim);
107- spec.reset(sim);
108- player.stop();
109- marks.start = marks.play = null;
110- ampl = 0;
111- showParams();
112-}
113-
114-function showParams(): void {
115- const g = sim.g, M = sim.modes;
116- const lines = [
117- `grid ${g.Nx} × ${g.Ny}, sides ${g.Lx.toFixed(2)} × ${g.Ly.toFixed(2)} (short side 1), ν 0.3`,
118- `driven (${sim.opts.m}, ${sim.opts.n}), pitch ${(sim.omega / M.omega[0]).toFixed(3)} × lowest`,
119- `top mode ${(M.omegaMax / sim.omega).toFixed(1)} × driven`,
120- sim.loss.tauTop > 0
121- ? `loss: decay ${fmt(sim.loss.tauTop)} periods at the top, ${fmt(sim.decayTime(M.m[M.count >> 1], M.n[M.count >> 1]))} mid-band, ${fmt(sim.decayTime(sim.opts.m, sim.opts.n))} driven mode`
122- : "loss off",
123- `lowest: ${Array.from(M.omega.slice(0, 8), (w, k) => `(${M.m[k]},${M.n[k]}) ${(w / M.omega[0]).toFixed(2)}`).join(" ")}`,
124- ];
125- paramsDiv.textContent = lines.join("\n");
126-}
127-
128-// Driven mode's and the other modes' energies in a bin (E_nl).
129-function split(modes: Float32Array): { driven: number; others: number } {
130- const d = sim.drivenIndex;
131- let all = 0;
132- for (let k = 0; k < modes.length; k++) all += modes[k];
133- return { driven: modes[d], others: all - modes[d] };
134-}
135-
136-function setTarget(): void {
137- sim.Et = 10 ** logTarget * sim.Enl;
138- targetValue.textContent = fmt(10 ** logTarget);
139-}
140-
141-// One frame: advance by speed x frame time (in driven periods), within the
142-// compute budget.
143-let last = performance.now();
144-let msPerPeriod = 1; // compute cost, measured
145-function frame(now: number): void {
146- const wall = Math.min(0.1, (now - last) / 1000);
147- last = now;
148- if (running) {
149- let periods = speed() * wall;
150- periods = Math.min(periods, BUDGET_MS / msPerPeriod);
151- if (rampBox.checked) {
152- const top = Number(targetSlider.max);
153- logTarget = Math.min(top, logTarget + periods / perDecade());
154- targetSlider.value = String(logTarget);
155- }
156- setTarget();
157- if (periods > 0) {
158- const t0 = performance.now(), Wd = sim.Wd, Wl = sim.Wl;
159- sim.advance(periods * sim.period);
160- const a = Math.min(1, periods / 20); // smooth over ~20 periods
161- drivePower += a * ((sim.Wd - Wd) / sim.Enl / periods - drivePower);
162- lossPower += a * ((sim.Wl - Wl) / sim.Enl / periods - lossPower);
163- const ms = performance.now() - t0;
164- msPerPeriod = 0.8 * msPerPeriod + 0.2 * Math.max(ms / periods, 1e-3);
165- }
166- actualSpeed = 0.9 * actualSpeed + 0.1 * (wall > 0 ? periods / wall : 0);
167- hist.update(sim);
168- modeGrid.update(hist.last.modes, hist.last.t);
169- } else actualSpeed = 0;
170- spec.update(sim.rec);
171- player.tick(sim.rec);
172- marks.play = player.position();
173- draw();
174- requestAnimationFrame(frame);
175-}
176-
177-function draw(): void {
178- drawPlate();
179- drawChart();
180- modeMap.draw(hist, marks);
181- spec.draw(sim.t / sim.period, marks);
182- speedValue.textContent = `${fmt(speed(), 2)} /s`;
183- rampValue.textContent = `${fmt(perDecade(), 2)} /decade`;
184- pitchValue.textContent = `${pitch().toFixed(0)} Hz`;
185- playButton.textContent = player.playing ? "Stop" : "Play";
186- const computed = (sim.t / sim.period).toFixed(0);
187- listenStatus.textContent = !player.playing
188- ? `from t ${(marks.start ?? 0).toFixed(0)} (click a time chart to choose)`
189- : player.waiting
190- ? `waiting at t ${marks.play!.toFixed(0)} for the computation (${computed} computed)` + (running ? "; it plays faster than it computes, so it is heard in stretches: let it compute ahead, or raise Speed" : "; the run is paused: press Run")
191- : `playing t ${marks.play!.toFixed(0)} of ${computed} computed`;
192- const { driven, others } = split(hist.last.modes), E = sim.E / sim.Enl;
193- const T = sim.measuredPeriod / sim.period;
194- readout.textContent =
195- `t ${(sim.t / sim.period).toFixed(0)} periods · E ${fmt(E)} E_nl (target ${fmt(sim.Et / sim.Enl)}) · driven mode ${fmt((100 * driven) / E, 3)}% · other modes ${fmt((100 * others) / E, 2)}%\n` +
196- `driven pitch ${Number.isFinite(T) ? fmt(1 / T) : "–"} × linear · max |w| ${fmt(sim.maxAbsU(), 2)} h · ` +
197- (sim.loss.tauTop > 0 ? `drive ${fmt(drivePower, 2)}, loss ${fmt(lossPower, 2)} E_nl/period · ` : "") +
198- `speed ${running ? fmt(actualSpeed, 2) : "0"} periods/s · energy error ${Math.abs(sim.energyError).toExponential(1)}`;
199-}
200-
201-// Where the plate was drawn (px), for clicks.
202-let plateBox = { x0: 0, y0: 0, w: 1, h: 1 };
203-// The plate card shows one of three views: the mode grid (energy per mode,
204-// default: steady), the vibration (RMS displacement over the last few
205-// periods at each point: steady), or the plate itself (the height map, live:
206-// it flickers as the plate oscillates many times a second).
207-function drawPlate(): void {
208- const view = viewSelect.value;
209- plateCanvas.style.cursor = view === "modes" ? "default" : "crosshair";
210- if (view === "modes") {
211- const ctx = fit(plateCanvas), W = plateCanvas.clientWidth, H = plateCanvas.clientHeight;
212- ctx.clearRect(0, 0, W, H);
213- modeGrid.draw(ctx, W, H, sim, plateHover);
214- } else if (view === "rms") drawField(sim.ms.map(Math.sqrt), false);
215- else drawField(sim.u, true);
216-}
217-// Height map, as in ../plate: values bilinear between the nodes (0 at the
218-// supports); signed: red up, blue down; unsigned (RMS): white to dark red;
219-// scaled to the recent largest.
220-const RES = 200;
221-const heat = document.createElement("canvas");
222-const heatCtx = heat.getContext("2d")!;
223-let heatImage: ImageData | null = null;
224-let shownView = "";
225-function drawField(u: Float64Array, signed: boolean): void {
226- const ctx = fit(plateCanvas), W = plateCanvas.clientWidth, H = plateCanvas.clientHeight;
227- ctx.clearRect(0, 0, W, H);
228- const g = sim.g;
229- let mx = 0;
230- for (const v of u) mx = Math.max(mx, Math.abs(v));
231- if (shownView !== viewSelect.value) ampl = 0; // new scale for a new view
232- shownView = viewSelect.value;
233- ampl = Math.max(mx, ampl * 0.97);
234- const scale = ampl > 0 ? 1 / ampl : 0;
235- const rw = RES, rh = Math.max(2, Math.round((RES * g.Ly) / g.Lx));
236- if (!heatImage || heatImage.width !== rw || heatImage.height !== rh) {
237- heat.width = rw;
238- heat.height = rh;
239- heatImage = heatCtx.createImageData(rw, rh);
240- }
241- const d = heatImage.data;
242- const at = (i: number, j: number) => (i < 0 || j < 0 || i >= g.Nx || j >= g.Ny ? 0 : u[i + g.Nx * j]);
243- for (let py = 0; py < rh; py++) {
244- const fy = (((rh - 1 - py + 0.5) / rh) * g.Ly) / g.ay - 1; // y up
245- const j0 = Math.floor(fy), by = fy - j0;
246- for (let px = 0; px < rw; px++) {
247- const fx = (((px + 0.5) / rw) * g.Lx) / g.ax - 1, i0 = Math.floor(fx), bx = fx - i0;
248- const v0 = (1 - bx) * at(i0, j0) + bx * at(i0 + 1, j0), v1 = (1 - bx) * at(i0, j0 + 1) + bx * at(i0 + 1, j0 + 1);
249- const v = Math.max(-1, Math.min(1, ((1 - by) * v0 + by * v1) * scale)), q = 4 * (px + rw * py);
250- if (!signed) {
251- d[q] = 255 - 75 * v;
252- d[q + 1] = 255 - 235 * v;
253- d[q + 2] = 255 - 215 * v;
254- } else if (v >= 0) {
255- d[q] = 255;
256- d[q + 1] = 255 - 200 * v;
257- d[q + 2] = 255 - 210 * v;
258- } else {
259- d[q] = 255 + 210 * v;
260- d[q + 1] = 255 + 150 * v;
261- d[q + 2] = 255 + 40 * v;
262- }
263- d[q + 3] = 255;
264- }
265- }
266- heatCtx.putImageData(heatImage, 0, 0);
267- const pad = 12, top = 8, bottom = 22;
268- const s = Math.min((W - 2 * pad) / g.Lx, (H - top - bottom) / g.Ly);
269- const w = s * g.Lx, h = s * g.Ly, x0 = (W - w) / 2, y0 = top;
270- ctx.imageSmoothingEnabled = true;
271- ctx.drawImage(heat, x0, y0, w, h);
272- ctx.strokeStyle = "#7a7d82";
273- ctx.lineWidth = 2;
274- ctx.strokeRect(x0, y0, w, h);
275- plateBox = { x0, y0, w, h };
276- // Listening point.
277- ctx.strokeStyle = "#23262b";
278- ctx.lineWidth = 1.5;
279- ctx.beginPath();
280- ctx.arc(x0 + listen.fx * w, y0 + (1 - listen.fy) * h, 6, 0, 2 * Math.PI);
281- ctx.stroke();
282- ctx.fillStyle = "#7a7d82";
283- ctx.font = "11px system-ui, sans-serif";
284- ctx.textAlign = "right";
285- ctx.fillText(ampl > 0 ? (signed ? `colour full at ±${fmt(ampl, 2)} h` : `RMS displacement, last ~4 periods; full at ${fmt(ampl, 2)} h`) : "", x0 + w, H - 6);
286- ctx.textAlign = "left";
287-}
288-
289-// Energy chart: log10(E / E_nl) against time in periods, from the history's
290-// bins (mode energies averaged over each bin). Its time axis, for clicks:
291-let chartMap = { l: 40, pw: 1, tMax: 1 };
292-const SERIES = [
293- { key: "E", color: "#23262b", dash: [] as number[], label: "total" },
294- { key: "Et", color: "#7a7d82", dash: [4, 4], label: "target" },
295- { key: "Ed", color: "#2f5d8a", dash: [], label: "driven mode" },
296- { key: "Eo", color: "#c8742c", dash: [], label: "other modes" },
297-] as const;
298-function drawChart(): void {
299- const ctx = fit(chartCanvas), W = chartCanvas.clientWidth, H = chartCanvas.clientHeight;
300- ctx.clearRect(0, 0, W, H);
301- const bins = hist.bins, n = bins.length;
302- if (n < 2) return;
303- const lg = (x: number) => Math.log10(Math.max(x, 1e-30));
304- const ts = bins.map((b) => b.t), parts = bins.map((b) => split(b.modes));
305- const series = {
306- E: bins.map((b) => lg(b.E)),
307- Et: bins.map((b) => lg(b.Et)),
308- Ed: parts.map((p) => lg(p.driven)),
309- Eo: parts.map((p) => lg(p.others)),
310- };
311- const l = 40, r = 8, t = 22, b = 22, pw = W - l - r, ph = H - t - b;
312- const tMax = Math.max(ts[n - 1], 1);
313- let lo = Infinity, hi = -Infinity;
314- for (const s of SERIES) for (const v of series[s.key]) if (v > -30) {
315- lo = Math.min(lo, v);
316- hi = Math.max(hi, v);
317- }
318- lo = Math.max(Math.floor(lo), hi - 14);
319- hi = Math.ceil(hi + 0.01);
320- const X = (x: number) => l + (x / tMax) * pw, Y = (y: number) => t + ((hi - Math.max(lo, y)) / (hi - lo)) * ph;
321- chartMap = { l, pw, tMax };
322- ctx.font = "11px system-ui, sans-serif";
323- ctx.fillStyle = "#7a7d82";
324- ctx.strokeStyle = "#e3e2dc";
325- ctx.lineWidth = 1;
326- const stepY = hi - lo > 8 ? 2 : 1;
327- ctx.textAlign = "right";
328- for (let y = Math.ceil(lo); y <= hi; y += stepY) {
329- ctx.beginPath();
330- ctx.moveTo(l, Y(y));
331- ctx.lineTo(l + pw, Y(y));
332- ctx.stroke();
333- ctx.fillText(`1e${y}`, l - 4, Y(y) + 4);
334- }
335- ctx.textAlign = "center";
336- ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)}`, l + pw / 2, H - 6);
337- for (const s of SERIES) {
338- const v = series[s.key];
339- ctx.strokeStyle = s.color;
340- ctx.setLineDash(s.dash);
341- ctx.lineWidth = 1.5;
342- ctx.beginPath();
343- for (let i = 0; i < n; i++) (i ? ctx.lineTo : ctx.moveTo).call(ctx, X(ts[i]), Y(v[i]));
344- ctx.stroke();
345- }
346- ctx.setLineDash([]);
347- drawMarks(ctx, marks, X, t, ph, "#23262b");
348- // Legend.
349- ctx.textAlign = "left";
350- let x = l;
351- for (const s of SERIES) {
352- ctx.strokeStyle = s.color;
353- ctx.setLineDash(s.dash);
354- ctx.beginPath();
355- ctx.moveTo(x, 10);
356- ctx.lineTo(x + 16, 10);
357- ctx.stroke();
358- ctx.setLineDash([]);
359- ctx.fillStyle = "#23262b";
360- ctx.fillText(s.label, x + 20, 14);
361- x += 28 + ctx.measureText(s.label).width;
362- }
363- ctx.fillStyle = "#7a7d82";
364- ctx.textAlign = "right";
365- ctx.fillText("E / E_nl", W - r, 14);
366-}
367-
368-// Controls.
369-function setRunning(on: boolean): void {
370- running = on;
371- runButton.textContent = on ? "Pause" : "Run";
372-}
373-runButton.addEventListener("click", () => setRunning(!running));
374-$("reset").addEventListener("click", reset);
375-document.addEventListener("keydown", (e) => {
376- if (e.code === "Space" && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLButtonElement)) {
377- e.preventDefault();
378- setRunning(!running);
379- }
380-});
381-targetSlider.addEventListener("input", () => {
382- logTarget = Number(targetSlider.value);
383- setTarget();
384-});
385-function applyGrid(): void {
386- const Ny = Math.round(Number(nyInput.value)), aspect = Number(aspectInput.value);
387- if (!(Ny >= 2 && Ny <= 32 && aspect >= 1 && aspect <= 3)) return;
388- P = { Ny, aspect };
389- fillModes();
390- reset();
391-}
392-nyInput.addEventListener("change", applyGrid);
393-aspectInput.addEventListener("change", applyGrid);
394-modeSelect.addEventListener("change", reset);
395-// Click the plate to move the listening point (the run goes on).
396-plateCanvas.addEventListener("click", (e) => {
397- if (viewSelect.value === "modes") return;
398- const r = plateCanvas.getBoundingClientRect(), b = plateBox;
399- const fx = (e.clientX - r.left - b.x0) / b.w, fy = 1 - (e.clientY - r.top - b.y0) / b.h;
400- if (fx <= 0 || fx >= 1 || fy <= 0 || fy >= 1) return;
401- listen = { fx, fy };
402- sim.setListen(fx, fy);
403-});
404-perTimeBox.addEventListener("change", () => (spec.perTime = perTimeBox.checked));
405-// Loss: applies to the running plate.
406-function applyLoss(): void {
407- const tau = Number(lossTauInput.value);
408- sim.setLoss({ tauTop: lossBox.checked && tau > 0 ? tau : 0, power: Number(lossPowerSelect.value) });
409- showParams();
410-}
411-for (const el of [lossBox, lossTauInput, lossPowerSelect]) el.addEventListener("change", applyLoss);
412-perturbBox.addEventListener("change", reset);
413-
414-// Listening: play the recorded signal from the chosen start; clicking a time
415-// chart moves the start (and restarts playback there if playing).
416-function play(from: number): void {
417- player.kind = signalSelect.value as SignalKind;
418- player.pitch = pitch();
419- player.level = levelSelect.value as Level;
420- player.start(from, sim.rec);
421-}
422-playButton.addEventListener("click", () => (player.playing ? player.stop() : play(marks.start ?? 0)));
423-function pick(t: number): void {
424- marks.start = Math.max(0, t);
425- if (player.playing) play(marks.start);
426-}
427-spec.onPick = pick;
428-modeMap.onPick = pick;
429-chartCanvas.addEventListener("click", (e) => {
430- const x = e.clientX - chartCanvas.getBoundingClientRect().left, m = chartMap;
431- if (x >= m.l && x <= m.l + m.pw) pick(((x - m.l) / m.pw) * m.tMax);
432-});
433-// Changing how it sounds restarts playback from where it is.
434-for (const el of [signalSelect, pitchSlider, levelSelect]) {
435- el.addEventListener("change", () => {
436- if (el === signalSelect) spec.setKind(signalSelect.value as SignalKind);
437- if (player.playing) play(player.position() ?? marks.start ?? 0);
438- });
439-}
440-
441-fillModes();
442-reset();
443-setTarget();
444-setRunning(false);
445-requestAnimationFrame(frame);
src/driven-plate/marks.tsdeleted+0−24View file
@@ -1,24 +0,0 @@
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/modegrid.tsdeleted+0−95View file
@@ -1,95 +0,0 @@
1-// The mode grid: one cell per mode (m across, n up), coloured by the mode's
2-// linear energy on a log scale, DECADES deep below a top that follows the
3-// largest (rising at once, falling slowly, so the colours do not jump). The
4-// energies are the history's bin means, smoothed over about SMOOTH driven
5-// periods. The driven mode is outlined; the hovered cell is named above.
6-
7-import { colormap } from "../common/spectrogram";
8-import type { DrivenPlate } from "./drive";
9-
10-const DECADES = 6;
11-const SMOOTH = 5; // periods
12-
13-export class ModeGrid {
14- private e = new Float64Array(0); // smoothed energies, by grid index (E_nl)
15- private lastT = 0;
16- private top = NaN; // log10 of the colour scale's top
17- // Cell layout of the last draw, for hover.
18- private box = { x0: 0, y0: 0, cw: 1, ch: 1, nx: 1, ny: 1 };
19-
20- reset(): void {
21- this.e = new Float64Array(0);
22- this.top = NaN;
23- }
24-
25- // Take in the latest bin (mode energies in E_nl, by grid index) at run time t.
26- update(modes: Float32Array, t: number): void {
27- if (this.e.length !== modes.length) {
28- this.e = Float64Array.from(modes);
29- this.lastT = t;
30- return;
31- }
32- const a = 1 - Math.exp(-Math.max(0, t - this.lastT) / SMOOTH);
33- this.lastT = t;
34- for (let k = 0; k < modes.length; k++) this.e[k] += a * (modes[k] - this.e[k]);
35- }
36-
37- // Grid index under (x, y) px, or -1.
38- at(x: number, y: number): number {
39- const b = this.box, i = Math.floor((x - b.x0) / b.cw), j = Math.floor((b.y0 + b.ny * b.ch - y) / b.ch);
40- return i >= 0 && j >= 0 && i < b.nx && j < b.ny ? i + b.nx * j : -1;
41- }
42-
43- draw(ctx: CanvasRenderingContext2D, W: number, H: number, sim: DrivenPlate, hover: { x: number; y: number } | null): void {
44- const { Nx, Ny } = sim.g, e = this.e;
45- if (e.length !== Nx * Ny) return;
46- let mx = 0;
47- for (const v of e) mx = Math.max(mx, v);
48- const want = Math.log10(Math.max(mx, 1e-30));
49- this.top = Number.isNaN(this.top) || want > this.top ? want : this.top + 0.02 * (want - this.top);
50- const left = 34, right = 12, top = 22, bottom = 34;
51- const cell = Math.min((W - left - right) / Nx, (H - top - bottom) / Ny);
52- const x0 = left + (W - left - right - cell * Nx) / 2, y0 = top;
53- this.box = { x0, y0, cw: cell, ch: cell, nx: Nx, ny: Ny };
54- const rgb = new Uint8ClampedArray(4);
55- for (let j = 0; j < Ny; j++) {
56- for (let i = 0; i < Nx; i++) {
57- const v = (Math.log10(Math.max(e[i + Nx * j], 1e-30)) - (this.top - DECADES)) / DECADES;
58- colormap(v, rgb, 0);
59- ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
60- ctx.fillRect(x0 + i * cell, y0 + (Ny - 1 - j) * cell, cell - 1, cell - 1);
61- }
62- }
63- // Driven mode outlined.
64- const di = sim.opts.m - 1, dj = sim.opts.n - 1;
65- ctx.strokeStyle = "#2f5d8a";
66- ctx.lineWidth = 2.5;
67- ctx.strokeRect(x0 + di * cell - 1, y0 + (Ny - 1 - dj) * cell - 1, cell + 1, cell + 1);
68- // Axes: m along x, n along y.
69- ctx.fillStyle = "#7a7d82";
70- ctx.font = "11px system-ui, sans-serif";
71- ctx.textAlign = "center";
72- for (let i = 0; i < Nx; i++) ctx.fillText(`${i + 1}`, x0 + (i + 0.5) * cell, y0 + Ny * cell + 13);
73- ctx.fillText("m (half waves along x)", x0 + (Nx * cell) / 2, y0 + Ny * cell + 28);
74- ctx.textAlign = "right";
75- for (let j = 0; j < Ny; j++) ctx.fillText(`${j + 1}`, x0 - 5, y0 + (Ny - 1 - j + 0.5) * cell + 4);
76- ctx.save();
77- ctx.translate(10, y0 + (Ny * cell) / 2);
78- ctx.rotate(-Math.PI / 2);
79- ctx.textAlign = "center";
80- ctx.fillText("n", 0, 0);
81- ctx.restore();
82- // Header: hovered mode, or the colour scale.
83- ctx.textAlign = "left";
84- const k = hover ? this.at(hover.x, hover.y) : -1;
85- if (k >= 0) {
86- const m = (k % Nx) + 1, n = Math.floor(k / Nx) + 1;
87- const w = sim.modes.omega[Array.from(sim.modes.m).findIndex((mm, q) => mm === m && sim.modes.n[q] === n)];
88- ctx.fillStyle = "#23262b";
89- ctx.fillText(`(${m}, ${n}) · pitch ${(w / sim.omega).toFixed(2)} × driven · ${e[k].toExponential(1)} E_nl`, x0, 14);
90- } else {
91- ctx.fillStyle = "#7a7d82";
92- ctx.fillText(`energy per mode, 1e${Math.round(this.top - DECADES)} to 1e${Math.round(this.top)} E_nl · driven mode outlined`, x0, 14);
93- }
94- }
95-}
src/driven-plate/modemap.tsdeleted+0−159View file
@@ -1,159 +0,0 @@
1-// The mode map: one row per mode, ordered by pitch (lowest at the bottom),
2-// time across (the whole run, as the energy chart), colour the mode's linear
3-// energy averaged over each time bin, on a log scale DECADES deep below the
4-// largest so far. The image holds one pixel per bin and mode and is
5-// stretched to the plot; new bins are painted as they come, and the whole
6-// image is repainted when the bins are merged or the colour scale moves.
7-// Hover names the mode under the cursor and its energy.
8-
9-import { fit } from "../common/canvas";
10-import type { DrivenPlate } from "./drive";
11-import { MAX_BINS, type History } from "./history";
12-import { drawMarks, type Marks } from "./marks";
13-
14-const DECADES = 12;
15-const L = 92, R = 8, T = 44, B = 22; // margins (px)
16-
17-// Inferno-like colour map, dark (low) to pale yellow (high), as a lookup table.
18-const STOPS = [
19- [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
20-];
21-const LUT = new Uint8ClampedArray(256 * 3);
22-for (let i = 0; i < 256; i++) {
23- const x = (i / 255) * (STOPS.length - 1), k = Math.min(STOPS.length - 2, Math.floor(x)), f = x - k;
24- for (let c = 0; c < 3; c++) LUT[3 * i + c] = STOPS[k][c] + f * (STOPS[k + 1][c] - STOPS[k][c]);
25-}
26-const cssColor = (v: number) => {
27- const i = 3 * Math.max(0, Math.min(255, Math.round(v * 255)));
28- return `rgb(${LUT[i]}, ${LUT[i + 1]}, ${LUT[i + 2]})`;
29-};
30-
31-export class ModeMap {
32- private canvas: HTMLCanvasElement;
33- private img: HTMLCanvasElement;
34- private ictx: CanvasRenderingContext2D;
35- private data: ImageData | null = null;
36- private painted = 0; // bins painted
37- private merges = -1;
38- private top = NaN; // log10 of the top of the colour scale
39- private rows: Int32Array = new Int32Array(0); // row (from the bottom) -> grid index
40- private sim: DrivenPlate | null = null;
41- private hover: { x: number; y: number } | null = null;
42-
43- constructor(canvas: HTMLCanvasElement) {
44- this.canvas = canvas;
45- this.img = document.createElement("canvas");
46- this.ictx = this.img.getContext("2d")!;
47- canvas.addEventListener("mousemove", (e) => {
48- const r = canvas.getBoundingClientRect();
49- this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
50- });
51- 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- });
56- }
57- onPick: ((t: number) => void) | null = null;
58- private tMax = 1;
59-
60- // A new run (new plate or reset).
61- reset(sim: DrivenPlate): void {
62- this.sim = sim;
63- const M = sim.modes, Nx = sim.g.Nx;
64- this.rows = Int32Array.from({ length: M.count }, (_, k) => M.m[k] - 1 + Nx * (M.n[k] - 1));
65- this.img.width = MAX_BINS;
66- this.img.height = M.count;
67- this.data = this.ictx.createImageData(MAX_BINS, M.count);
68- this.painted = 0;
69- this.merges = -1;
70- this.top = NaN;
71- }
72-
73- draw(hist: History, marks: Marks): void {
74- const sim = this.sim!, data = this.data!, bins = hist.bins, count = this.rows.length;
75- // Colour scale: top at the decade above the largest energy so far.
76- let mx = 0;
77- for (const b of bins) for (let k = 0; k < count; k++) if (b.modes[k] > mx) mx = b.modes[k];
78- const top = Math.ceil(Math.log10(Math.max(mx, 1e-30)));
79- if (top !== this.top || hist.merges !== this.merges) {
80- this.top = top;
81- this.merges = hist.merges;
82- this.painted = 0;
83- }
84- const d = data.data, W = MAX_BINS;
85- for (let c = this.painted; c < bins.length; c++) {
86- const e = bins[c].modes;
87- for (let r = 0; r < count; r++) {
88- const v = (Math.log10(Math.max(e[this.rows[r]], 1e-30)) - (top - DECADES)) / DECADES;
89- const i = 3 * Math.max(0, Math.min(255, Math.round(v * 255))), q = 4 * (c + W * (count - 1 - r));
90- d[q] = LUT[i];
91- d[q + 1] = LUT[i + 1];
92- d[q + 2] = LUT[i + 2];
93- d[q + 3] = 255;
94- }
95- }
96- if (this.painted < bins.length) this.ictx.putImageData(data, 0, 0, this.painted, 0, bins.length - this.painted, count);
97- this.painted = bins.length;
98-
99- const ctx = fit(this.canvas), Wc = this.canvas.clientWidth, Hc = this.canvas.clientHeight;
100- ctx.clearRect(0, 0, Wc, Hc);
101- const pw = Wc - L - R, ph = Hc - T - B, rowH = ph / count;
102- ctx.imageSmoothingEnabled = false;
103- if (bins.length > 0) ctx.drawImage(this.img, 0, 0, bins.length, count, L, T, pw, ph);
104- ctx.strokeStyle = "#e3e2dc";
105- ctx.strokeRect(L - 0.5, T - 0.5, pw + 1, ph + 1);
106-
107- // Row labels: (m, n) and pitch relative to the driven mode, as many as fit;
108- // the driven mode always, in the accent colour.
109- const M = sim.modes, driven = this.rows.indexOf(sim.drivenIndex);
110- const every = Math.max(1, Math.ceil(13 / rowH));
111- ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
112- ctx.textAlign = "right";
113- const label = (r: number) => `(${M.m[r]},${M.n[r]}) ${(M.omega[r] / sim.omega).toFixed(2)}`;
114- const yOf = (r: number) => T + ph - (r + 0.5) * rowH;
115- for (let r = 0; r < count; r += every) {
116- if (Math.abs(r - driven) < every && r !== driven) continue;
117- ctx.fillStyle = "#7a7d82";
118- ctx.fillText(label(r), L - 6, yOf(r) + 4);
119- }
120- ctx.fillStyle = "#2f5d8a";
121- ctx.fillText(`▸ ${label(driven)}`, L - 6, yOf(driven) + 4);
122-
123- // Time axis and colour bar.
124- const tMax = Math.max(hist.last.t, 1);
125- this.tMax = tMax;
126- drawMarks(ctx, marks, (t) => L + (t / tMax) * pw, T, ph, "#ffffff");
127- ctx.font = "11px system-ui, sans-serif";
128- ctx.fillStyle = "#7a7d82";
129- ctx.textAlign = "center";
130- ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)}`, L + pw / 2, Hc - 6);
131- // Colour bar on its own line under the hover text, right-aligned.
132- const cbW = Math.min(160, pw / 3), cbX = L + pw - cbW - 66, cbY = 24;
133- for (let i = 0; i < cbW; i++) {
134- ctx.fillStyle = cssColor(i / (cbW - 1));
135- ctx.fillRect(cbX + i, cbY, 1.5, 10);
136- }
137- ctx.fillStyle = "#7a7d82";
138- ctx.textAlign = "right";
139- ctx.fillText(`1e${top - DECADES}`, cbX - 4, cbY + 9);
140- ctx.textAlign = "left";
141- ctx.fillText(`1e${top} E_nl`, cbX + cbW + 4, cbY + 9);
142-
143- // Hover: the mode and bin under the cursor.
144- const h = this.hover;
145- ctx.textAlign = "left";
146- ctx.fillStyle = "#23262b";
147- if (h && h.x >= L && h.x < L + pw && h.y >= T && h.y < T + ph && bins.length > 0) {
148- const c = Math.min(bins.length - 1, Math.floor(((h.x - L) / pw) * bins.length));
149- const r = Math.min(count - 1, Math.floor((T + ph - h.y) / rowH));
150- const e = bins[c].modes[this.rows[r]];
151- ctx.fillText(`(${M.m[r]}, ${M.n[r]}) · ${(M.omega[r] / sim.omega).toFixed(2)} × driven · t ${bins[c].t.toFixed(0)} · ${e.toExponential(1)} E_nl`, L, 13);
152- ctx.strokeStyle = "rgba(255, 255, 255, 0.7)";
153- ctx.strokeRect(L, yOf(r) - rowH / 2, pw, rowH);
154- } else {
155- ctx.fillStyle = "#7a7d82";
156- ctx.fillText("rows: modes by pitch (× driven) · hover to read · click: play from", L, 13);
157- }
158- }
159-}
src/driven-plate/plate.tsdeleted+0−252View file
@@ -1,252 +0,0 @@
1-// A rectangular plate, simply supported, as Nx x Ny equal masses on vertical
2-// rails at the interior nodes of a grid, moving up and down only, with von
3-// Karman stretching. Node (i, j) at x = (i + 1) ax, y = (j + 1) ay, index i +
4-// Nx j; the edge nodes (i = -1 or Nx, j = -1 or Ny) are supports at u = 0.
5-// The sides are Lx = (Nx + 1) ax and Ly = (Ny + 1) ay.
6-// Units: the von Karman plate, scaled, has only the aspect ratio and Poisson's
7-// ratio left as parameters (lengths in the plane by Ly, displacement by the
8-// thickness h, time by Ly^2 sqrt(rho h / D)). So D = 1, rho h = 1, h = 1, Ly
9-// = 1 and Lx = aspect; energies are reported against the nonlinear scale and
10-// times in periods of the driven mode, so these units never show.
11-// Bending: D / 2 times the area-weighted sum of (Lap u)^2, Lap the five-point
12-// Laplacian with spacings ax, ay (L u = -Lap u below, positive); a simply
13-// supported edge has no term (mirror ghost). Force -D ax ay L (L u). The modes
14-// are exact products of sines, S_mn(i, j) = sx_m(i) sy_n(j) with sx_m(i) =
15-// sqrt(2 / (Nx + 1)) sin(m pi (i + 1) / (Nx + 1)), and L S_mn = Lam_mn S_mn,
16-// Lam_mn = lx_m / ax^2 + ly_n / ay^2, l_m = 4 sin^2(m pi / 2 (N + 1)), so
17-// omega_mn = Lam_mn sqrt(D / rho h): for low modes (m pi / Lx)^2 + (n pi /
18-// Ly)^2, the continuum plate.
19-// Stretching (von Karman), as in ../plate/plate.ts with two spacings: s =
20-// l(u, u) = 2 (X Y - Z^2) at each node, X = Dxx u / ax^2, Y = Dyy u / ay^2, Z
21-// = Dxy u / (ax ay) (second differences, zero beyond the edge, Dxy the centred
22-// cross difference), phi = L^-2 s by sine transforms (F = Lap F = 0 at the
23-// edge), energy (Eh / 8) ax ay sum s phi, Eh = 12 (1 - nu^2) D / h^2. The
24-// force is its exact gradient, -(Eh / 2) ax ay (Dxx (phi Y) / ax^2 + Dyy (phi
25-// X) / ay^2 - 2 Dxy (phi Z) / (ax ay)), so velocity Verlet conserves energy.
26-// Loss (none yet) and the drive live in drive.ts.
27-
28-export const NU = 0.3;
29-const D = 1, RHO_H = 1, THICK = 1;
30-const EH = (12 * (1 - NU * NU) * D) / (THICK * THICK);
31-
32-export interface Params {
33- Ny: number; // masses across the short side (y)
34- aspect: number; // Lx / Ly, at least 1
35-}
36-
37-// The grid for the given parameters: Nx chosen so the cells are as near
38-// square as possible.
39-export interface Grid {
40- Nx: number;
41- Ny: number;
42- n: number; // Nx Ny
43- Lx: number;
44- Ly: number;
45- ax: number;
46- ay: number;
47- mass: number; // of each node, rho h ax ay
48-}
49-export function gridFor(P: Params): Grid {
50- const Ly = 1, Lx = P.aspect, Ny = P.Ny;
51- const Nx = Math.max(1, Math.round(Lx * (Ny + 1)) - 1);
52- const ax = Lx / (Nx + 1), ay = Ly / (Ny + 1);
53- return { Nx, Ny, n: Nx * Ny, Lx, Ly, ax, ay, mass: RHO_H * ax * ay };
54-}
55-
56-const lam1 = (N: number, m: number) => 4 * Math.sin((m * Math.PI) / (2 * (N + 1))) ** 2;
57-// Orthonormal sine matrix for N points, S[m * N + i], m = 0..N-1 (mode m + 1).
58-function sineMatrix(N: number): Float64Array {
59- const S = new Float64Array(N * N);
60- for (let m = 0; m < N; m++) for (let i = 0; i < N; i++) S[m * N + i] = Math.sqrt(2 / (N + 1)) * Math.sin(((m + 1) * (i + 1) * Math.PI) / (N + 1));
61- return S;
62-}
63-
64-// The normal modes (exact), sorted by frequency: omega (rad / time unit) and
65-// (m, n), ties by m then n. Mode (m, n) has Lam = omega (D = rho h = 1).
66-export class Modes {
67- count: number;
68- m: Int32Array;
69- n: Int32Array;
70- omega: Float64Array;
71- constructor(g: Grid) {
72- const all: [number, number, number][] = [];
73- for (let m = 1; m <= g.Nx; m++) for (let n = 1; n <= g.Ny; n++) all.push([omegaOf(g, m, n), m, n]);
74- all.sort((x, y) => x[0] - y[0] || x[1] - y[1] || x[2] - y[2]);
75- this.count = all.length;
76- this.omega = Float64Array.from(all, (x) => x[0]);
77- this.m = Int32Array.from(all, (x) => x[1]);
78- this.n = Int32Array.from(all, (x) => x[2]);
79- }
80- get omegaMax(): number {
81- return this.omega[this.count - 1];
82- }
83-}
84-export const omegaOf = (g: Grid, m: number, n: number) => (lam1(g.Nx, m) / g.ax ** 2 + lam1(g.Ny, n) / g.ay ** 2) * Math.sqrt(D / RHO_H);
85-
86-// Unit-length shape of mode (m, n) on the nodes (sum of squares 1).
87-export function modeShape(g: Grid, m: number, n: number): Float64Array {
88- const v = new Float64Array(g.n), cx = Math.sqrt(2 / (g.Nx + 1)), cy = Math.sqrt(2 / (g.Ny + 1));
89- for (let j = 0; j < g.Ny; j++) {
90- const sy = cy * Math.sin((n * (j + 1) * Math.PI) / (g.Ny + 1));
91- for (let i = 0; i < g.Nx; i++) v[i + g.Nx * j] = sy * cx * Math.sin((m * (i + 1) * Math.PI) / (g.Nx + 1));
92- }
93- return v;
94-}
95-
96-// Forces and potential energy. One per grid; holds the scratch arrays.
97-export class Plate {
98- g: Grid;
99- private Sx: Float64Array;
100- private Sy: Float64Array;
101- private inv2: Float64Array; // 1 / Lam_mn^2, at m + Nx n (0-based)
102- private c: Float64Array;
103- private xx: Float64Array;
104- private yy: Float64Array;
105- private xy: Float64Array;
106- private s: Float64Array;
107- private phi: Float64Array;
108- private t: Float64Array;
109- private t2: Float64Array;
110- private pa: Float64Array; // padded, (Nx + 2)(Ny + 2)
111- private pb: Float64Array;
112- private pc: Float64Array;
113- constructor(g: Grid) {
114- this.g = g;
115- const { Nx, Ny, n } = g, f = () => new Float64Array(n), pad = () => new Float64Array((Nx + 2) * (Ny + 2));
116- this.Sx = sineMatrix(Nx);
117- this.Sy = sineMatrix(Ny);
118- this.inv2 = f();
119- for (let q = 0; q < Ny; q++) for (let p = 0; p < Nx; p++) this.inv2[p + Nx * q] = 1 / (lam1(Nx, p + 1) / g.ax ** 2 + lam1(Ny, q + 1) / g.ay ** 2) ** 2;
120- this.c = f();
121- this.xx = f();
122- this.yy = f();
123- this.xy = f();
124- this.s = f();
125- this.phi = f();
126- this.t = f();
127- this.t2 = f();
128- this.pa = pad();
129- this.pb = pad();
130- this.pc = pad();
131- }
132-
133- // out = (Sy x Sx) X: the sine transform along x then y (its own inverse).
134- sine2(X: Float64Array, out: Float64Array): void {
135- const { Nx, Ny } = this.g, Sx = this.Sx, Sy = this.Sy, t = this.t;
136- for (let j = 0; j < Ny; j++) {
137- const r = Nx * j;
138- for (let m = 0; m < Nx; m++) {
139- let acc = 0;
140- for (let i = 0; i < Nx; i++) acc += Sx[m * Nx + i] * X[r + i];
141- t[r + m] = acc;
142- }
143- }
144- for (let m = 0; m < Nx; m++) {
145- for (let q = 0; q < Ny; q++) {
146- let acc = 0;
147- for (let j = 0; j < Ny; j++) acc += Sy[q * Ny + j] * t[m + Nx * j];
148- out[m + Nx * q] = acc;
149- }
150- }
151- }
152-
153- // L x into out (positive: minus the Laplacian, x = 0 at the supports).
154- private laplacian(x: Float64Array, out: Float64Array): void {
155- const { Nx, Ny, ax, ay } = this.g, bx = 1 / (ax * ax), by = 1 / (ay * ay);
156- for (let j = 0, k = 0; j < Ny; j++) {
157- for (let i = 0; i < Nx; i++, k++) {
158- let sx = 2 * x[k], sy = 2 * x[k];
159- if (i > 0) sx -= x[k - 1];
160- if (i < Nx - 1) sx -= x[k + 1];
161- if (j > 0) sy -= x[k - Nx];
162- if (j < Ny - 1) sy -= x[k + Nx];
163- out[k] = bx * sx + by * sy;
164- }
165- }
166- }
167-
168- // Forces at u into F; returns the potential energy (bending + stretching).
169- forces(u: Float64Array, F: Float64Array, linear = false): number {
170- const g = this.g, n = g.n, A = g.ax * g.ay, c = this.c;
171- this.laplacian(u, c);
172- let Vb = 0;
173- for (let k = 0; k < n; k++) Vb += c[k] * c[k];
174- this.laplacian(c, F);
175- for (let k = 0; k < n; k++) F[k] *= -D * A;
176- if (linear) return (D * A * Vb) / 2;
177- return (D * A * Vb) / 2 + this.stretching(u, F);
178- }
179-
180- // Stretching energy at u; with F given, its force is added.
181- stretching(u: Float64Array, F?: Float64Array): number {
182- const { Nx, Ny, n, ax, ay } = this.g, R = Nx + 2;
183- const { pa: pad, xx, yy, xy, s, phi, t2 } = this;
184- const bx = 1 / (ax * ax), by = 1 / (ay * ay), bxy = 1 / (ax * ay);
185- for (let j = 0; j < Ny; j++) for (let i = 0; i < Nx; i++) pad[i + 1 + R * (j + 1)] = u[i + Nx * j];
186- for (let j = 0, k = 0; j < Ny; j++) {
187- for (let i = 0, K = 1 + R * (j + 1); i < Nx; i++, k++, K++) {
188- const cc = 2 * pad[K];
189- xx[k] = bx * (pad[K - 1] - cc + pad[K + 1]);
190- yy[k] = by * (pad[K - R] - cc + pad[K + R]);
191- xy[k] = 0.25 * bxy * (pad[K + R + 1] - pad[K - R + 1] - pad[K + R - 1] + pad[K - R - 1]);
192- s[k] = 2 * (xx[k] * yy[k] - xy[k] * xy[k]);
193- }
194- }
195- this.sine2(s, t2);
196- for (let k = 0; k < n; k++) t2[k] *= this.inv2[k];
197- this.sine2(t2, phi);
198- let V = 0;
199- for (let k = 0; k < n; k++) V += s[k] * phi[k];
200- const A = ax * ay;
201- if (F) {
202- // F -= (Eh / 2) A (Dxx (phi Y) bx + Dyy (phi X) by - 2 Dxy (phi Z) bxy);
203- // the difference operators are symmetric.
204- // a reuses the padded copy of u (no longer needed); the rings of zeros
205- // are never written.
206- const a = this.pa, b = this.pb, c = this.pc;
207- for (let j = 0, k = 0; j < Ny; j++) {
208- for (let i = 0, K = 1 + R * (j + 1); i < Nx; i++, k++, K++) {
209- a[K] = phi[k] * yy[k];
210- b[K] = phi[k] * xx[k];
211- c[K] = phi[k] * xy[k];
212- }
213- }
214- const h = 0.5 * EH * A;
215- for (let j = 0, k = 0; j < Ny; j++) {
216- for (let i = 0, K = 1 + R * (j + 1); i < Nx; i++, k++, K++) {
217- const gk =
218- bx * (a[K - 1] - 2 * a[K] + a[K + 1]) +
219- by * (b[K - R] - 2 * b[K] + b[K + R]) -
220- 0.5 * bxy * (c[K + R + 1] - c[K - R + 1] - c[K + R - 1] + c[K - R - 1]);
221- F[k] -= h * gk;
222- }
223- }
224- }
225- return (EH / 8) * A * V;
226- }
227-
228- private Fs: Float64Array | null = null;
229- potential(u: Float64Array): number {
230- return this.forces(u, (this.Fs ??= new Float64Array(this.g.n)));
231- }
232-}
233-
234-// Nonlinear scale of mode (m, n): the energy at which its stretching energy
235-// equals its bending energy, for the mode shape alone (bending ~ q^2,
236-// stretching ~ q^4): E = Vb(1)^2 / Vs(1) at unit amplitude.
237-export function nonlinearScale(plate: Plate, m: number, n: number): number {
238- const v = modeShape(plate.g, m, n), F = new Float64Array(plate.g.n);
239- const Vb = plate.forces(v, F, true), Vs = plate.stretching(v);
240- return (Vb * Vb) / Vs;
241-}
242-
243-// Integrator step: STEP / omega at the top of the band, as in ../plate. The
244-// in-plane force per length N (from the stretching) raises the top mode's
245-// omega^2 by about N (4 / ax^2 + 4 / ay^2) / rho h; N is at most about
246-// sqrt(2 Eh E / (ax ay)) (all of E as stretching in one cell), taken twice
247-// for margin.
248-const STEP = 0.2;
249-export function stepFor(g: Grid, modes: Modes, E: number): number {
250- const N = 2 * Math.sqrt((2 * EH * Math.max(E, 0)) / (g.ax * g.ay));
251- return STEP / Math.sqrt(modes.omegaMax ** 2 + (N * (4 / g.ax ** 2 + 4 / g.ay ** 2)) / RHO_H);
252-}
src/driven-plate/player.tsdeleted+0−168View file
@@ -1,168 +0,0 @@
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 fades out and waits, and
9-// resumes (fading in) once RESUME_S of sound has been computed ahead, so a
10-// run that computes slower than it plays is heard in stretches.
11-// Level: "auto" divides by a peak follower (instant attack, RELEASE_S
12-// release), so the level stays steady while the energy rises by many
13-// decades and what changes is the timbre; "fixed" scales by the largest
14-// value recorded so far (later, louder parts are soft-limited by tanh).
15-
16-import { SIGNAL_RATE } from "./drive";
17-import type { Recording, SignalKind } from "./recording";
18-
19-const CHUNK_S = 0.2;
20-const AHEAD_S = 0.6;
21-const HALF = 8;
22-const RELEASE_S = 1.5;
23-const FADE_S = 0.01;
24-const RESUME_S = 1.5;
25-
26-export type Level = "auto" | "fixed";
27-
28-export class LivePlayer {
29- private ctx: AudioContext | null = null;
30- private gain: GainNode | null = null;
31- private volume = 0.5;
32- muted = false;
33- playing = false;
34- pitch = 60; // Hz of the driven mode
35- kind: SignalKind = "v";
36- level: Level = "auto";
37- waiting = false; // at the computed end, waiting for more
38- private t0 = 0; // run time (periods) at ctx time ctx0
39- private ctx0 = 0;
40- private nextCtx = 0; // ctx time of the next chunk
41- private nextT = 0; // run time (periods) of the next chunk's first sample
42- private env = 0;
43- private fixedScale = 1;
44- private first = true;
45- private sources: AudioBufferSourceNode[] = [];
46-
47- private ensure(): AudioContext {
48- if (!this.ctx) {
49- this.ctx = new AudioContext();
50- this.gain = this.ctx.createGain();
51- this.gain.connect(this.ctx.destination);
52- this.applyGain();
53- }
54- return this.ctx;
55- }
56- private applyGain(): void {
57- if (this.gain) this.gain.gain.value = this.muted ? 0 : this.volume;
58- }
59- setVolume(v: number): void {
60- this.volume = v;
61- this.applyGain();
62- }
63- setMuted(m: boolean): void {
64- this.muted = m;
65- this.applyGain();
66- }
67-
68- // Start playing from run time t (periods); call from a user gesture.
69- start(t: number, rec: Recording): void {
70- const ctx = this.ensure();
71- void ctx.resume();
72- this.stop();
73- this.playing = true;
74- this.waiting = false;
75- this.t0 = this.nextT = Math.max(0, t);
76- this.ctx0 = this.nextCtx = ctx.currentTime + 0.05;
77- this.env = 0;
78- this.first = true;
79- let peak = 0;
80- for (let j = 0; j < rec.length; j++) peak = Math.max(peak, Math.abs(rec.get(this.kind, j)));
81- this.fixedScale = peak > 0 ? 0.7 / peak : 1;
82- }
83-
84- stop(): void {
85- for (const s of this.sources) {
86- s.onended = null;
87- s.stop();
88- }
89- this.sources = [];
90- this.playing = false;
91- }
92-
93- // Run time (periods) being heard now, or null.
94- position(): number | null {
95- if (!this.playing || !this.ctx) return null;
96- if (this.waiting && this.sources.length === 0) return this.nextT;
97- const t = this.t0 + Math.max(0, this.ctx.currentTime - this.ctx0) * this.pitch;
98- return Math.min(t, this.nextT);
99- }
100-
101- // Keep chunks scheduled ahead; call every frame.
102- tick(rec: Recording): void {
103- if (!this.playing || !this.ctx) return;
104- const ctx = this.ctx, fs = ctx.sampleRate, dT = this.pitch / fs; // periods per output sample
105- const c = 0.9 * Math.min(1, fs / (SIGNAL_RATE * this.pitch)), K = Math.ceil(HALF / c);
106- // Last run time with the whole kernel recorded.
107- const endT = (rec.length - K - 1) / SIGNAL_RATE;
108- if (this.waiting) {
109- // Resume once enough is computed ahead, after what is still playing.
110- if (endT - this.nextT < RESUME_S * this.pitch) return;
111- this.waiting = false;
112- this.first = true;
113- this.nextCtx = Math.max(this.nextCtx, ctx.currentTime + 0.05);
114- this.t0 = this.nextT;
115- this.ctx0 = this.nextCtx;
116- }
117- while (this.nextCtx < ctx.currentTime + AHEAD_S) {
118- const want = Math.round(CHUNK_S * fs), count = Math.min(want, Math.floor((endT - this.nextT) / dT));
119- if (count < want) {
120- // Out of computed signal: play what there is, faded out, and wait.
121- if (count > 0) this.schedule(this.render(rec, count, dT, c, K, true), fs);
122- this.waiting = true;
123- return;
124- }
125- this.schedule(this.render(rec, count, dT, c, K, false), fs);
126- }
127- }
128-
129- private schedule(x: Float32Array, fs: number): void {
130- const ctx = this.ctx!, buf = ctx.createBuffer(1, x.length, fs);
131- buf.copyToChannel(x as Float32Array<ArrayBuffer>, 0);
132- const src = ctx.createBufferSource();
133- src.buffer = buf;
134- src.connect(this.gain!);
135- src.start(this.nextCtx);
136- src.onended = () => {
137- this.sources = this.sources.filter((s) => s !== src);
138- };
139- this.sources.push(src);
140- this.nextCtx += x.length / fs;
141- }
142-
143- private render(rec: Recording, count: number, dT: number, c: number, K: number, fadeOut: boolean): Float32Array {
144- const out = new Float32Array(count), fs = this.ctx!.sampleRate, rel = Math.exp(-1 / (RELEASE_S * fs));
145- const nFade = Math.round(FADE_S * fs);
146- for (let i = 0; i < count; i++) {
147- // Sample j is centred at (j + 0.5) / SIGNAL_RATE periods.
148- const s = (this.nextT + i * dT) * SIGNAL_RATE - 0.5, j0 = Math.floor(s);
149- let y = 0;
150- for (let j = j0 - K + 1; j <= j0 + K; j++) {
151- const d = s - j, w = 0.5 + 0.5 * Math.cos((Math.PI * d) / K);
152- const x = Math.PI * c * d, sinc = Math.abs(x) < 1e-9 ? 1 : Math.sin(x) / x;
153- y += rec.get(this.kind, j) * c * sinc * w;
154- }
155- if (this.level === "auto") {
156- const a = Math.abs(y);
157- this.env = a > this.env ? a : this.env * rel;
158- y = this.env > 0 ? (0.5 * y) / this.env : 0;
159- } else y = Math.tanh(y * this.fixedScale);
160- if (this.first && i < nFade) y *= i / nFade;
161- if (fadeOut && count - 1 - i < nFade) y *= (count - 1 - i) / nFade;
162- out[i] = y;
163- }
164- this.first = false;
165- this.nextT += count * dT;
166- return out;
167- }
168-}
src/driven-plate/recording.tsdeleted+0−28View file
@@ -1,28 +0,0 @@
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-}
src/driven-plate/style.cssdeleted+0−18View file
@@ -1,18 +0,0 @@
1-/* Styles for this app only, after ../common/style.css. */
2-
3-.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
4-.row + .row { margin-top: 10px; }
5-canvas { display: block; width: 100%; }
6-#plate { aspect-ratio: 1.45; }
7-#chart { height: 240px; }
8-#readout { margin-top: 8px; font-size: 12px; color: var(--muted); white-space: pre-wrap; line-height: 1.6; }
9-.column { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
10-.card-head { font-size: 12px; margin-bottom: 12px; display: flex; align-items: center; }
11-input[type="number"] { width: 4.5em; font-family: var(--mono); }
12-.label-w { min-width: 7.5em; }
13-.value-w { min-width: 6em; text-align: right; }
14-#params { margin-top: 12px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
15-.wide { margin-top: 16px; }
16-#spec { height: 380px; cursor: crosshair; }
17-.view-row { margin-bottom: 6px; }
18-#modemap { height: 420px; cursor: crosshair; }
src/index.htmlmodified+0−1View file
@@ -35,7 +35,6 @@
3535 <li><a href="./fput-chain/"><strong>FPUT chain</strong><div class="muted">A struck chain of N masses with cubic springs; energy spreading up the modes, Lyapunov exponent and Poincaré sections for N = 2 and 3.</div></a></li>
3636 <li><a href="./beam/"><strong>Beam</strong><div class="muted">A struck beam of N masses with bending stiffness and stretching: clamped, pinned or cantilever; inharmonic, cymbal-like overtones.</div></a></li>
3737 <li><a href="./plate/"><strong>Plate</strong><div class="muted">A struck square plate of N × N masses with bending stiffness and von Kármán stretching, simply supported, clamped or free; modes (m, n) at m² + n², pitch glides when struck hard.</div></a></li>
38- <li><a href="./driven-plate/"><strong>Driven plate</strong><div class="muted">A rectangular plate driven in one mode, with no damping, its energy raised slowly: watch the energy move from the driven mode into the others.</div></a></li>
3938 </ul>
4039 </main>
4140 </body>
vite.config.tsmodified+0−1View file
@@ -20,7 +20,6 @@ export default defineConfig({
2020 "fput-chain": page("fput-chain/index.html"),
2121 beam: page("beam/index.html"),
2222 plate: page("plate/index.html"),
23- "driven-plate": page("driven-plate/index.html"),
2423 },
2524 // Name the chunk holding src/common/ "common" (otherwise it takes the
2625 // name of one of its modules).