Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdown
17 changed files+394−43
README.mdmodified+8−4View file
@@ -15,20 +15,24 @@ Requires Node 20 or later and, for the GPU time loop, a browser with WebGPU (Chr
1515
1616 The sidebar holds the preset selector, YAML import and download, the run controls, and three tabs: the YAML editor, a form with the same fields, and the set-up summary of the last run. The YAML schema is that of the Python package (the same file runs in both), and the four presets are its example configurations. Edits in the form rewrite the YAML and edits in the YAML rebuild the form; unknown fields, wrong types and out-of-range values are listed under the editor, and a run does not start while issues remain. Omitted fields take the defaults, which describe an 18 inch medium-thin B20 crash.
1717
18-Start builds the set-up and begins the run. The main area shows the cymbal as a lit height field (drag to orbit, wheel to zoom, the exaggeration slider scales the displacement), the spectrogram of the listener signal growing as chunks of audio arrive, and a playback bar. Play plays the part of the signal that exists so far from the slider position and stops where the signal ends; press again to hear newer material. The signal selector switches between the unbaffled estimate (front minus diffracted back, the default) and the baffled Rayleigh integral; the WAV download writes the selected one at 16 bits, normalized to 0.9 full scale or at 1 Pa = 0.05 full scale. Under the views, the energy table (kinetic, membrane, bending, exciters, and the nonlinearity meter max|w|/h) and the hit table (peak force, contact time, exciter velocity) update about once a second, and the log reports the set-up and the run.
18+Start builds the set-up and begins the run. The main area shows the cymbal as a lit height field (drag to orbit, wheel to zoom, the exaggeration slider scales the displacement), the spectrogram of the listener signal growing as chunks of audio arrive, and a playback bar.
19+
20+**Displacement movie.** During a run the transverse displacement of every node is captured at the frame rate in the plate toolbar (2000 frames per second of simulated time by default; 0 disables capture). Afterwards Replay plays those frames back in the plate view, slowed down by the factor on the slider (x50 by default, up to x10000), and the position slider scrubs through them. A frame is stored as one 16-bit value per node against that frame's own peak, so a 2 s run of the default 4657-node mesh at 2000 fps is about 19 MB; capture stops at 256 MB and the toolbar says so. The lowest modes of an 18 inch crash are a few hundred hertz, so x200 to x500 is about right for watching individual cycles, and x2000 or more for the contact itself.
21+
22+**Spectrogram axis.** The frequency axis is linear by default, which shows the upper partials and the noise floor of a crash as they are spaced; the scale selector switches to logarithmic, and the max-f selector zooms the axis to 20, 10, 5 or 2 kHz. Play plays the part of the signal that exists so far from the slider position and stops where the signal ends; press again to hear newer material. The signal selector switches between the unbaffled estimate (front minus diffracted back, the default) and the baffled Rayleigh integral; the WAV download writes the selected one at 16 bits, normalized to 0.9 full scale or at 1 Pa = 0.05 full scale. Under the views, the energy table (kinetic, membrane, bending, exciters, and the nonlinearity meter max|w|/h) and the hit table (peak force, contact time, exciter velocity) update about once a second, and the log reports the set-up and the run.
1923
2024 URL parameters: `?preset=<id>` selects a preset (`crash_mallet`, `crash_stick`, `hit_train`, `quick_cpu`), `backend=cpu` forces the CPU loop, `autostart=1` starts on load, `chat=1` opens the chat panel, `bridge=ws://host:port` points it at a bridge other than `ws://localhost:8765`.
2125
2226 ## Chat with Claude Code
2327
24-The Chat button opens a panel in which Claude Code talks about the app, reads its state, and changes it: it can write a configuration from a description ("a thin 20 inch ride hit with a stick near the edge, then a mallet stroke a second later"), load a preset, start, pause and stop runs, play the sound, and interpret a finished run (contact force, nonlinearity, band onsets and centroid, that is, whether the upward cascade happened). It has read access to this repository, so it can answer questions about the method from the source and from THEORY.md.
28+The Chat button opens a panel in which Claude Code talks about the app, reads its state, and changes it: it can write a configuration from a description ("a thin 20 inch ride hit with a stick near the edge, then a mallet stroke a second later"), load a preset, start, pause and stop runs, play the sound, replay the displacement movie at a slowdown you ask for, change the spectrogram axis, and interpret a finished run (contact force, nonlinearity, band onsets and centroid, that is, whether the upward cascade happened). It has read access to this repository, so it can answer questions about the method from the source and from THEORY.md.
2529
2630 A web page cannot run Claude Code, so the panel talks to a small bridge process on your machine, which uses your own Claude Code login (subscription or API key, whatever `claude` is logged in with):
2731
2832 git clone https://vault1.magland.org/proof-of-concept/cymsimweb
2933 cd cymsimweb && npm install && npm run bridge
3034
31-The bridge listens on `ws://localhost:8765` (127.0.0.1 only) and accepts connections from the deployed site and from localhost pages; it also serves the built app at `http://localhost:8765` after `npm run build`. Open the site, press Chat, and the panel connects. `node bridge/server.mjs --port 9000 --model claude-sonnet-5 --origin https://other.site` changes the port, the model (default: your Claude Code default) and the allowed page origins. Each browser connection is one Claude Code session that persists across turns; New starts a fresh one, Stop interrupts the current turn. The bridge gives Claude the read-only tools (Read, Glob, Grep, WebFetch) plus the app tools `get_app_state`, `set_yaml`, `select_preset`, `start_run`, `pause_run`, `stop_run`, `play_audio` and `set_signal`, which execute in the page; it cannot edit files or run commands. `node tests/bridge.smoke.mjs` runs one real turn against a scripted fake page (it uses your quota, so it is not part of `npm test`).
35+The bridge listens on `ws://localhost:8765` (127.0.0.1 only) and accepts connections from the deployed site and from localhost pages; it also serves the built app at `http://localhost:8765` after `npm run build`. Open the site, press Chat, and the panel connects. `node bridge/server.mjs --port 9000 --model claude-sonnet-5 --origin https://other.site` changes the port, the model (default: your Claude Code default) and the allowed page origins. Each browser connection is one Claude Code session that persists across turns; New starts a fresh one, Stop interrupts the current turn. The bridge gives Claude the read-only tools (Read, Glob, Grep, WebFetch) plus the app tools `get_app_state`, `set_yaml`, `select_preset`, `start_run`, `pause_run`, `stop_run`, `play_audio`, `set_signal`, `play_movie`, `set_movie_fps` and `set_spectrogram_axis`, which execute in the page; it cannot edit files or run commands. `node tests/bridge.smoke.mjs` runs one real turn against a scripted fake page (it uses your quota, so it is not part of `npm test`).
3236
3337 ## Speed
3438
@@ -46,7 +50,7 @@ At the default resolution (6 mm elements, about 4700 nodes, 54 sub-steps per aud
4650 | `src/model/` | seeded RNG, cymbal geometry, mesher, DKT element, shell assembly, element forces and energies, exciters, radiation, set-up |
4751 | `src/cpu/` | float64 reference time loop, also the fallback without WebGPU |
4852 | `src/gpu/` | WGSL kernels and the WebGPU driver |
49-| `src/analysis/`, `src/audio/` | incremental STFT; Web Audio playback and WAV export |
53+| `src/analysis/`, `src/audio/` | incremental STFT and band summary, the displacement-movie store and player; Web Audio playback and WAV export |
5054 | `src/ui/`, `src/run.ts`, `src/main.ts` | editor, form, plate and spectrogram views, readouts, chat panel, the run loop, wiring |
5155 | `src/chat/`, `bridge/` | WebSocket client of the bridge and the tool executor; the local Claude Code bridge (Agent SDK) |
5256 | `tests/` | node:test files, the Dawn check, and the Python fixture |
THEORY.mdmodified+8−2View file
@@ -64,6 +64,12 @@ CPU loop against Python (float64 both): in the linear run the peak force and reb
6464
6565 GPU kernels against the CPU loop (float32 against float64, under Dawn): peak force to $10^{-4}$, contact time to the step, energies to $10^{-3}$ and the pressure to $10^{-3}$ relative rms in the linear run; $10^{-3}$, $10^{-2}$ and $10^{-2}$ in a short nonlinear run.
6666
67-## 7. Limitations
67+## 7. The displacement movie
6868
69-The limitations of the model are those listed in the Python documentation. Specific to the web application: the imperfections of a seed differ from Python's; the decimation and the fractional delays are approximations described in section 4; the plate view shows $w$ scaled by a user-chosen factor and is not a measurement; and the CPU fallback is a hundred times slower than a discrete GPU. The set-up runs on the main thread and freezes the page for a second or two at full resolution.
69+The plate view reads `w` straight from the simulation's state buffer while a run is in progress, which shows the motion at whatever rate the browser repaints (a few frames per second of wall time, each an arbitrary instant of simulated time). To watch the motion itself, the run also captures frames at a prescribed rate in *simulated* time: a capture dispatch runs at the start of every sub-step whose audio-sample index is a multiple of `movieEvery = round(fs / fps)`, writing `w` of every node into a per-chunk buffer that is read back with the chunk's pressure. The CPU loop captures the same instants, and the GPU check compares the two frame by frame (relative rms below 1e-2 in float32).
70+
71+Frames are stored quantized to 16 bits against each frame's own peak, which costs 2 bytes per node per frame and keeps the relative error near 1e-4 of that frame's amplitude, far below what the colour map and the height exaggeration resolve. The default 2000 fps resolves the low modes of a crash (a few hundred hertz) with several samples per cycle; the contact itself, a few milliseconds long, needs 5000 fps or more. Replay is driven by `requestAnimationFrame` against wall time divided by the slowdown factor, so the same frames play correctly on any display: the player advances simulated time by the elapsed wall time over the factor and shows the nearest captured frame, rather than stepping one frame per repaint.
72+
73+## 8. Limitations
74+
75+The limitations of the model are those listed in the Python documentation. Specific to the web application: the imperfections of a seed differ from Python's; the decimation and the fractional delays are approximations described in section 4; the plate view shows $w$ scaled by a user-chosen factor and is not a measurement; the movie is capped at 256 MB, after which capture stops silently apart from a note in the toolbar, and its frames are quantized as described in section 7; and the CPU fallback is a hundred times slower than a discrete GPU. The set-up runs on the main thread and freezes the page for a second or two at full resolution.
bridge/server.mjsmodified+14−0View file
@@ -68,6 +68,11 @@ files: you have no write access, and the app is changed only through the tools.
6868 depending on the GPU; start_run returns immediately, so check progress with get_app_state.
6969 - play_audio: play the generated sound from a time; set_signal chooses the unbaffled or the baffled
7070 (Rayleigh) listener signal for the spectrogram and playback.
71+- play_movie: replay the captured displacement of the plate, slowed down by a factor (50 is a good
72+ start; the lowest modes of an 18 inch crash are a few hundred Hz, so x200 to x500 shows individual
73+ cycles). set_movie_fps sets how many frames per second of simulated time the next run captures
74+ (2000 by default, 0 disables it); the frames are what play_movie replays.
75+- set_spectrogram_axis: linear (the default) or log frequency axis, and the upper edge in Hz.
7176
7277 ## Guidance
7378 When the user describes a cymbal or a stroke in words, translate it into the configuration: size sets
@@ -114,6 +119,15 @@ class Session {
114119 relay("stop_run", "Stop the running simulation, keeping the audio generated so far.", {}),
115120 relay("play_audio", "Play the generated sound in the browser from a time in seconds.", { from_seconds: z.number().min(0).default(0) }),
116121 relay("set_signal", "Choose which listener signal the spectrogram and playback use.", { signal: z.enum(["unbaffled", "rayleigh"]) }),
122+ relay("play_movie", "Replay the captured displacement of the cymbal in the plate view, slowed down.", {
123+ slowdown: z.number().min(2).max(10000).default(50).describe("how many times slower than reality"),
124+ from_seconds: z.number().min(0).default(0).describe("simulated time to start from"),
125+ }),
126+ relay("set_spectrogram_axis", "Set the spectrogram's frequency axis.", {
127+ scale: z.enum(["linear", "log"]),
128+ max_frequency_hz: z.number().min(500).max(96000).optional().describe("upper edge; omit for the Nyquist frequency"),
129+ }),
130+ relay("set_movie_fps", "Set how many frames of the displacement movie are captured per second of simulated time (0 disables capture). Applies to the next run.", { fps: z.number().min(0).max(44100) }),
117131 ],
118132 });
119133 }
index.htmlmodified+10−0View file
@@ -47,16 +47,26 @@
4747 <div class="toolbar">
4848 <span>Plate</span>
4949 <label>exaggeration <input id="exag" type="range" min="0" max="3" step="0.1" value="0" /><span id="exag-text">x1</span></label>
50+ <label>movie <input id="movie-fps" type="number" min="60" max="44100" step="100" value="2000" title="frames per second of simulated time" /> fps</label>
5051 <button id="reset-view">Reset view</button>
5152 <span id="plate-info" class="muted"></span>
5253 </div>
5354 <canvas id="plate"></canvas>
55+ <div class="toolbar movie-bar">
56+ <button id="movie-play" disabled>Replay</button>
57+ <button id="movie-stop" disabled>Stop</button>
58+ <label>slowdown <input id="slowdown" type="range" min="0.3" max="4" step="0.05" value="1.7" /><span id="slowdown-text">x50</span></label>
59+ <input id="movie-pos" type="range" min="0" max="1" step="0.0001" value="0" disabled />
60+ <span id="movie-info" class="muted">no frames</span>
61+ </div>
5462 </section>
5563 <div class="splitter horizontal" data-target="plate-section" data-var="--plate-h"></div>
5664 <section id="spec-section">
5765 <div class="toolbar">
5866 <span>Spectrogram</span>
5967 <label>signal <select id="signal"><option value="unbaffled">unbaffled</option><option value="rayleigh">Rayleigh (baffled)</option></select></label>
68+ <label>scale <select id="spec-scale"><option value="linear">linear</option><option value="log">log</option></select></label>
69+ <label>max f <select id="spec-fmax"><option value="0">Nyquist</option><option value="20000">20 kHz</option><option value="10000">10 kHz</option><option value="5000">5 kHz</option><option value="2000">2 kHz</option></select></label>
6070 <span id="spec-info" class="muted"></span>
6171 </div>
6272 <canvas id="spectrogram"></canvas>
src/analysis/movie.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+// Store of the displacement movie: the frames captured during the run, quantized to 16 bits per
2+// node against a running peak, plus playback timing. A frame holds w at every node at one instant
3+// of simulated time; replaying them at a slowdown factor shows the plate moving.
4+
5+export interface MovieOptions {
6+ /** Frames per second of *simulated* time. */
7+ fps: number;
8+ /** Refuse to store more than this many bytes; capture stops when the budget is reached. */
9+ budgetBytes: number;
10+}
11+
12+export const DEFAULT_MOVIE_FPS = 2000;
13+export const DEFAULT_MOVIE_BUDGET = 256e6;
14+
15+export class Movie {
16+ readonly nodes: number; readonly fps: number; readonly budgetBytes: number; readonly fs: number;
17+ private readonly frames: Int16Array[] = [];
18+ private readonly scales: number[] = []; // metres per unit of the quantized value
19+ private readonly times: number[] = []; // simulated time of each frame [s]
20+ full = false;
21+
22+ constructor(nodes: number, fs: number, opts: MovieOptions) {
23+ this.nodes = nodes; this.fs = fs; this.fps = opts.fps; this.budgetBytes = opts.budgetBytes;
24+ }
25+
26+ get length() { return this.frames.length; }
27+ get bytes() { return this.frames.length * this.nodes * 2; }
28+ /** Simulated duration covered by the frames [s]. */
29+ get duration() { return this.times.length ? this.times[this.times.length - 1] : 0; }
30+ frameTime(i: number) { return this.times[Math.min(Math.max(i, 0), this.times.length - 1)] ?? 0; }
31+
32+ /** Store one frame (w in metres at every node) taken at audio sample ``sample``. */
33+ push(w: Float32Array, sample: number) {
34+ if (this.full || w.length !== this.nodes) return;
35+ if (this.bytes + this.nodes * 2 > this.budgetBytes) { this.full = true; return; }
36+ let peak = 0;
37+ for (let i = 0; i < w.length; i++) { const a = Math.abs(w[i]); if (a > peak) peak = a; }
38+ const scale = peak > 0 ? peak / 32767 : 1;
39+ const q = new Int16Array(this.nodes);
40+ for (let i = 0; i < w.length; i++) q[i] = Math.round(w[i] / scale);
41+ this.frames.push(q); this.scales.push(scale); this.times.push(sample / this.fs);
42+ }
43+
44+ /** Dequantized frame i, written into ``out`` (allocated when omitted). */
45+ read(i: number, out?: Float32Array): Float32Array {
46+ const dst = out && out.length === this.nodes ? out : new Float32Array(this.nodes);
47+ const k = Math.min(Math.max(i, 0), this.frames.length - 1);
48+ if (k < 0) return dst;
49+ const q = this.frames[k], s = this.scales[k];
50+ for (let n = 0; n < this.nodes; n++) dst[n] = q[n] * s;
51+ return dst;
52+ }
53+
54+ /** Index of the frame nearest a simulated time [s]. */
55+ indexAtTime(t: number): number {
56+ if (!this.times.length) return 0;
57+ let lo = 0, hi = this.times.length - 1;
58+ while (lo < hi) { const mid = (lo + hi) >> 1; if (this.times[mid] < t) lo = mid + 1; else hi = mid; }
59+ if (lo > 0 && Math.abs(this.times[lo - 1] - t) <= Math.abs(this.times[lo] - t)) lo--;
60+ return lo;
61+ }
62+}
63+
64+/** Audio samples between captured frames for a target simulated frame rate (at least 1). */
65+export function movieEvery(fs: number, fps: number): number {
66+ return Math.max(1, Math.round(fs / Math.max(fps, 1)));
67+}
68+
69+/** Drives frame playback in real time at a slowdown factor (20 = 20x slower than reality). */
70+export class MoviePlayer {
71+ private raf = 0; private last = 0; time = 0; // simulated time being shown [s]
72+ playing = false; slowdown = 50;
73+ readonly movie: Movie; private readonly onFrame: (index: number, time: number) => void;
74+
75+ constructor(movie: Movie, onFrame: (index: number, time: number) => void) { this.movie = movie; this.onFrame = onFrame; }
76+
77+ play(fromSeconds = this.time) {
78+ this.stop();
79+ this.time = fromSeconds >= this.movie.duration ? 0 : fromSeconds;
80+ this.playing = true; this.last = performance.now();
81+ const tick = () => {
82+ if (!this.playing) return;
83+ const now = performance.now();
84+ this.time += (now - this.last) / 1000 / this.slowdown;
85+ this.last = now;
86+ if (this.time >= this.movie.duration) { this.time = this.movie.duration; this.seek(this.time); this.stop(); return; }
87+ this.seek(this.time);
88+ this.raf = requestAnimationFrame(tick);
89+ };
90+ this.raf = requestAnimationFrame(tick);
91+ }
92+
93+ stop() { this.playing = false; if (this.raf) cancelAnimationFrame(this.raf); this.raf = 0; }
94+ seek(t: number) { this.time = t; this.onFrame(this.movie.indexAtTime(t), t); }
95+}
src/chat/client.tsmodified+6−0View file
@@ -10,6 +10,9 @@ export interface AppApi {
1010 stop(): string;
1111 play(fromSeconds: number): string;
1212 setSignal(signal: string): string;
13+ playMovie(factor: number, fromSeconds: number): string;
14+ setSpectrogramAxis(scale: string, maxHz?: number): string;
15+ setMovieFps(fps: number): string;
1316 }
1417
1518 export type ChatEvent =
@@ -86,6 +89,9 @@ export class ChatClient {
8689 case "stop_run": return { text: this.api.stop(), isError: false };
8790 case "play_audio": return { text: this.api.play(Number(args.from_seconds ?? 0)), isError: false };
8891 case "set_signal": return { text: this.api.setSignal(String(args.signal)), isError: false };
92+ case "play_movie": return { text: this.api.playMovie(Number(args.slowdown ?? 0), Number(args.from_seconds ?? 0)), isError: false };
93+ case "set_spectrogram_axis": return { text: this.api.setSpectrogramAxis(String(args.scale ?? "linear"), args.max_frequency_hz === undefined ? undefined : Number(args.max_frequency_hz)), isError: false };
94+ case "set_movie_fps": return { text: this.api.setMovieFps(Number(args.fps ?? 0)), isError: false };
8995 default: return { text: `unknown tool ${name}`, isError: true };
9096 }
9197 } catch (err) {
src/cpu/simulator.tsmodified+15−7View file
@@ -21,11 +21,12 @@ export class CpuSimulator implements SimulatorBackend {
2121 private readonly history: Float64Array;
2222 private pending: { j: number; front: number; back: number }[] = [];
2323 samplesDone = 0;
24+ readonly maxChunk = 8192;
25+ readonly setup: Setup; readonly movieEvery: number;
2426
25- readonly setup: Setup;
26-
27- constructor(setup: Setup) {
28- this.setup = setup;
27+ /** ``movieEvery`` > 0 captures w every that many audio samples. */
28+ constructor(setup: Setup, movieEvery = 0) {
29+ this.setup = setup; this.movieEvery = movieEvery;
2930 const N = setup.mesh.n, E = setup.mesh.e, K = setup.schedule.K, P = setup.schedule.P;
3031 this.q = new Float64Array(5 * N); this.v = new Float64Array(5 * N); this.nodeAcc = new Float64Array(N); this.vwPrev = new Float64Array(N);
3132 this.ef = new Float64Array(15 * E); this.fc = new Float64Array(N);
@@ -123,12 +124,19 @@ export class CpuSimulator implements SimulatorBackend {
123124 }
124125
125126 async runChunk(nSamples: number): Promise<ChunkResult> {
126- const m = this.setup.substeps;
127- for (let s = 0; s < nSamples * m; s++) this.step();
127+ const m = this.setup.substeps, N = this.setup.mesh.n, frames: Float32Array[] = [], frameSamples: number[] = [];
128+ for (let s = 0; s < nSamples * m; s++) {
129+ if (this.movieEvery > 0 && this.n % (m * this.movieEvery) === 0) {
130+ const w = new Float32Array(N);
131+ for (let nd = 0; nd < N; nd++) w[nd] = this.q[5 * nd + 2];
132+ frames.push(w); frameSamples.push(this.n / m);
133+ }
134+ this.step();
135+ }
128136 this.samplesDone += nSamples;
129137 const out = this.pending; this.pending = [];
130138 const firstSample = out.length ? out[0].j : this.samplesDone;
131- return { firstSample, pressureFront: Float32Array.from(out, (o) => o.front), pressureBack: Float32Array.from(out, (o) => o.back) };
139+ return { firstSample, pressureFront: Float32Array.from(out, (o) => o.front), pressureBack: Float32Array.from(out, (o) => o.back), frames, frameSamples };
132140 }
133141
134142 async diagnostics(): Promise<Diagnostics> {
src/gpu/shaders.tsmodified+16−1View file
@@ -32,7 +32,7 @@ const EF: u32 = ${ELEM_STRIDE}u; const NF: u32 = ${NODE_STRIDE}u; const HF: u32
3232 const OFF_V: u32 = 5u * N; const OFF_ACC: u32 = 10u * N; const OFF_VWP: u32 = 11u * N;
3333 const OFF_HIST: u32 = E * W; const OFF_TAPS: u32 = EF * E; const OFF_PART: u32 = ${SYNC_HEADER}u;
3434
35-struct Chunk { base: i32, size: u32 }
35+struct Chunk { base: i32, size: u32, frameBase: u32, every: u32 }
3636
3737 // ------------------------------------------------------------------ element pass
3838 @group(0) @binding(0) var<storage, read> elemF: array<f32>;
@@ -237,5 +237,20 @@ fn gatherPass(@builtin(local_invocation_id) lid: vec3<u32>) {
237237 if (idx >= 0 && u32(idx) < chunk.size) { pressure[u32(idx)] = redF[0]; pressure[chunk.size + u32(idx)] = redB[0]; }
238238 }
239239 }
240+
241+// ------------------------------------------------------------------ capture pass (displacement movie)
242+@group(3) @binding(0) var<storage, read> stateC: array<f32>;
243+@group(3) @binding(1) var<storage, read_write> movie: array<f32>;
244+@group(3) @binding(2) var<uniform> chunkC: Chunk;
245+@group(3) @binding(3) var<storage, read> syncC: array<f32>;
246+
247+@compute @workgroup_size(256)
248+fn capturePass(@builtin(global_invocation_id) gid: vec3<u32>) {
249+ let nd = gid.x;
250+ if (nd >= N) { return; }
251+ let step = bitcast<u32>(syncC[0]);
252+ let frame = (step / M) / chunkC.every - chunkC.frameBase;
253+ movie[frame * N + nd] = stateC[5u * nd + 2u];
254+}
240255 `;
241256 }
src/gpu/simulator.tsmodified+40−7View file
@@ -20,11 +20,17 @@ export class GpuSimulator implements SimulatorBackend {
2020 private readonly buffers: Record<string, GPUBuffer> = {};
2121 private readonly pipelines: Record<string, GPUComputePipeline> = {};
2222 private readonly bindGroups: Record<string, GPUBindGroup> = {};
23- private readonly chunkCapacity: number;
23+ private readonly chunkCapacity: number; readonly movieEvery: number; private readonly framesCapacity: number;
2424 private readonly stagingPressure: GPUBuffer; private readonly stagingState: GPUBuffer; private readonly stagingHits: GPUBuffer; private readonly stagingHitsU: GPUBuffer;
25+ private readonly stagingMovie: GPUBuffer | null;
2526
26- constructor(device: GPUDevice, setup: Setup, chunkCapacity = 8192) {
27- this.device = device; this.setup = setup; this.chunkCapacity = chunkCapacity;
27+ /** ``movieEvery`` > 0 captures w every that many audio samples; the chunk size is reduced so a chunk's frames stay under 64 MB. */
28+ constructor(device: GPUDevice, setup: Setup, chunkCapacity = 8192, movieEvery = 0) {
29+ this.device = device; this.setup = setup; this.movieEvery = movieEvery;
30+ const Nn = setup.mesh.n;
31+ if (movieEvery > 0) chunkCapacity = Math.max(128, Math.min(chunkCapacity, Math.floor((64e6 / (4 * Nn)) * movieEvery)));
32+ this.chunkCapacity = chunkCapacity;
33+ this.framesCapacity = movieEvery > 0 ? Math.ceil(chunkCapacity / movieEvery) + 2 : 0;
2834 const { mesh, shell: sh, schedule: sc, radiation: rad, substeps: M, dt } = setup;
2935 const N = mesh.n, E = mesh.e, K = sc.K, P = sc.P, W = DECIMATION_SPAN, D = rad.ringLength;
3036 this.G = Math.ceil(N / WORKGROUP_NODES);
@@ -82,6 +88,10 @@ export class GpuSimulator implements SimulatorBackend {
8288 const staging = (size: number) => device.createBuffer({ size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
8389 this.stagingPressure = staging(8 * chunkCapacity); this.stagingState = staging(12 * N * 4);
8490 this.stagingHits = staging(Math.max(16, HF * K * 4)); this.stagingHitsU = staging(Math.max(16, HU * K * 4));
91+ if (this.framesCapacity > 0) {
92+ mk("movie", new Float32Array(this.framesCapacity * N));
93+ this.stagingMovie = staging(this.framesCapacity * N * 4);
94+ } else this.stagingMovie = null;
8595
8696 // ---- pipelines
8797 const module = device.createShaderModule({ code: shaderSource({ N, E, K, P, M, W, D, G, dt, sigma0: setup.config.material.sigma0, nl: sh.nl }) });
@@ -91,6 +101,7 @@ export class GpuSimulator implements SimulatorBackend {
91101 const L0 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "storage"], [5, "read-only-storage"], [6, "storage"], [7, "storage"]]);
92102 const L1 = bgl([[0, "storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "read-only-storage"], [4, "storage"], [5, "storage"], [6, "storage"]]);
93103 const L2 = bgl([[0, "read-only-storage"], [1, "read-only-storage"], [2, "read-only-storage"], [3, "storage"], [4, "uniform"]]);
104+ const L3 = bgl([[0, "read-only-storage"], [1, "storage"], [2, "uniform"], [3, "read-only-storage"]]);
94105 const empty = device.createBindGroupLayout({ entries: [] });
95106 const mkPipe = (entryPoint: string, layouts: GPUBindGroupLayout[]) => device.createComputePipeline({
96107 label: entryPoint, layout: device.createPipelineLayout({ bindGroupLayouts: layouts }), compute: { module, entryPoint },
@@ -98,11 +109,13 @@ export class GpuSimulator implements SimulatorBackend {
98109 this.pipelines.element = mkPipe("elementPass", [L0]);
99110 this.pipelines.node = mkPipe("nodePass", [empty, L1]);
100111 this.pipelines.gather = mkPipe("gatherPass", [empty, empty, L2]);
112+ if (this.framesCapacity > 0) this.pipelines.capture = mkPipe("capturePass", [empty, empty, empty, L3]);
101113 const B = this.buffers;
102114 const bg = (layout: GPUBindGroupLayout, bufs: GPUBuffer[]) => device.createBindGroup({ layout, entries: bufs.map((buffer, binding) => ({ binding, resource: { buffer } })) });
103115 this.bindGroups.element = bg(L0, [B.elemF, B.elemU, B.state, B.elemForce, B.hitsF, B.hitsU, B.sync, B.rad]);
104116 this.bindGroups.node = bg(L1, [B.state, B.elemForce, B.nodeF, B.adj, B.hitsF, B.hitsU, B.sync]);
105117 this.bindGroups.gather = bg(L2, [B.sync, B.rad, B.radCoef, B.pressure, B.chunk]);
118+ if (this.framesCapacity > 0) this.bindGroups.capture = bg(L3, [B.state, B.movie, B.chunk, B.sync]);
106119 this.bindGroups.empty = device.createBindGroup({ layout: empty, entries: [] });
107120 }
108121
@@ -116,18 +129,29 @@ export class GpuSimulator implements SimulatorBackend {
116129 return j >= 0 ? j : -1;
117130 }
118131
132+ get maxChunk() { return this.chunkCapacity; }
133+
119134 async runChunk(nSamples: number): Promise<ChunkResult> {
120135 if (nSamples > this.chunkCapacity) throw new Error("chunk larger than the pressure buffer");
121- const { device, setup } = this, M = setup.substeps, E = setup.mesh.e, K = setup.schedule.K;
136+ const { device, setup } = this, M = setup.substeps, E = setup.mesh.e, K = setup.schedule.K, N = setup.mesh.n;
122137 const n0 = this.step, n1 = n0 + nSamples * M;
123138 let base = -1, count = 0;
124139 for (let n = n0; n < n1; n++) { const j = this.sampleDueAt(n); if (j >= 0) { if (base < 0) base = j; count++; } }
125- device.queue.writeBuffer(this.buffers.chunk, 0, new Int32Array([base < 0 ? 0 : base, Math.max(count, 1), 0, 0]));
140+ // frames captured in this chunk: the state at the start of sub-step n where n / M is a multiple of movieEvery
141+ const frameSamples: number[] = [];
142+ const every = this.movieEvery;
143+ if (every > 0) for (let n = n0; n < n1; n++) if (n % (M * every) === 0) frameSamples.push(n / M);
144+ const frameBase = frameSamples.length ? frameSamples[0] / every : 0;
145+ device.queue.writeBuffer(this.buffers.chunk, 0, new Int32Array([base < 0 ? 0 : base, Math.max(count, 1), frameBase, Math.max(every, 1)]));
126146 const enc = device.createCommandEncoder();
127147 enc.clearBuffer(this.buffers.pressure);
128148 const pass = enc.beginComputePass();
129149 const wgE = Math.ceil((E + K) / 64), wgN = this.G;
130150 for (let n = n0; n < n1; n++) {
151+ if (every > 0 && n % (M * every) === 0) {
152+ pass.setPipeline(this.pipelines.capture); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
153+ pass.setBindGroup(2, this.bindGroups.empty); pass.setBindGroup(3, this.bindGroups.capture); pass.dispatchWorkgroups(wgN);
154+ }
131155 pass.setPipeline(this.pipelines.element); pass.setBindGroup(0, this.bindGroups.element); pass.dispatchWorkgroups(wgE);
132156 if (this.sampleDueAt(n) >= 0) {
133157 pass.setPipeline(this.pipelines.gather); pass.setBindGroup(0, this.bindGroups.empty); pass.setBindGroup(1, this.bindGroups.empty);
@@ -137,13 +161,22 @@ export class GpuSimulator implements SimulatorBackend {
137161 }
138162 pass.end();
139163 enc.copyBufferToBuffer(this.buffers.pressure, 0, this.stagingPressure, 0, 8 * this.chunkCapacity);
164+ const nFrames = frameSamples.length;
165+ if (nFrames > 0) enc.copyBufferToBuffer(this.buffers.movie, 0, this.stagingMovie!, 0, nFrames * N * 4);
140166 device.queue.submit([enc.finish()]);
141167 this.step = n1; this.samplesDone += nSamples;
142168 await this.stagingPressure.mapAsync(GPUMapMode.READ);
143169 const all = new Float32Array(this.stagingPressure.getMappedRange().slice(0));
144170 this.stagingPressure.unmap();
171+ const frames: Float32Array[] = [];
172+ if (nFrames > 0) {
173+ await this.stagingMovie!.mapAsync(GPUMapMode.READ, 0, nFrames * N * 4);
174+ const data = new Float32Array(this.stagingMovie!.getMappedRange(0, nFrames * N * 4).slice(0));
175+ this.stagingMovie!.unmap();
176+ for (let f = 0; f < nFrames; f++) frames.push(data.slice(f * N, (f + 1) * N));
177+ }
145178 const size = Math.max(count, 1);
146- return { firstSample: base < 0 ? this.samplesDone : base, pressureFront: all.slice(0, count), pressureBack: all.slice(size, size + count) };
179+ return { firstSample: base < 0 ? this.samplesDone : base, pressureFront: all.slice(0, count), pressureBack: all.slice(size, size + count), frames, frameSamples };
147180 }
148181
149182 private async read(buffer: GPUBuffer, staging: GPUBuffer, bytes: number): Promise<ArrayBuffer> {
@@ -187,6 +220,6 @@ export class GpuSimulator implements SimulatorBackend {
187220
188221 dispose() {
189222 for (const b of Object.values(this.buffers)) b.destroy();
190- this.stagingPressure.destroy(); this.stagingState.destroy(); this.stagingHits.destroy(); this.stagingHitsU.destroy();
223+ this.stagingPressure.destroy(); this.stagingState.destroy(); this.stagingHits.destroy(); this.stagingHitsU.destroy(); this.stagingMovie?.destroy();
191224 }
192225 }
src/main.tsmodified+63−2View file
@@ -1,3 +1,4 @@
1+import { Movie, MoviePlayer, DEFAULT_MOVIE_BUDGET, movieEvery } from "./analysis/movie.ts";
12 import { Stft, bandSummary } from "./analysis/spectrogram.ts";
23 import { ChatClient, type AppApi } from "./chat/client.ts";
34 import { ChatPanel } from "./ui/chatPanel.ts";
@@ -10,6 +11,7 @@ import { GpuSimulator } from "./gpu/simulator.ts";
1011 import { DECIMATION_SPAN } from "./model/radiation.ts";
1112 import { buildSetup, type Setup } from "./model/setup.ts";
1213 import type { ChunkResult, Diagnostics, SimulatorBackend } from "./model/simulator.ts";
14+import type { FrequencyScale } from "./ui/spectrogramView.ts";
1315 import { Runner } from "./run.ts";
1416 import { YamlEditor } from "./ui/editor.ts";
1517 import { ConfigForm } from "./ui/form.ts";
@@ -140,6 +142,37 @@ function pushSpectrogram() {
140142 $("spec-info").textContent = `${stft.columns.length} columns, centroid ${c.toFixed(0)} Hz`;
141143 }
142144 signalSel.onchange = rebuildSpectrogram;
145+const specScale = $<HTMLSelectElement>("spec-scale"), specFmax = $<HTMLSelectElement>("spec-fmax");
146+specScale.onchange = () => specView.setScale(specScale.value as FrequencyScale);
147+specFmax.onchange = () => specView.setMaxFrequency(Number(specFmax.value));
148+specView.setScale(specScale.value as FrequencyScale);
149+
150+// ---- displacement movie
151+let movie: Movie | null = null;
152+let moviePlayer: MoviePlayer | null = null;
153+const frameBuf = { w: new Float32Array(0) };
154+const movieFps = $<HTMLInputElement>("movie-fps"), moviePlay = $<HTMLButtonElement>("movie-play"), movieStop = $<HTMLButtonElement>("movie-stop");
155+const slowdown = $<HTMLInputElement>("slowdown"), moviePos = $<HTMLInputElement>("movie-pos"), movieInfo = $("movie-info");
156+const slowdownFactor = () => Math.round(Math.pow(10, Number(slowdown.value)));
157+function showFrame(index: number, time: number) {
158+ if (!movie || !movie.length) return;
159+ if (frameBuf.w.length !== movie.nodes) frameBuf.w = new Float32Array(movie.nodes);
160+ plate.setDisplacement(movie.read(index, frameBuf.w));
161+ moviePos.value = String(movie.duration > 0 ? time / movie.duration : 0);
162+ movieInfo.textContent = `${(time * 1e3).toFixed(1)} ms of ${(movie.duration * 1e3).toFixed(0)} ms · frame ${index + 1}/${movie.length} · x${slowdownFactor()} slower${movie.full ? " · budget reached" : ""}`;
163+}
164+function updateMovieUi() {
165+ const have = !!movie && movie.length > 1;
166+ moviePlay.disabled = !have; moviePos.disabled = !have;
167+ movieStop.disabled = !(moviePlayer?.playing ?? false);
168+ $("slowdown-text").textContent = `x${slowdownFactor()}`;
169+ if (!have && movie) movieInfo.textContent = `${movie.length} frames`;
170+}
171+slowdown.oninput = () => { if (moviePlayer) moviePlayer.slowdown = slowdownFactor(); updateMovieUi(); if (movie?.length) showFrame(movie.indexAtTime(moviePlayer?.time ?? 0), moviePlayer?.time ?? 0); };
172+moviePlay.onclick = () => { if (!moviePlayer) return; moviePlayer.slowdown = slowdownFactor(); moviePlayer.play(); updateMovieUi(); };
173+movieStop.onclick = () => { moviePlayer?.stop(); updateMovieUi(); };
174+moviePos.oninput = () => { if (!movie || !moviePlayer) return; moviePlayer.stop(); moviePlayer.seek(Number(moviePos.value) * movie.duration); updateMovieUi(); };
175+updateMovieUi();
143176
144177 const startBtn = $<HTMLButtonElement>("start"), pauseBtn = $<HTMLButtonElement>("pause"), stopBtn = $<HTMLButtonElement>("stop");
145178 const playBtn = $<HTMLButtonElement>("play"), stopAudioBtn = $<HTMLButtonElement>("stop-audio"), wavBtn = $<HTMLButtonElement>("download-wav");
@@ -160,7 +193,14 @@ async function start() {
160193 readouts.log(`set-up done in ${setup.summary.setupSeconds.toFixed(1)} s: ${setup.summary.nodes} nodes, ${setup.summary.substeps} sub-steps per sample, ${setup.summary.nSteps} steps`);
161194 if (setup.summary.slopeWarning) readouts.log(`warning: max slope ${setup.summary.maxSlope.toFixed(2)} exceeds the shallow-shell bound ${cfg.profile.max_slope}`);
162195 backend?.dispose();
163- backend = device ? new GpuSimulator(device, setup, 8192) : new CpuSimulator(setup);
196+ const fps = Math.max(0, Number(movieFps.value) || 0);
197+ const every = fps > 0 ? movieEvery(setup.fs, fps) : 0;
198+ backend = device ? new GpuSimulator(device, setup, 8192, every) : new CpuSimulator(setup, every);
199+ moviePlayer?.stop();
200+ movie = every > 0 ? new Movie(setup.mesh.n, setup.fs, { fps, budgetBytes: DEFAULT_MOVIE_BUDGET }) : null;
201+ moviePlayer = movie ? new MoviePlayer(movie, showFrame) : null;
202+ if (movie) readouts.log(`movie: capturing every ${every} samples (${(setup.fs / every).toFixed(0)} fps of simulated time, ${((setup.nSamples / every) * setup.mesh.n * 2 / 1e6).toFixed(0)} MB at full duration)`);
203+ updateMovieUi();
164204 plate.setMesh(setup.mesh, setup.shell.z0Node, setup.shell.hE.reduce((a, b) => Math.min(a, b), Infinity), backend instanceof GpuSimulator ? backend.stateBuffer : null);
165205 $("plate-info").textContent = `${setup.mesh.n} nodes, ${plate.usesWebGPU ? "WebGPU" : "2D"} view`;
166206 front = new Float32Array(setup.nSamples); back = new Float32Array(setup.nSamples); frontier = 0;
@@ -178,9 +218,13 @@ async function start() {
178218 back.set(res.pressureBack.subarray(0, Math.min(n, setup.nSamples - res.firstSample)), res.firstSample);
179219 frontier = Math.min(setup.nSamples, res.firstSample + n);
180220 }
221+ if (movie) {
222+ for (let f = 0; f < res.frames.length; f++) movie.push(res.frames[f], res.frameSamples[f]);
223+ if (!moviePlayer?.playing) updateMovieUi();
224+ }
181225 pushSpectrogram();
182226 playBtn.disabled = frontier === 0; wavBtn.disabled = frontier === 0;
183- if (performance.now() - lastPlate > 100) {
227+ if (performance.now() - lastPlate > 100 && !moviePlayer?.playing) {
184228 lastPlate = performance.now();
185229 if (backend instanceof GpuSimulator) plate.render(); else void backend!.displacement().then((w) => plate.setDisplacement(w));
186230 }
@@ -198,6 +242,8 @@ async function start() {
198242 },
199243 onDone(stopped) {
200244 runState = stopped ? "stopped" : "done";
245+ if (movie && movie.length) { plate.useOwnState(); moviePlayer!.seek(0); }
246+ updateMovieUi();
201247 readouts.log(stopped ? "stopped" : `done: ${frontier} samples of pressure (the last ${DECIMATION_SPAN / 2} samples of the decimation window are not produced)`);
202248 startBtn.disabled = false; presetSel.disabled = false; pauseBtn.disabled = true; stopBtn.disabled = true;
203249 void backend?.diagnostics().then((d) => { lastDiagnostics = d; readouts.setDiagnostics(d, kinds); });
@@ -259,6 +305,8 @@ const appApi: AppApi = {
259305 audioAvailableSeconds: setup ? frontier / setup.fs : 0, signal: signalSel.value,
260306 },
261307 setup: setup ? formatSummary(setup.summary) : null,
308+ movie: movie ? { frames: movie.length, fps: movie.fps, simulatedSeconds: movie.duration, megabytes: movie.bytes / 1e6, budgetReached: movie.full, slowdown: slowdownFactor(), playing: moviePlayer?.playing ?? false } : null,
309+ spectrogramAxis: { scale: specScale.value, maxFrequencyHz: Number(specFmax.value) || (setup ? setup.fs / 2 : 0) },
262310 diagnostics: lastDiagnostics ? { time: lastDiagnostics.time, kinetic: lastDiagnostics.kinetic, membrane: lastDiagnostics.membrane, bending: lastDiagnostics.bending,
263311 exciters: lastDiagnostics.exciters, maxWoverH: lastDiagnostics.maxWoverH,
264312 hits: lastDiagnostics.hits.map((h, k) => ({ kind: setup?.patches[k].hit.exciter.kind, peakForceN: h.peakForce, contactMs: h.contactTime * 1e3, exciterVelocity: h.velocity })) } : null,
@@ -282,6 +330,19 @@ const appApi: AppApi = {
282330 stop() { if (!runner || (runState !== "running" && runState !== "paused")) return "no run in progress"; runner.stop(); return "stopping"; },
283331 play(from) { return playFrom(from); },
284332 setSignal(s) { if (s !== "unbaffled" && s !== "rayleigh") return "signal must be 'unbaffled' or 'rayleigh'"; signalSel.value = s; rebuildSpectrogram(); return `signal set to ${s}`; },
333+ playMovie(factor, fromSeconds) {
334+ if (!movie || movie.length < 2 || !moviePlayer) return "no displacement movie: set a movie frame rate before the run";
335+ if (factor > 0) { slowdown.value = String(Math.log10(Math.min(Math.max(factor, 2), 10000))); moviePlayer.slowdown = slowdownFactor(); }
336+ moviePlayer.play(Math.min(Math.max(fromSeconds, 0), movie.duration));
337+ updateMovieUi();
338+ return `replaying ${movie.length} frames covering ${(movie.duration * 1e3).toFixed(0)} ms of simulated time at x${slowdownFactor()} slowdown from ${fromSeconds.toFixed(4)} s`;
339+ },
340+ setSpectrogramAxis(scale, maxHz) {
341+ if (scale === "linear" || scale === "log") { specScale.value = scale; specView.setScale(scale); }
342+ if (maxHz !== undefined) { specFmax.value = String(maxHz); specView.setMaxFrequency(maxHz); }
343+ return `spectrogram axis: ${specScale.value}, up to ${Number(specFmax.value) || (setup ? setup.fs / 2 : 0)} Hz`;
344+ },
345+ setMovieFps(fps) { movieFps.value = String(Math.max(0, Math.round(fps))); return `movie frame rate set to ${movieFps.value} fps of simulated time; it applies to the next run`; },
285346 };
286347 const bridgeUrl = params.get("bridge") ?? "ws://localhost:8765";
287348 const chatEl = $("chat"), chatSplitter = $("chat-splitter"), appEl = $("app");
src/model/simulator.tsmodified+4−0View file
@@ -3,6 +3,8 @@ export interface ChunkResult {
33 firstSample: number; // audio index of pressureFront[0]
44 pressureFront: Float32Array; // baffled Rayleigh pressure [Pa]
55 pressureBack: Float32Array; // diffracted back term [Pa]; unbaffled = front - back
6+ frames: Float32Array[]; // displacement w at every node, captured every `movieEvery` audio samples
7+ frameSamples: number[]; // audio sample index of each frame
68 }
79
810 export interface HitState { peakForce: number; contactTime: number; velocity: number; height: number }
@@ -14,6 +16,8 @@ export interface Diagnostics {
1416 export interface SimulatorBackend {
1517 readonly kind: "cpu" | "gpu";
1618 readonly samplesDone: number;
19+ /** Largest chunk (audio samples) runChunk accepts. */
20+ readonly maxChunk: number;
1721 /** Advance by nSamples audio samples and return the pressure samples completed meanwhile. */
1822 runChunk(nSamples: number): Promise<ChunkResult>;
1923 diagnostics(): Promise<Diagnostics>;
src/run.tsmodified+2−2View file
@@ -23,12 +23,12 @@ export class Runner {
2323 while (!this.stopped && backend.samplesDone < setup.nSamples) {
2424 if (this.paused) await new Promise<void>((r) => { this.resume = r; });
2525 if (this.stopped) break;
26- const n = Math.min(this.chunk, setup.nSamples - backend.samplesDone);
26+ const n = Math.min(this.chunk, backend.maxChunk, setup.nSamples - backend.samplesDone);
2727 const tc = performance.now();
2828 const res = await backend.runChunk(n);
2929 const dtc = (performance.now() - tc) / 1000;
3030 // aim at about 0.4 s of wall time per chunk, in multiples of 128 samples, at most 8192
31- this.chunk = Math.max(128, Math.min(8192, Math.round((n * 0.4) / Math.max(dtc, 1e-3) / 128) * 128));
31+ this.chunk = Math.max(128, Math.min(backend.maxChunk, Math.round((n * 0.4) / Math.max(dtc, 1e-3) / 128) * 128));
3232 this.cb.onChunk(res);
3333 this.cb.onProgress(backend.samplesDone, (performance.now() - t0) / 1000);
3434 if (performance.now() - lastDiag > 1000 || backend.samplesDone >= setup.nSamples) {
src/style.cssmodified+5−0View file
@@ -88,3 +88,8 @@ canvas { flex: 1; width: 100%; height: 100%; display: block; min-height: 0; }
8888 .chat-input { border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
8989 .chat-input textarea { width: 100%; resize: vertical; background: #111318; color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px; font: inherit; }
9090 .chat-buttons { display: flex; justify-content: flex-end; gap: 6px; }
91+
92+.movie-bar { border-top: 1px solid var(--border); font-weight: normal; }
93+.movie-bar #movie-pos { flex: 1; min-width: 60px; }
94+.movie-bar input[type="number"] { width: 70px; }
95+.toolbar input[type="number"] { background: #111318; color: var(--text); border: 1px solid var(--border); border-radius: 3px; padding: 2px 4px; }
src/ui/plateView.tsmodified+20−4View file
@@ -86,6 +86,16 @@ export class PlateView {
8686
8787 resetView() { this.az = 0.6; this.el = 0.9; this.dist = 0.7; this.render(); }
8888
89+ /** Switch between reading w from the live simulation buffer and uploading frames from the CPU. */
90+ useOwnState(): void {
91+ const d = this.device;
92+ if (!d || !this.mesh || this.ownState) return;
93+ this.stateBuf = d.createBuffer({ size: 5 * this.mesh.n * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
94+ this.ownState = true;
95+ this.bindGroup = d.createBindGroup({ layout: this.pipeline!.getBindGroupLayout(0), entries: [
96+ { binding: 0, resource: { buffer: this.uniform! } }, { binding: 1, resource: { buffer: this.posBuf! } }, { binding: 2, resource: { buffer: this.stateBuf } } ] });
97+ }
98+
8999 /** Install a mesh; ``stateBuffer`` is the simulator's state buffer (5 floats per node) or null to upload w from the CPU. */
90100 setMesh(mesh: Mesh, z0: Float64Array, hRef: number, stateBuffer: GPUBuffer | null) {
91101 this.mesh = mesh; this.z0 = z0; this.hRef = hRef; this.w = new Float32Array(mesh.n);
@@ -106,11 +116,17 @@ export class PlateView {
106116 this.render();
107117 }
108118
109- /** Update w from the CPU (used with the CPU backend or before a run). */
119+ /** Fixed reference amplitude for the colour map [m]; 0 restores the thickness-based default. */
120+ colorScale = 0;
121+
122+ private uploadBuf: Float32Array<ArrayBuffer> | null = null;
123+
124+ /** Update w from the CPU (used with the CPU backend, for movie frames, or before a run). */
110125 setDisplacement(w: Float32Array) {
111126 this.w = w;
112127 if (this.device && this.ownState && this.stateBuf) {
113- const st = new Float32Array(5 * w.length);
128+ if (!this.uploadBuf || this.uploadBuf.length !== 5 * w.length) this.uploadBuf = new Float32Array(new ArrayBuffer(5 * w.length * 4));
129+ const st = this.uploadBuf;
114130 for (let n = 0; n < w.length; n++) st[5 * n + 2] = w[n];
115131 this.device.queue.writeBuffer(this.stateBuf, 0, st);
116132 }
@@ -133,7 +149,7 @@ export class PlateView {
133149 const zMid = 0.5 * (zMin + zMax), R = this.dist;
134150 const eye = [R * Math.cos(this.el) * Math.cos(this.az), R * Math.cos(this.el) * Math.sin(this.az), R * Math.sin(this.el)];
135151 const mvp = mul(perspective(0.6, W / H, 0.02, 20), lookAt(eye, [0, 0, 0], [0, 0, 1]));
136- const u = new Float32Array(20); u.set(mvp); u[16] = this.exaggeration; u[17] = 3 * this.hRef; u[18] = zMid;
152+ const u = new Float32Array(20); u.set(mvp); u[16] = this.exaggeration; u[17] = this.colorScale > 0 ? this.colorScale : 3 * this.hRef; u[18] = zMid;
137153 d.queue.writeBuffer(this.uniform!, 0, u);
138154 const enc = d.createCommandEncoder();
139155 const pass = enc.beginRenderPass({
@@ -152,7 +168,7 @@ export class PlateView {
152168 const cw = cv.clientWidth, ch = cv.clientHeight;
153169 ctx.fillStyle = "#14161a"; ctx.fillRect(0, 0, cw, ch);
154170 let R = 0; for (let n = 0; n < mesh.n; n++) R = Math.max(R, Math.hypot(mesh.xy[2 * n], mesh.xy[2 * n + 1]));
155- const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = 3 * this.hRef;
171+ const s = (0.46 * Math.min(cw, ch)) / R, cx = cw / 2, cy = ch / 2, scale = this.colorScale > 0 ? this.colorScale : 3 * this.hRef;
156172 for (let e = 0; e < mesh.e; e++) {
157173 const a = mesh.tri[3 * e], b = mesh.tri[3 * e + 1], c = mesh.tri[3 * e + 2];
158174 const t = Math.max(-1, Math.min(1, (w[a] + w[b] + w[c]) / (3 * scale)));
src/ui/spectrogramView.tsmodified+53−13View file
@@ -3,6 +3,7 @@
33 import type { Stft } from "../analysis/spectrogram.ts";
44
55 const ROWS = 256, DB_RANGE = 80, F_MIN = 40;
6+export type FrequencyScale = "linear" | "log";
67 const STOPS = [[0.0, 13, 8, 60], [0.25, 40, 40, 130], [0.5, 30, 120, 140], [0.75, 90, 200, 90], [1.0, 250, 240, 80]];
78
89 function color(t: number): [number, number, number] {
@@ -23,20 +24,50 @@ export class SpectrogramView {
2324 private duration = 1; private drawn = 0; private maxPower = 0;
2425 private rowLo: Int32Array = new Int32Array(0); private rowHi: Int32Array = new Int32Array(0);
2526 cursorTime: number | null = null; frontierTime = 0;
27+ private scale: FrequencyScale = "linear";
28+ private fMaxShown = 0; // 0 = up to Nyquist
2629
2730 constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; }
2831
32+ /** Frequency axis; changing it redraws the stored columns. */
33+ setScale(scale: FrequencyScale) {
34+ if (scale === this.scale) return;
35+ this.scale = scale;
36+ if (this.stft) { this.buildRows(); this.drawn = 0; this.update(); } else this.draw();
37+ }
38+ get frequencyScale() { return this.scale; }
39+
40+ /** Upper edge of the axis in Hz; 0 restores the Nyquist frequency. */
41+ setMaxFrequency(f: number) {
42+ this.fMaxShown = f;
43+ if (this.stft) { this.buildRows(); this.drawn = 0; this.update(); } else this.draw();
44+ }
45+
46+ private get fMax() { return this.fMaxShown > 0 ? this.fMaxShown : this.stft ? this.stft.fs / 2 : 22050; }
47+ private get fMin() { return this.scale === "log" ? F_MIN : 0; }
48+
49+ /** Frequency at the top edge of row r (r = 0 is the top of the image). */
50+ private rowFrequency(r: number): number {
51+ const u = 1 - r / ROWS;
52+ return this.scale === "log" ? this.fMin * Math.pow(this.fMax / this.fMin, u) : this.fMax * u;
53+ }
54+
55+ private buildRows() {
56+ const st = this.stft!;
57+ this.rowLo = new Int32Array(ROWS); this.rowHi = new Int32Array(ROWS);
58+ const df = st.binFrequency(1);
59+ for (let r = 0; r < ROWS; r++) {
60+ const fHi = this.rowFrequency(r), fLo = this.rowFrequency(r + 1);
61+ this.rowLo[r] = Math.max(0, Math.floor(fLo / df));
62+ this.rowHi[r] = Math.max(this.rowLo[r] + 1, Math.ceil(fHi / df));
63+ }
64+ }
65+
2966 reset(stft: Stft, duration: number) {
3067 this.stft = stft; this.duration = duration; this.drawn = 0; this.maxPower = 0; this.frontierTime = 0; this.cursorTime = null;
3168 this.off.width = Math.max(1, Math.ceil((duration * stft.fs) / stft.hop)); this.off.height = ROWS;
3269 this.off.getContext("2d")!.fillStyle = "#0d0838"; this.off.getContext("2d")!.fillRect(0, 0, this.off.width, ROWS);
33- const fMax = stft.fs / 2;
34- this.rowLo = new Int32Array(ROWS); this.rowHi = new Int32Array(ROWS);
35- for (let r = 0; r < ROWS; r++) {
36- const fHi = F_MIN * Math.pow(fMax / F_MIN, 1 - r / ROWS), fLo = F_MIN * Math.pow(fMax / F_MIN, 1 - (r + 1) / ROWS);
37- this.rowLo[r] = Math.max(0, Math.floor(fLo / stft.binFrequency(1)));
38- this.rowHi[r] = Math.max(this.rowLo[r] + 1, Math.ceil(fHi / stft.binFrequency(1)));
39- }
70+ this.buildRows();
4071 this.draw();
4172 }
4273
@@ -75,12 +106,21 @@ export class SpectrogramView {
75106 const left = 44, bottom = 18, pw = w - left - 6, ph = h - bottom - 4;
76107 if (this.stft) ctx.drawImage(this.off, 0, 0, this.off.width, ROWS, left, 4, pw, ph);
77108 ctx.fillStyle = "#c8c8c8"; ctx.font = "10px system-ui, sans-serif"; ctx.textAlign = "right";
78- const fMax = this.stft ? this.stft.fs / 2 : 22050;
79- for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
80- if (f < F_MIN || f > fMax) continue;
81- const y = 4 + ph * (1 - Math.log(f / F_MIN) / Math.log(fMax / F_MIN));
82- ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), left - 4, y + 3);
83- ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(left, y, pw, 1); ctx.fillStyle = "#c8c8c8";
109+ const fMax = this.fMax;
110+ const ticks = this.scale === "log"
111+ ? [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
112+ : (() => {
113+ const step = [500, 1000, 2000, 2500, 5000, 10000].find((s) => fMax / s <= 11) ?? 10000;
114+ const out: number[] = [];
115+ for (let f = 0; f <= fMax + 1e-6; f += step) out.push(f);
116+ return out;
117+ })();
118+ for (const f of ticks) {
119+ if (f > fMax || (this.scale === "log" && f < F_MIN)) continue;
120+ const u = this.scale === "log" ? Math.log(f / this.fMin) / Math.log(fMax / this.fMin) : f / fMax;
121+ const y = 4 + ph * (1 - u);
122+ ctx.fillText(f === 0 ? "0" : f >= 1000 ? `${f / 1000}k` : String(f), left - 4, y + 3);
123+ if (f > 0) { ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(left, y, pw, 1); ctx.fillStyle = "#c8c8c8"; }
84124 }
85125 ctx.textAlign = "center";
86126 const tick = this.duration > 4 ? 1 : this.duration > 1.5 ? 0.5 : 0.1;
tests/analysis.test.tsmodified+26−0View file
@@ -43,3 +43,29 @@ test("band summary reports staggered onsets for a chirp", async () => {
4343 assert.ok(b250.onsetMs < b2k.onsetMs, `${b250.onsetMs} vs ${b2k.onsetMs}`);
4444 assert.ok(s.centroidHz.length > 3 && s.centroidHz[0].hz < s.centroidHz[s.centroidHz.length - 1].hz);
4545 });
46+
47+test("movie store quantizes, times and seeks frames", async () => {
48+ const { Movie, movieEvery } = await import("../src/analysis/movie.ts");
49+ const fs = 44100, N = 50;
50+ assert.equal(movieEvery(fs, 2000), 22);
51+ const m = new Movie(N, fs, { fps: 2000, budgetBytes: 1e6 });
52+ for (let f = 0; f < 10; f++) {
53+ const w = new Float32Array(N);
54+ for (let n = 0; n < N; n++) w[n] = 1e-3 * Math.sin(n + f);
55+ m.push(w, f * 22);
56+ }
57+ assert.equal(m.length, 10);
58+ assert.equal(m.bytes, 10 * N * 2);
59+ assert.ok(Math.abs(m.duration - (9 * 22) / fs) < 1e-12);
60+ // dequantization is accurate to about 1e-4 of the frame's peak
61+ const back = m.read(3);
62+ for (let n = 0; n < N; n++) assert.ok(Math.abs(back[n] - 1e-3 * Math.sin(n + 3)) < 1e-3 * 1e-4);
63+ assert.equal(m.indexAtTime(0), 0);
64+ assert.equal(m.indexAtTime((3 * 22) / fs), 3);
65+ assert.equal(m.indexAtTime(10), 9);
66+ // the budget stops capture rather than growing without bound
67+ const small = new Movie(N, fs, { fps: 2000, budgetBytes: 3 * N * 2 });
68+ for (let f = 0; f < 10; f++) small.push(new Float32Array(N), f * 22);
69+ assert.equal(small.length, 3);
70+ assert.ok(small.full);
71+});
tests/gpu.check.tsmodified+9−1View file
@@ -25,16 +25,24 @@ async function test(name: string, fn: () => Promise<void>) {
2525 async function runBoth(cfg: any, nSamples: number, chunk: number) {
2626 const setup = await buildSetup(cfg);
2727 const device = await getDevice();
28- const cpu = new CpuSimulator(setup), gpu = new GpuSimulator(device, setup, 4096);
28+ const cpu = new CpuSimulator(setup, 7), gpu = new GpuSimulator(device, setup, 4096, 7);
2929 const pc: number[] = [], pg: number[] = [];
30+ let framesChecked = 0;
3031 for (let done = 0; done < nSamples; done += chunk) {
3132 const n = Math.min(chunk, nSamples - done);
3233 const rc = await cpu.runChunk(n), rg = await gpu.runChunk(n);
3334 assert.equal(rg.firstSample, rc.firstSample); assert.equal(rg.pressureFront.length, rc.pressureFront.length);
35+ assert.deepEqual(rg.frameSamples, rc.frameSamples);
36+ for (let f = 0; f < rc.frames.length; f++) {
37+ let num = 0, den = 0;
38+ for (let i = 0; i < rc.frames[f].length; i++) { num += (rc.frames[f][i] - rg.frames[f][i]) ** 2; den += rc.frames[f][i] ** 2; }
39+ if (den > 0) { assert.ok(Math.sqrt(num / den) < 1e-2, `frame ${f} rel error ${Math.sqrt(num / den)}`); framesChecked++; }
40+ }
3441 for (let i = 0; i < rc.pressureFront.length; i++) { pc.push(rc.pressureFront[i] - rc.pressureBack[i]); pg.push(rg.pressureFront[i] - rg.pressureBack[i]); }
3542 }
3643 const dc = await cpu.diagnostics(), dg = await gpu.diagnostics();
3744 gpu.dispose();
45+ assert.ok(framesChecked > 10, `only ${framesChecked} movie frames compared`);
3846 return { setup, pc, pg, dc, dg };
3947 }
4048