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

367 lines
16 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;
/**
* 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;
}
/**
* 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.
*/
let lastDrewWhilePaused = false;
function frame(): void {
if (disposed) return;
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;
}
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
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,
};
lastDrewWhilePaused = false; // force a repaint of the new datum
},
setPlayback(positionSeconds: number, isPlaying: boolean): void {
playback = { positionSeconds, isPlaying };
},
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
},
refreshTheme(): void {
theme = readTheme();
lastDrewWhilePaused = false; // re-theme is visible immediately, even when paused
},
dispose(): void {
disposed = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
},
};
}