fix(visualizer): ease playhead re-anchor to kill startup jitter; gate diagnostics off (P10 W1)

This commit is contained in:
daniel-c-harvey
2026-06-15 22:32:02 -04:00
parent d73e94a12f
commit 65e5e09245
2 changed files with 117 additions and 10 deletions
@@ -58,7 +58,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
private const bool Debug = true;
private const bool Debug = false;
private const string Tag = "[MixVisualizer]";
private static void DebugLog(string message)
@@ -67,6 +67,43 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92;
*/
const MAX_DPR = 2;
/**
* Playhead-correction smoothing time constant, in seconds. Governs how fast the
* rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push.
*
* The problem: the player's position reports are irregular at startup (buffering /
* playback ramp-up), so each push lands a position that doesn't match where the
* wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior
* behaviour) made that gap a visible snap every push — the startup jitter.
*
* The fix (classic netcode-style entity reconciliation): the player stays the sole
* source of truth, but instead of rendering the authoritative position directly, we
* render authoritative + a small *correction offset* that decays toward zero every
* frame. On each push we fold the re-anchor discontinuity into that offset so the
* rendered playhead is continuous across the push, then bleed the offset off over
* ~this time constant. This eases the snap into a sub-perceptible glide.
*
* Why an offset that decays to zero, not an absolute lerp toward target: a lerp
* toward the target leaves a steady-state lag proportional to velocity (the render
* always trailing real playback). Decaying the *error* to zero converges the
* rendered playhead back onto the authoritative one, so once pushes steady the
* offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and
* steady-state is unchanged as required.
*
* 0.12 s is a sensible default: long enough to dissolve the worst startup snaps
* (tens of ms of position error), short enough that the correction is imperceptible
* and the render never trails real playback by more than a few ms. Tunable.
*/
const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12;
/**
* Below this absolute correction (seconds) we snap the offset to 0 and stop easing —
* an exponential decay never mathematically reaches zero, and carrying a sub-ms
* residual forever is pointless. ~0.5 ms is well under one frame of motion at any
* real zoom, so collapsing it is invisible.
*/
const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// ── Diagnostics ──────────────────────────────────────────────────────────────────
//
// Set true to surface the init/draw/datum/playback seams to the browser console
@@ -75,7 +112,7 @@ const MAX_DPR = 2;
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
const DEBUG = true;
const DEBUG = false;
const TAG = '[MixVisualizer]';
function debugLog(...args: unknown[]): void {
@@ -495,11 +532,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
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).
* The *authoritative* playhead for this instant: the last pushed position advanced
* by wall-clock elapsed since the push while playing, or the pushed position while
* idle. The player remains the sole source of truth — this is display-only and is
* never written back (read-only contract, spec §D / §5.10). This is the target the
* rendered playhead converges onto; the shader uploads the *rendered* value (see
* renderedPlayhead) so a re-anchor at a push doesn't snap on screen.
*/
function effectivePlayhead(): number {
if (!playback.isPlaying) return playback.positionSeconds;
@@ -507,6 +545,46 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
return playback.positionSeconds + elapsedSeconds;
}
// ── Rendered-playhead reconciliation (startup-jitter fix). ───────────────────────
//
// The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset,
// where correctionOffset decays exponentially toward 0 each frame (time constant
// PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the
// authoritative target; without correction that re-anchor would teleport the
// rendered playhead. Instead we *preserve the rendered position across the push* by
// folding the discontinuity into correctionOffset (see setPlayback), then bleed it
// off — turning each snap into a brief, sub-perceptible glide.
//
// Steady-state: when pushes are regular, the authoritative target barely moves at a
// push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour
// is then identical to uploading effectivePlayhead() directly (the prior renderer).
let correctionOffset = 0;
let lastRenderWallClockMs = performance.now();
/**
* The playhead the shader actually scrolls to this frame. Equals the authoritative
* effectivePlayhead() plus a correction offset that decays to zero, so the rendered
* motion is continuous across the irregular startup pushes. Advances the decay by
* real elapsed time since the previous render, making it frame-rate-independent
* (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn
* frame — it mutates the decay state.
*/
function renderedPlayhead(): number {
const nowMs = performance.now();
const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000);
lastRenderWallClockMs = nowMs;
// Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate
// independent — the fraction retained depends only on wall-clock dt, not frame
// count. Snap tiny residuals to 0 (an exponential never reaches it).
if (correctionOffset !== 0) {
correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS);
if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0;
}
return effectivePlayhead() + correctionOffset;
}
/** Resolve the gradient stops from the live palette vars on the canvas. */
function readTheme(): ResolvedTheme {
const resolved: ResolvedTheme = {
@@ -612,7 +690,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// 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, effectivePlayhead());
gl.uniform1f(u.playheadSeconds, renderedPlayhead());
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).
@@ -682,6 +760,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// starting, not a stale count from a previous play session.
fpsFrameCount = 0;
fpsWindowStartMs = performance.now();
// Re-base the decay clock to now so the first frame's dt is one frame, not the
// (possibly long) idle gap since the last redrawOnce — otherwise a stale dt
// would collapse the offset in one step. (Offset is 0 at play-start today, so
// this is belt-and-braces, but it keeps the decay honest if that ever changes.)
lastRenderWallClockMs = performance.now();
rafId = requestAnimationFrame(frame);
}
@@ -837,12 +920,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
setPlayback(positionSeconds: number, isPlaying: boolean): void {
const wasPlaying = playback.isPlaying;
// Preserve on-screen continuity across the re-anchor. The rendered playhead
// right now is effectivePlayhead() (old anchor) + correctionOffset; capture
// it before we replace the anchor. We read effectivePlayhead() without going
// through renderedPlayhead() so we don't advance the decay clock here — the
// decay belongs to the render loop, ticked once per drawn frame.
const renderedBefore = effectivePlayhead() + correctionOffset;
// 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.
// smoothly between these ~10 Hz pushes.
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
// Fold the re-anchor discontinuity into the correction offset so the rendered
// playhead doesn't jump: choose offset such that effectivePlayhead() (new
// anchor) + offset == renderedBefore. The render loop then decays this offset
// to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the
// authoritative position. When pushes are regular the gap is ~0, so offset is
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
//
// Only smooth while continuously playing. On a play/pause edge or while idle
// we want the exact authoritative position, not a glide from a stale render:
// a resume should land on the real position, and a paused still frame must be
// truthful (read-only contract — never show a position the player isn't at).
if (isPlaying && wasPlaying) {
correctionOffset = renderedBefore - effectivePlayhead();
} else {
correctionOffset = 0;
}
if (isPlaying && !wasPlaying) {
// Transition paused/stopped → playing: start the rAF loop.
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);