Files
deepdrft/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
T

449 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
},
};
}