449 lines
20 KiB
TypeScript
449 lines
20 KiB
TypeScript
/**
|
||
* 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();
|
||
},
|
||
};
|
||
}
|