/** * MixVisualizer — the scrolling Mix waveform background (Phase 9, 8.K Wave 2). * * What this renders: a *windowed* slice of a mix's loudness profile, scrolling * bottom-to-top, coupled to playback position. New audio enters at the bottom, * already-played audio exits off the top, and the "now" playhead sits at a fixed * line (vertical centre by default). This is a read-only, ambient lava-lamp * background — there is no seek, no click handling, no write-back to playback. * * Rendering tech: HTML5 Canvas 2D. This is the industry-standard, well-documented * choice for a single flowing gradient waveform: createLinearGradient gives us the * theme gradient directly, and layered translucent draws give the glassy look * without any exotic tricks. Canvas 2D holds 60fps comfortably here because each * frame draws one filled path of a few hundred points, not a per-pixel shader. * (If this ever fails the 60fps budget at the glassy treatment, the textbook next * step is WebGL — but we are nowhere near needing it, so we stay on Canvas 2D.) * * The Blazor component owns the canvas element and the inputs (datum, playback, * zoom, theme); this module owns the requestAnimationFrame loop and all the * drawing/scroll/zoom math. The component drives it through the small handle * returned by `create`. */ // ── Tuning anchors (see spec §B). These are the load-bearing constants. ────────── /** * Hard anchor: at maximum zoom the window shows exactly one quarter note at * 180 BPM = 60 / 180 s = 0.333 s of audio, top to bottom. This is a fixed * requirement, not a tunable. */ export const MIN_VISIBLE_SECONDS = 60 / 180; // 0.3333… s — quarter note @ 180 BPM /** Slow end of the zoom range — how much of the mix is visible at minimum zoom. Tunable. */ export const MAX_VISIBLE_SECONDS = 30; /** Default opening window when a mix is first opened. Tunable. */ export const DEFAULT_VISIBLE_SECONDS = 10; /** * Where the "now" line sits within the window, as a fraction from the top. * 0.5 = vertical centre (default): a short lead-in below, a short trail-out above. * Tunable. */ const NOW_ANCHOR_FROM_TOP = 0.5; /** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */ const RIBBON_OPACITY = 0.22; // ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─ // // We do NOT take colours from Blazor: canvas createLinearGradient stop colours must be concrete // CSS colour strings (it does not resolve `var(--…)`). Instead the module reads the computed // `--mud-palette-*` custom properties straight off the canvas element, which inherits them from the // page. The bespoke light/dark themes ("Charleston in the Day" / "Lowcountry Summer Nights") swap // those vars when dark mode toggles, so re-reading them re-themes the gradient with no reload. The // component just calls `refreshTheme()` after a dark-mode change. interface ResolvedTheme { /** Colour at the "now" line (brightest). Concrete CSS colour. */ accent: string; /** Colour at the window edges (dimmer). Concrete CSS colour. */ edge: string; } /** Read a CSS custom property off an element, falling back if it is empty/undefined. */ function readVar(el: Element, name: string, fallback: string): string { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v.length > 0 ? v : fallback; } // ── Datum: the pre-downloaded loudness profile (spec §F). ──────────────────────── interface Datum { /** Loudness samples, each already normalized to [0, 1]. */ samples: Float32Array; /** Total mix duration in seconds — needed to map time <-> sample index. */ durationSeconds: number; /** * samplesPerSecond = samples.length / durationSeconds. Wave 1 made the sample * count duration-derived (~333/s), so this is NOT a fixed 2048/duration — we * always compute it from the actual datum length. */ samplesPerSecond: number; } interface Playback { /** Current playback head in seconds. */ positionSeconds: number; /** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */ isPlaying: boolean; } export interface MixVisualizerHandle { setDatum(samplesBase64: string, durationSeconds: number): void; setPlayback(positionSeconds: number, isPlaying: boolean): void; setZoom(visibleSeconds: number): void; /** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */ refreshTheme(): void; dispose(): void; } /** * Decode the base64 loudness datum (bytes [0,255]) into normalized [0,1] floats. * Done once per datum, off the animation path. */ function decodeSamples(base64: string): Float32Array { const binary = atob(base64); const out = new Float32Array(binary.length); for (let i = 0; i < binary.length; i++) { out[i] = binary.charCodeAt(i) / 255; // [0,255] -> [0,1] } return out; } export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { const maybeCtx = canvas.getContext('2d'); if (!maybeCtx) { // No 2D context (extremely old/headless engine): hand back a no-op handle so // the component still functions as a plain backdrop. return { setDatum() {}, setPlayback() {}, setZoom() {}, refreshTheme() {}, dispose() {}, }; } // Non-null binding so the closures below (draw/frame) keep the narrowing. const ctx: CanvasRenderingContext2D = maybeCtx; // ── Mutable state, fed by the component through the handle. ────────────────── let datum: Datum | null = null; let playback: Playback = { positionSeconds: 0, isPlaying: false }; let visibleSeconds = DEFAULT_VISIBLE_SECONDS; /** Resolve the gradient stops from the live palette vars on the canvas. */ function readTheme(): ResolvedTheme { return { // Brightest stop at the "now" line — the bespoke themes' primary accent. accent: readVar(canvas, '--mud-palette-primary', '#b08d57'), // Dim stop at the edges — the surface/background colour so the ribbon fades into the page. edge: readVar(canvas, '--mud-palette-surface', '#1a1a1a'), }; } let theme: ResolvedTheme = readTheme(); let rafId: number | null = null; let disposed = false; // Backing-store size in device pixels, tracked so we only resize the canvas // (which clears it) when the CSS box actually changed. let cssWidth = 0; 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. */ function syncCanvasSize(): boolean { const rect = canvas.getBoundingClientRect(); const nextDpr = window.devicePixelRatio || 1; // Cap DPR at 2: beyond that the extra pixels cost frame time for no visible // gain on a soft glassy backdrop (graceful-degrade lever, spec §E). const effectiveDpr = Math.min(nextDpr, 2); if (rect.width === cssWidth && rect.height === cssHeight && effectiveDpr === dpr) { return false; } cssWidth = rect.width; cssHeight = rect.height; dpr = effectiveDpr; canvas.width = Math.max(1, Math.round(cssWidth * dpr)); canvas.height = Math.max(1, Math.round(cssHeight * dpr)); return true; } /** * THE SCROLL + ZOOM MATH (spec §A, §B). Read this top to bottom to follow how * a quarter-note-@-180-BPM becomes 0.333 s becomes N samples becomes pixels. * * Coordinate model: * - The canvas is `cssHeight` px tall (we draw in CSS px; the ctx is scaled by * dpr so 1 unit == 1 CSS px). * - The "now" line is a fixed screen Y: nowY = cssHeight * NOW_ANCHOR_FROM_TOP. * - Audio flows UP: time increases downward in the data, but newer audio is * drawn lower and scrolls up past the now line. So: * * audio BELOW the now line (screen Y > nowY) is the lead-in (not yet played) * * audio ABOVE the now line (screen Y < nowY) is the trail-out (just played) * * Zoom -> time-span -> pixels: * - `visibleSeconds` is the entire window's time span, top to bottom. At max * zoom this is MIN_VISIBLE_SECONDS (0.333 s); at min zoom MAX_VISIBLE_SECONDS. * - pixelsPerSecond = cssHeight / visibleSeconds. Smaller visibleSeconds => * more px per second => the same audio sweeps the window faster at a fixed * playback rate. That IS the Guitar-Hero coupling: apparent scroll speed * falls straight out of the zoom, with no separate speed control. * * Time at a given screen Y: * - At nowY the time is playback.positionSeconds. * - Moving DOWN by 1 px adds (1 / pixelsPerSecond) seconds (future audio). * - So: timeAt(y) = now + (y - nowY) / pixelsPerSecond * * Sample at a given time (spec §F mapping, BucketCount-driven, never fixed-2048): * - sampleIndex = round(time * samplesPerSecond), where * samplesPerSecond = datum.samples.length / datum.durationSeconds. * - Out-of-range indices (before 0 or past the end) draw as zero amplitude, * which is what gives the "scrolls in from empty / out to empty" behaviour * at the very start and end of the mix (spec §A) with no special-casing. */ function draw(): void { const w = cssWidth; const h = cssHeight; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS px, crisp on HiDPI ctx.clearRect(0, 0, w, h); if (!datum || h <= 0 || w <= 0) return; const now = playback.positionSeconds; const nowY = h * NOW_ANCHOR_FROM_TOP; const pixelsPerSecond = h / visibleSeconds; const samplesPerSecond = datum.samplesPerSecond; const sampleCount = datum.samples.length; // We draw one screen row per pixel of height (a few hundred points) — smooth // at every zoom with no stair-stepping, cheap enough for 60fps. const centreX = w / 2; // Max half-width of the ribbon (mirrored silhouette), with a small margin. const maxHalfWidth = (w / 2) * 0.92; // Build the mirrored closed path: down the right edge (top->bottom), then back // up the left edge (bottom->top). amplitude maps loudness [0,1] to half-width. ctx.beginPath(); // Right edge, top to bottom. for (let y = 0; y <= h; y++) { const t = now + (y - nowY) / pixelsPerSecond; // time at this screen row const amp = sampleAt(t); const x = centreX + amp * maxHalfWidth; if (y === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } // Left edge, bottom to top (mirror). for (let y = h; y >= 0; y--) { const t = now + (y - nowY) / pixelsPerSecond; const amp = sampleAt(t); const x = centreX - amp * maxHalfWidth; ctx.lineTo(x, y); } ctx.closePath(); // ── Glassy gradient fill (spec §C). ───────────────────────────────────── // Vertical gradient brightest at the now line, dimming toward both edges — // this is the optional luminosity cue that reinforces the playhead without a // hard played/unplayed boundary. Stops come from the live MudBlazor palette. const grad = ctx.createLinearGradient(0, 0, 0, h); const nowStop = clamp01(nowY / h); grad.addColorStop(0, theme.edge); // top edge (trail-out), dim grad.addColorStop(nowStop, theme.accent); // the "now" line, brightest grad.addColorStop(1, theme.edge); // bottom edge (lead-in), dim // Two layered draws give the frosted/lit-glass depth: a soft wide glow under // a crisper core, both translucent. No backdrop-filter needed on the canvas // itself (the CSS layer adds blur); this is pure standard 2D compositing. ctx.globalCompositeOperation = 'source-over'; // Soft luminous halo. ctx.globalAlpha = RIBBON_OPACITY * 0.6; ctx.shadowColor = theme.accent; ctx.shadowBlur = 24 * dpr; ctx.fillStyle = grad; ctx.fill(); // Crisp core on top, no shadow. ctx.shadowBlur = 0; ctx.globalAlpha = RIBBON_OPACITY; ctx.fillStyle = grad; ctx.fill(); ctx.globalAlpha = 1; } /** Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out from empty). */ function sampleAt(timeSeconds: number): number { if (!datum) return 0; if (timeSeconds < 0 || timeSeconds >= datum.durationSeconds) return 0; const idx = Math.round(timeSeconds * datum.samplesPerSecond); if (idx < 0 || idx >= datum.samples.length) return 0; return datum.samples[idx]; } function clamp01(v: number): number { 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. /** * 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. */ function redrawOnce(): void { if (disposed) return; syncCanvasSize(); draw(); } /** * 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); } /** * 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 { if (durationSeconds <= 0 || !samplesBase64) { datum = null; return; } const samples = decodeSamples(samplesBase64); datum = { samples, durationSeconds, // samplesPerSecond from the ACTUAL datum length — never assume 2048. samplesPerSecond: samples.length / durationSeconds, }; // 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)); // 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(); // 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; stopLoop(); resizeObserver.disconnect(); }, }; }