diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index 05cd367..7f14860 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -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) diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index 246b6d5..c8b5f6d 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -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.`);