/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Driven plate: playback waits at the computed end and resumes when 1.5 s is ready (was stopping); default speed 60 periods/s to match the default pitch
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 1b48749c4748 parent 2a0d838 Browse files
4 changed files+33−19
src/driven-plate/README.mdmodified+3−1View file
@@ -26,8 +26,10 @@ Spectrogram (step 3, `livespec.ts`; `drive.ts` gives the signal): displacement a
2626
2727 Seen (default settings, no loss, 2026-10-09): one line at the driven pitch to about 0.1 E_nl, with odd multiples (3, 5, ...) coming in as the energy rises (the bound harmonics); from about 0.3 E_nl the driven line bends up (stiffening), more lines enter through the jump, and by 10 to 100 E_nl the spectrum is a dense band of lines up to the top, the driven line near 2.9 times its linear pitch. A faint line falling below the driven pitch around 1000 periods is not identified yet. In that run the driven mode held only 0.06% of the energy at 100 E_nl (other runs: 20 to 27%): with no loss and the target reached, nothing makes the drive keep it filled.
2828
29-Listening (step 4, `recording.ts`, `player.ts`, `marks.ts`; Jeremy: always compute ahead and play back, from any point, while the run goes on). The run computes as before (main thread, Speed); `drive.ts` records displacement, velocity and acceleration (with the drive force) at the listening point, 512 samples per driven period, each the mean over its interval, kept whole (`Recording`, Float32 chunks, 12 MB per 2000 periods). Playback (`LivePlayer`) maps one driven period to 1 / pitch s (pitch 20 to 160 Hz, default 60), resamples by a windowed sinc (Hann, 8 zero crossings, cutoff 0.9 of the lower Nyquist), renders 0.2 s chunks scheduled back to back 0.6 s ahead of the audio clock, and stops when it reaches the computed end (status says so). Level: steady (divide by a peak follower, instant attack, 1.5 s release; default) or as computed (one scale from the loudest recorded so far, tanh limit). Clicking the energy chart, spectrogram or mode map sets the start (dashed line on all three; the playhead is a solid line); changing signal, pitch or level restarts from the playhead. The spectrogram shows the signal chosen for listening (velocity by default) and is remade from the recording when it changes. Ramp rate: log slider, 5 to 2000 periods per decade (default 400); target slider to 10³ E_nl.
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.
3030
3131 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.
3232
3333 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/index.htmlmodified+2−2View file
@@ -38,7 +38,7 @@
3838 </div>
3939 <div class="row">
4040 <span class="muted label-w">Speed</span>
41- <input id="speed" type="range" min="0" max="2.5" step="0.01" value="1.3" />
41+ <input id="speed" type="range" min="0" max="2.5" step="0.01" value="1.778" />
4242 <span id="speed-value" class="num value-w"></span>
4343 </div>
4444 </div>
@@ -135,7 +135,7 @@
135135 </template>
136136 <template id="help-listen">
137137 <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 stops and says so. Raise Speed, or start earlier, to hear more.</p>
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>
139139 <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>
140140 <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>
141141 </template>
src/driven-plate/main.tsmodified+6−4View file
@@ -183,9 +183,12 @@ function draw(): void {
183183 rampValue.textContent = `${fmt(perDecade(), 2)} /decade`;
184184 pitchValue.textContent = `${pitch().toFixed(0)} Hz`;
185185 playButton.textContent = player.playing ? "Stop" : "Play";
186- listenStatus.textContent = player.playing
187- ? `playing t ${marks.play!.toFixed(0)} of ${(sim.t / sim.period).toFixed(0)} computed`
188- : player.ended || `from t ${(marks.start ?? 0).toFixed(0)} (click a time chart to choose)`;
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`;
189192 const { driven, others } = split(hist.last.modes), E = sim.E / sim.Enl;
190193 const T = sim.measuredPeriod / sim.period;
191194 readout.textContent =
@@ -419,7 +422,6 @@ function play(from: number): void {
419422 playButton.addEventListener("click", () => (player.playing ? player.stop() : play(marks.start ?? 0)));
420423 function pick(t: number): void {
421424 marks.start = Math.max(0, t);
422- player.ended = "";
423425 if (player.playing) play(marks.start);
424426 }
425427 spec.onPick = pick;
src/driven-plate/player.tsmodified+22−12View file
@@ -4,8 +4,10 @@
44 // (SIGNAL_RATE samples per period) is resampled to the audio rate by a
55 // windowed sinc (Hann, HALF zero crossings each side), its cutoff 0.9 of the
66 // lower of the two Nyquist frequencies. Chunks of CHUNK_S s are rendered and
7-// scheduled back to back, AHEAD_S ahead of the audio clock; when playback
8-// reaches the end of what has been computed it stops (the caller says so).
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.
911 // Level: "auto" divides by a peak follower (instant attack, RELEASE_S
1012 // release), so the level stays steady while the energy rises by many
1113 // decades and what changes is the timbre; "fixed" scales by the largest
@@ -19,6 +21,7 @@ const AHEAD_S = 0.6;
1921 const HALF = 8;
2022 const RELEASE_S = 1.5;
2123 const FADE_S = 0.01;
24+const RESUME_S = 1.5;
2225
2326 export type Level = "auto" | "fixed";
2427
@@ -31,7 +34,7 @@ export class LivePlayer {
3134 pitch = 60; // Hz of the driven mode
3235 kind: SignalKind = "v";
3336 level: Level = "auto";
34- ended = ""; // why playback last stopped by itself
37+ waiting = false; // at the computed end, waiting for more
3538 private t0 = 0; // run time (periods) at ctx time ctx0
3639 private ctx0 = 0;
3740 private nextCtx = 0; // ctx time of the next chunk
@@ -68,7 +71,7 @@ export class LivePlayer {
6871 void ctx.resume();
6972 this.stop();
7073 this.playing = true;
71- this.ended = "";
74+ this.waiting = false;
7275 this.t0 = this.nextT = Math.max(0, t);
7376 this.ctx0 = this.nextCtx = ctx.currentTime + 0.05;
7477 this.env = 0;
@@ -90,6 +93,7 @@ export class LivePlayer {
9093 // Run time (periods) being heard now, or null.
9194 position(): number | null {
9295 if (!this.playing || !this.ctx) return null;
96+ if (this.waiting && this.sources.length === 0) return this.nextT;
9397 const t = this.t0 + Math.max(0, this.ctx.currentTime - this.ctx0) * this.pitch;
9498 return Math.min(t, this.nextT);
9599 }
@@ -101,17 +105,21 @@ export class LivePlayer {
101105 const c = 0.9 * Math.min(1, fs / (SIGNAL_RATE * this.pitch)), K = Math.ceil(HALF / c);
102106 // Last run time with the whole kernel recorded.
103107 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+ }
104117 while (this.nextCtx < ctx.currentTime + AHEAD_S) {
105118 const want = Math.round(CHUNK_S * fs), count = Math.min(want, Math.floor((endT - this.nextT) / dT));
106119 if (count < want) {
107- // Out of computed signal: finish with what there is, faded, and stop
108- // once it has played.
120+ // Out of computed signal: play what there is, faded out, and wait.
109121 if (count > 0) this.schedule(this.render(rec, count, dT, c, K, true), fs);
110- this.ended = `reached the end of the computed run (t ${this.nextT.toFixed(0)}): raise Speed, or start earlier`;
111- const last = this.sources[this.sources.length - 1];
112- if (last) last.onended = () => (this.playing = false);
113- else this.playing = false;
114- this.nextCtx = Infinity;
122+ this.waiting = true;
115123 return;
116124 }
117125 this.schedule(this.render(rec, count, dT, c, K, false), fs);
@@ -125,7 +133,9 @@ export class LivePlayer {
125133 src.buffer = buf;
126134 src.connect(this.gain!);
127135 src.start(this.nextCtx);
128- src.onended = () => (this.sources = this.sources.filter((s) => s !== src));
136+ src.onended = () => {
137+ this.sources = this.sources.filter((s) => s !== src);
138+ };
129139 this.sources.push(src);
130140 this.nextCtx += x.length / fs;
131141 }
↑↓ move↵openescclose