From c64455f2f282054c5a4abb594a643220cdc2a70a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 14 Jun 2026 18:31:24 -0400 Subject: [PATCH] =?UTF-8?q?fix(visualizer):=20gate=20rAF=20loop=20on=20is-?= =?UTF-8?q?playing;=20one-shot=20redraws=20while=20idle=20(=C2=A7E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interop/visualizer/MixVisualizer.ts | 142 ++++++++++++++---- 1 file changed, 112 insertions(+), 30 deletions(-) diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index de0d33f..77ece8a 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -154,6 +154,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { let cssHeight = 0; let dpr = 1; + // ── ResizeObserver: one-shot redraw when the container changes while idle. ──── + // + // While the rAF loop is running (playing), syncCanvasSize() catches resizes each + // frame. While idle (paused/stopped), we use a ResizeObserver instead — it fires + // only when the element actually changes size, which is far cheaper than a 60fps + // tick. On each observation we do a single one-shot redraw. + const resizeObserver = new ResizeObserver(() => { + if (!playback.isPlaying && !disposed) { + // Loop is not running; draw one still frame reflecting the new size. + redrawOnce(); + } + // If the loop IS running, syncCanvasSize() inside frame() will catch it next + // tick — no action needed here. + }); + resizeObserver.observe(canvas); + /** * Sync the canvas backing store to its CSS size × devicePixelRatio so the draw * is crisp on HiDPI without blurring. Returns true if a resize happened. @@ -293,36 +309,84 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { return v < 0 ? 0 : v > 1 ? 1 : v; } + // ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ───────────── + // + // DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames + // are scheduled. The still slice stays correct via one-shot redraws triggered by + // the handle methods (setZoom, refreshTheme, setDatum) and by ResizeObserver. + // + // Start/stop contract: + // startLoop() — schedules the first frame if not already running. Safe to call + // redundantly; the rafId guard prevents double-loops. + // stopLoop() — cancels any pending frame. The current frame callback will see + // playback.isPlaying === false and will NOT reschedule itself, so + // this is belt-and-suspenders for the dispose() path. + // redrawOnce() — draw one still frame synchronously (no rAF scheduling). Used + // by setZoom/refreshTheme/setDatum/ResizeObserver while idle. + /** - * The animation loop. We always keep ONE rAF scheduled while not disposed so the - * canvas stays correctly sized and a single still slice is shown when paused — - * but we only redraw the moving content while playing. A backgrounded tab gets - * rAF throttled by the browser automatically (spec §E "cool idle"); on top of - * that we skip the expensive redraw when not playing, so a paused/foregrounded - * mix also stays cheap. + * Draw one still frame immediately, without scheduling a new rAF. Syncs the + * canvas size first so zoom/theme/datum/resize changes are reflected correctly + * even when the loop is not running. */ - let lastDrewWhilePaused = false; - function frame(): void { + function redrawOnce(): void { if (disposed) return; + syncCanvasSize(); + draw(); + } - const resized = syncCanvasSize(); - - if (playback.isPlaying) { - // Playback position is pushed in from Blazor each tick; redraw every frame - // so the scroll is smooth between ticks (position is interpolated upstream). - draw(); - lastDrewWhilePaused = false; - } else if (resized || !lastDrewWhilePaused) { - // Paused/stopped: draw the still slice once (and again only if the canvas - // resized). Holding the scroll on pause falls out of position being held. - draw(); - lastDrewWhilePaused = true; - } - + /** + * Start the rAF loop. No-op if already running or disposed — the rafId guard + * ensures at most one loop is live at any time. + */ + function startLoop(): void { + if (disposed || rafId !== null) return; rafId = requestAnimationFrame(frame); } - rafId = requestAnimationFrame(frame); + /** + * Stop the rAF loop. Safe to call when already stopped. + */ + function stopLoop(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + /** + * The animation loop. Runs only while playing. Each frame: + * 1. Syncs the canvas backing-store size (cheap no-op when nothing changed). + * 2. Redraws the scrolling waveform with the current playback position. + * 3. Reschedules itself — unless playback has stopped since this frame was + * queued, in which case it draws one final still frame 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 + * mix burns no rAF budget (spec §E acceptance criterion). + */ + function frame(): void { + if (disposed) { + rafId = null; + return; + } + + syncCanvasSize(); + draw(); + + if (playback.isPlaying) { + // Still playing — schedule the next frame. + rafId = requestAnimationFrame(frame); + } else { + // Playback stopped between the time this frame was queued and now. + // We already drew the final still frame above; exit the loop. + rafId = null; + } + } + + // Kick off one still frame on creation so the canvas is not blank while idle + // before the first play command arrives. + redrawOnce(); return { setDatum(samplesBase64: string, durationSeconds: number): void { @@ -337,30 +401,48 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // samplesPerSecond from the ACTUAL datum length — never assume 2048. samplesPerSecond: samples.length / durationSeconds, }; - lastDrewWhilePaused = false; // force a repaint of the new datum + // New datum changes what is drawn — refresh the still slice immediately. + // If playing, the running loop will pick it up on the next frame automatically. + if (!playback.isPlaying) redrawOnce(); }, setPlayback(positionSeconds: number, isPlaying: boolean): void { + const wasPlaying = playback.isPlaying; playback = { positionSeconds, isPlaying }; + + if (isPlaying && !wasPlaying) { + // Transition: paused/stopped → playing. Start the rAF loop. + startLoop(); + } else if (!isPlaying && wasPlaying) { + // Transition: playing → paused/stopped. The current in-flight frame + // will draw the final still position and exit the loop on its own + // (frame() checks playback.isPlaying before rescheduling). We do NOT + // call stopLoop() here — that would cancel the in-flight frame before + // it draws, leaving a stale or blank canvas. Let the frame run out. + } + // If isPlaying unchanged (position-only update), the running loop (if any) + // will redraw on the next frame automatically; no action needed. }, setZoom(seconds: number): void { // Clamp into the supported span so a stray value can't break the math. visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds)); - lastDrewWhilePaused = false; // zoom changed the still slice — repaint + // Zoom changes the still slice — redraw immediately when idle. + // If playing, the running loop will pick up the new value on the next frame. + if (!playback.isPlaying) redrawOnce(); }, refreshTheme(): void { theme = readTheme(); - lastDrewWhilePaused = false; // re-theme is visible immediately, even when paused + // Theme change is immediately visible — redraw the still slice when idle. + // If playing, the running loop will pick up the new theme on the next frame. + if (!playback.isPlaying) redrawOnce(); }, dispose(): void { disposed = true; - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } + stopLoop(); + resizeObserver.disconnect(); }, }; }