diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index 7e8fc73..246b6d5 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -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.