// Frame playback over the records in the file, in simulated time slowed by a user factor: the // displacement movie (frames at output.displacement_rate) deforms and tints the surface; the // acceleration record (one frame per audio sample) tints it, normalized per frame because the // signal decays through many decades, and borrows the nearest displacement frame for the shape. import { AccelerationStore } from "./analysis/acceleration.ts"; export interface Frame { w: Float32Array | null; field: Float32Array | null; scale: number } export interface FrameSource { kind: "displacement" | "acceleration"; label: string; fps: number; nFrames: number; frame(i: number): Frame; } export function displacementSource(d: { rate: number; times: number[]; frames: Float32Array[] }): FrameSource { let peak = 0; for (const f of d.frames) for (const w of f) { const a = Math.abs(w); if (a > peak) peak = a; } return { kind: "displacement", label: `displacement (${d.rate.toFixed(0)} fps)`, fps: d.rate, nFrames: d.frames.length, frame: (i) => ({ w: d.frames[i], field: d.frames[i], scale: peak || 1 }), }; } export function accelerationSource(a: AccelerationStore, disp: { rate: number; frames: Float32Array[] } | null): FrameSource { const peaks = new Float32Array(a.nSamples); for (const b of a.blocks) for (let r = 0; r < b.count; r++) peaks[b.first + r] = b.scale[r] * 32767; return { kind: "acceleration", label: `acceleration (${a.fs.toFixed(0)} fps)`, fps: a.fs, nFrames: a.nSamples, frame: (i) => { const w = disp && disp.frames.length > 0 ? disp.frames[Math.max(0, Math.min(disp.frames.length - 1, Math.round((i / a.fs) * disp.rate)))] : null; return { w, field: a.read(i, i + 1), scale: peaks[i] || 1 }; }, }; } /** Advances simulated time by wall time over the slowdown factor and reports the nearest frame. */ export class FramePlayer { source: FrameSource | null = null; slowdown = 100; playing = false; private simTime = 0; private lastWall = 0; private lastFrame = -1; private raf = 0; private onFrame: (i: number, time: number) => void; private onStop: () => void; constructor(onFrame: (i: number, time: number) => void, onStop: () => void) { this.onFrame = onFrame; this.onStop = onStop; } setSource(s: FrameSource | null) { this.pause(); this.source = s; this.simTime = 0; this.lastFrame = -1; this.emit(); } get duration() { return this.source ? this.source.nFrames / this.source.fps : 0; } get fraction() { return this.duration > 0 ? this.simTime / this.duration : 0; } play() { if (!this.source || this.playing) return; if (this.fraction >= 1) this.simTime = 0; this.playing = true; this.lastWall = performance.now(); const tick = (now: number) => { if (!this.playing) return; this.simTime += (now - this.lastWall) / 1000 / this.slowdown; this.lastWall = now; if (this.simTime >= this.duration) { this.simTime = this.duration; this.emit(); this.pause(); return; } this.emit(); this.raf = requestAnimationFrame(tick); }; this.raf = requestAnimationFrame(tick); } pause() { this.playing = false; cancelAnimationFrame(this.raf); this.onStop(); } seek(fraction: number) { this.simTime = Math.max(0, Math.min(1, fraction)) * this.duration; this.lastFrame = -1; this.emit(); } private emit() { if (!this.source) return; const i = Math.max(0, Math.min(this.source.nFrames - 1, Math.round(this.simTime * this.source.fps))); if (i !== this.lastFrame) { this.lastFrame = i; this.onFrame(i, this.simTime); } } }