fix(visualizer): interpolate Mix playhead on wall clock so ribbon scrolls at 60 FPS, not 10 Hz push cadence

This commit is contained in:
daniel-c-harvey
2026-06-15 22:16:45 -04:00
parent ad8cb7dbc0
commit df4381b4d8
@@ -171,10 +171,23 @@ interface Datum {
}
interface Playback {
/** Current playback head in seconds. */
/**
* Last playback head pushed from Blazor, in seconds. This is the *authoritative*
* position the player last reported — it updates only on the ~10 Hz setPlayback
* push, NOT every frame. The per-frame scroll uses the interpolated
* effectivePlayhead (see draw()), anchored on this value.
*/
positionSeconds: number;
/** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
isPlaying: boolean;
/**
* performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
* advances the playhead by wall-clock elapsed since this anchor so the ribbon
* scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes,
* instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on
* every push, so each push is a small correction rather than a hard reset.
*/
pushWallClockMs: number;
}
export interface MixVisualizerHandle {
@@ -478,9 +491,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// ── Mutable state, fed by the component through the handle. ──────────────────
let datum: Datum | null = null;
let playback: Playback = { positionSeconds: 0, isPlaying: false };
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
/**
* The playhead the shader actually scrolls to this frame. While playing it is the
* last pushed position advanced by wall-clock elapsed since the push (computed in
* draw()); while idle it equals the last pushed position. The player remains the
* sole source of truth — this is display-only smoothing between pushes, never
* written back (read-only contract, spec §D / §5.10).
*/
function effectivePlayhead(): number {
if (!playback.isPlaying) return playback.positionSeconds;
const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000;
return playback.positionSeconds + elapsedSeconds;
}
/** Resolve the gradient stops from the live palette vars on the canvas. */
function readTheme(): ResolvedTheme {
const resolved: ResolvedTheme = {
@@ -499,6 +525,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let disposed = false;
const startTimeMs = performance.now();
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
// the two failure modes: a rate near the display refresh (~60) with the playhead
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
// to the playback pushes instead of free-running. Reset when the loop (re)starts.
let fpsFrameCount = 0;
let fpsWindowStartMs = 0;
// One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
// first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
// backing store here means the canvas had no layout box when the first draw ran —
@@ -574,9 +608,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
gl.useProgram(program);
gl.bindVertexArray(vao);
// Per-frame uniforms.
// Per-frame uniforms. The playhead is the wall-clock-interpolated value, not
// the raw last-pushed position — that is what makes the scroll advance every
// animation frame instead of stepping at Blazor's ~10 Hz push cadence.
gl.uniform2f(u.resolution, canvas.width, canvas.height);
gl.uniform1f(u.playheadSeconds, playback.positionSeconds);
gl.uniform1f(u.playheadSeconds, effectivePlayhead());
gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000);
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
// separate dirty-tracking needed for scalars/vec3s).
@@ -624,9 +660,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
// by the ResizeObserver. The shader clock (uTimeSeconds) advances every frame so
// motion is smooth at 60 FPS between Blazor's ~10 Hz playback ticks, not stepping
// at that cadence (spec §2e / §5.4).
// by the ResizeObserver.
//
// Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating
// the playhead on the wall clock — each frame uploads effectivePlayhead(), which
// advances the last pushed position by real time elapsed since the push. (The
// separate uTimeSeconds monotonic clock is reserved for Wave 3's field/blob motion
// and is unused by this parity shader; it is NOT what drives the scroll here.)
/** Draw one still frame immediately, without scheduling a new rAF. */
function redrawOnce(): void {
@@ -637,6 +678,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
/** Start the rAF loop. No-op if already running or disposed (rafId guard). */
function startLoop(): void {
if (disposed || rafId !== null) return;
// Reset the FPS window so the first measured second reflects the run we're
// starting, not a stale count from a previous play session.
fpsFrameCount = 0;
fpsWindowStartMs = performance.now();
rafId = requestAnimationFrame(frame);
}
@@ -650,9 +695,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
/**
* The animation loop. Runs only while playing. Each frame draws the scrolling
* waveform at the current playback position + shader clock, then reschedules
* itself — unless playback stopped since this frame was queued, in which case it
* draws one final still frame (already done above) and exits the loop.
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
* stopped since this frame was queued, in which case it draws one final still
* frame (already done above) and exits the loop.
*
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
* that the loop does not run at all when paused, so a foregrounded-but-paused
@@ -664,6 +710,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
return;
}
draw();
// FPS tally: count this callback, and once per elapsed second emit the rate.
// performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log
// fires at most once/sec, so this adds no meaningful per-frame cost.
if (DEBUG) {
fpsFrameCount++;
const nowMs = performance.now();
const windowMs = nowMs - fpsWindowStartMs;
if (windowMs >= 1000) {
const fps = (fpsFrameCount * 1000) / windowMs;
debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`);
fpsFrameCount = 0;
fpsWindowStartMs = nowMs;
}
}
if (playback.isPlaying) {
rafId = requestAnimationFrame(frame);
} else {
@@ -775,7 +837,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
setPlayback(positionSeconds: number, isPlaying: boolean): void {
const wasPlaying = playback.isPlaying;
playback = { positionSeconds, isPlaying };
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
// forward from here each frame (effectivePlayhead), so the scroll advances
// smoothly between these ~10 Hz pushes. Each push re-anchors, turning any
// drift since the last push into a small correction rather than a snap.
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
if (isPlaying && !wasPlaying) {
// Transition paused/stopped → playing: start the rAF loop.