1// Frame playback over the records in the file, in simulated time slowed by a user factor: the
2// displacement movie (frames at output.displacement_rate) deforms and tints the surface; the
3// acceleration record (one frame per audio sample) tints it, normalized per frame because the
4// signal decays through many decades, and borrows the nearest displacement frame for the shape.
5import { AccelerationStore } from "./analysis/acceleration.ts";
7export interface Frame { w: Float32Array | null; field: Float32Array | null; scale: number }
9export interface FrameSource {
10 kind: "displacement" | "acceleration";
11 label: string;
12 fps: number;
13 nFrames: number;
14 frame(i: number): Frame;
15}
17export function displacementSource(d: { rate: number; times: number[]; frames: Float32Array[] }): FrameSource {
18 let peak = 0;
19 for (const f of d.frames) for (const w of f) { const a = Math.abs(w); if (a > peak) peak = a; }
20 return {
21 kind: "displacement", label: `displacement (${d.rate.toFixed(0)} fps)`, fps: d.rate, nFrames: d.frames.length,
22 frame: (i) => ({ w: d.frames[i], field: d.frames[i], scale: peak || 1 }),
23 };
24}
26export function accelerationSource(a: AccelerationStore, disp: { rate: number; frames: Float32Array[] } | null): FrameSource {
27 const peaks = new Float32Array(a.nSamples);
28 for (const b of a.blocks) for (let r = 0; r < b.count; r++) peaks[b.first + r] = b.scale[r] * 32767;
29 return {
30 kind: "acceleration", label: `acceleration (${a.fs.toFixed(0)} fps)`, fps: a.fs, nFrames: a.nSamples,
31 frame: (i) => {
32 const w = disp && disp.frames.length > 0
33 ? disp.frames[Math.max(0, Math.min(disp.frames.length - 1, Math.round((i / a.fs) * disp.rate)))] : null;
34 return { w, field: a.read(i, i + 1), scale: peaks[i] || 1 };
35 },
36 };
37}
39/** Advances simulated time by wall time over the slowdown factor and reports the nearest frame. */
40export class FramePlayer {
41 source: FrameSource | null = null;
42 slowdown = 100;
43 playing = false;
44 private simTime = 0;
45 private lastWall = 0;
46 private lastFrame = -1;
47 private raf = 0;
49 private onFrame: (i: number, time: number) => void;
50 private onStop: () => void;
52 constructor(onFrame: (i: number, time: number) => void, onStop: () => void) { this.onFrame = onFrame; this.onStop = onStop; }
54 setSource(s: FrameSource | null) { this.pause(); this.source = s; this.simTime = 0; this.lastFrame = -1; this.emit(); }
56 get duration() { return this.source ? this.source.nFrames / this.source.fps : 0; }
57 get fraction() { return this.duration > 0 ? this.simTime / this.duration : 0; }
59 play() {
60 if (!this.source || this.playing) return;
61 if (this.fraction >= 1) this.simTime = 0;
62 this.playing = true;
63 this.lastWall = performance.now();
64 const tick = (now: number) => {
65 if (!this.playing) return;
66 this.simTime += (now - this.lastWall) / 1000 / this.slowdown;
67 this.lastWall = now;
68 if (this.simTime >= this.duration) { this.simTime = this.duration; this.emit(); this.pause(); return; }
69 this.emit();
70 this.raf = requestAnimationFrame(tick);
71 };
72 this.raf = requestAnimationFrame(tick);
73 }
75 pause() { this.playing = false; cancelAnimationFrame(this.raf); this.onStop(); }
77 seek(fraction: number) { this.simTime = Math.max(0, Math.min(1, fraction)) * this.duration; this.lastFrame = -1; this.emit(); }
79 private emit() {
80 if (!this.source) return;
81 const i = Math.max(0, Math.min(this.source.nFrames - 1, Math.round(this.simTime * this.source.fps)));
82 if (i !== this.lastFrame) { this.lastFrame = i; this.onFrame(i, this.simTime); }
83 }
84}