// Store of the displacement movie: the frames captured during the run, quantized to 16 bits per // node against a running peak, plus playback timing. A frame holds w at every node at one instant // of simulated time; replaying them at a slowdown factor shows the plate moving. export interface MovieOptions { /** Frames per second of *simulated* time. */ fps: number; /** Refuse to store more than this many bytes; capture stops when the budget is reached. */ budgetBytes: number; } export const DEFAULT_MOVIE_FPS = 2000; export const DEFAULT_MOVIE_BUDGET = 256e6; export class Movie { readonly nodes: number; readonly fps: number; readonly budgetBytes: number; readonly fs: number; private readonly frames: Int16Array[] = []; private readonly scales: number[] = []; // metres per unit of the quantized value private readonly times: number[] = []; // simulated time of each frame [s] full = false; constructor(nodes: number, fs: number, opts: MovieOptions) { this.nodes = nodes; this.fs = fs; this.fps = opts.fps; this.budgetBytes = opts.budgetBytes; } get length() { return this.frames.length; } get bytes() { return this.frames.length * this.nodes * 2; } /** Simulated duration covered by the frames [s]. */ get duration() { return this.times.length ? this.times[this.times.length - 1] : 0; } frameTime(i: number) { return this.times[Math.min(Math.max(i, 0), this.times.length - 1)] ?? 0; } /** Store one frame (w in metres at every node) taken at audio sample ``sample``. */ push(w: Float32Array, sample: number) { if (this.full || w.length !== this.nodes) return; if (this.bytes + this.nodes * 2 > this.budgetBytes) { this.full = true; return; } let peak = 0; for (let i = 0; i < w.length; i++) { const a = Math.abs(w[i]); if (a > peak) peak = a; } const scale = peak > 0 ? peak / 32767 : 1; const q = new Int16Array(this.nodes); for (let i = 0; i < w.length; i++) q[i] = Math.round(w[i] / scale); this.frames.push(q); this.scales.push(scale); this.times.push(sample / this.fs); } /** Dequantized frame i, written into ``out`` (allocated when omitted). */ read(i: number, out?: Float32Array): Float32Array { const dst = out && out.length === this.nodes ? out : new Float32Array(this.nodes); const k = Math.min(Math.max(i, 0), this.frames.length - 1); if (k < 0) return dst; const q = this.frames[k], s = this.scales[k]; for (let n = 0; n < this.nodes; n++) dst[n] = q[n] * s; return dst; } /** Index of the frame nearest a simulated time [s]. */ indexAtTime(t: number): number { if (!this.times.length) return 0; let lo = 0, hi = this.times.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.times[mid] < t) lo = mid + 1; else hi = mid; } if (lo > 0 && Math.abs(this.times[lo - 1] - t) <= Math.abs(this.times[lo] - t)) lo--; return lo; } } /** Audio samples between captured frames for a target simulated frame rate (at least 1). */ export function movieEvery(fs: number, fps: number): number { return Math.max(1, Math.round(fs / Math.max(fps, 1))); } /** Drives frame playback in real time at a slowdown factor (20 = 20x slower than reality). */ export class MoviePlayer { private raf = 0; private last = 0; time = 0; // simulated time being shown [s] playing = false; slowdown = 50; readonly movie: Movie; private readonly onFrame: (index: number, time: number) => void; constructor(movie: Movie, onFrame: (index: number, time: number) => void) { this.movie = movie; this.onFrame = onFrame; } play(fromSeconds = this.time) { this.stop(); this.time = fromSeconds >= this.movie.duration ? 0 : fromSeconds; this.playing = true; this.last = performance.now(); const tick = () => { if (!this.playing) return; const now = performance.now(); this.time += (now - this.last) / 1000 / this.slowdown; this.last = now; if (this.time >= this.movie.duration) { this.time = this.movie.duration; this.seek(this.time); this.stop(); return; } this.seek(this.time); this.raf = requestAnimationFrame(tick); }; this.raf = requestAnimationFrame(tick); } stop() { this.playing = false; if (this.raf) cancelAnimationFrame(this.raf); this.raf = 0; } seek(t: number) { this.time = t; this.onFrame(this.movie.indexAtTime(t), t); } }