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

820 lines
39 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 10, Wave 1).
*
* 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: WebGL2, fragment-shader. This is a wholesale renderer swap from
* the Canvas 2D predecessor (8.K). The reasons are forward-looking (the planned
* bulge / detach / morphing-field / glass effects are all per-pixel, per-frame
* work that Canvas is worst at and a fragment shader is best at — see
* product-notes/mix-visualizer-webgl-renderer.md). Wave 1 introduces NO new
* effects: it reproduces the predecessor's scrolling navy/moss-ish ribbon on the
* GPU at parity, holding 60 FPS, with the Blazor bridge contract unchanged.
*
* The pipeline is the textbook "shadertoy-style" full-screen pass: a single quad
* covering the canvas, a trivial pass-through vertex shader, and ALL the work in
* the fragment shader. Per fragment (pixel) the shader asks "which mix-time does
* my screen Y map to, what loudness is there, am I inside the ribbon, and what
* colour am I?" — the same scroll/zoom math the Canvas walked per screen-row,
* evaluated per-pixel in parallel on the GPU instead.
*
* The Blazor component owns the canvas element and the inputs (datum, playback,
* zoom, theme); this module owns the requestAnimationFrame loop and all the
* GL/scroll/zoom math. The component drives it through the small handle returned
* by `create`. The handle shape is identical to the Canvas predecessor's, so the
* bridge (MixWaveformVisualizer.razor.cs) needs no change.
*/
// ── 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. NOTE: kept in sync with the GLSL constant NOW_ANCHOR_FROM_TOP below.
*/
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;
/**
* Half-width of the ribbon at full loudness, as a fraction of half the canvas
* width (the predecessor used 0.92). Mirrors the Canvas `maxHalfWidth` factor.
*/
const RIBBON_HALF_WIDTH_FRAC = 0.92;
/**
* Cap device-pixel-ratio at 2. Beyond that the extra fragments cost frame time for
* no visible gain on a soft glassy backdrop — this is the graceful-degrade lever
* (spec §5.1): drop internal resolution before dropping frames.
*/
const MAX_DPR = 2;
// ── Diagnostics ──────────────────────────────────────────────────────────────────
//
// Set true to surface the init/draw/datum/playback seams to the browser console
// (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this
// flag — they only trigger on the abnormal path. The verbose `log` paths (datum
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
const DEBUG = true;
const TAG = '[MixVisualizer]';
function debugLog(...args: unknown[]): void {
if (DEBUG) console.log(TAG, ...args);
}
// ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─
//
// The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain
// floats. So we read the computed `--mud-palette-*` custom properties straight off
// the canvas element (which inherits them from the page), parse them to RGB, and
// upload them as vec3 colour uniforms. The bespoke light/dark themes ("Charleston
// in the Day" / "Lowcountry Summer Nights") swap those vars when dark mode toggles,
// so re-reading + re-uploading them re-themes the ribbon with no reload. The
// component just calls `refreshTheme()` after a dark-mode change.
//
// Parity binding (matches the Canvas predecessor): accent = --mud-palette-primary
// (brightest, at the now line), edge = --mud-palette-surface (dim, at the window
// edges so the ribbon fades into the page). Wave 3 will replace this two-stop
// gradient with the full navy↔moss morphing field; for parity we keep the
// predecessor's exact stops.
interface ResolvedTheme {
/** RGB [0,1] at the "now" line (brightest). */
accent: [number, number, number];
/** RGB [0,1] at the window edges (dimmer). */
edge: [number, number, number];
}
/**
* Read a CSS custom property off an element, falling back if it is empty/undefined.
* An empty value means the var did not inherit onto the canvas (e.g. the palette is
* scoped to a wrapper the canvas isn't under), which would silently swap the ribbon
* colour for the hardcoded default — so warn on it when diagnosing.
*/
function readVar(el: Element, name: string, fallback: string): string {
const v = getComputedStyle(el).getPropertyValue(name).trim();
if (v.length === 0) {
if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`);
return fallback;
}
return v;
}
/**
* Parse a CSS colour string to normalized [0,1] RGB. Handles #rgb / #rrggbb and
* rgb()/rgba() — the only forms MudBlazor emits for these palette vars. Falls back
* to mid-grey on anything unrecognised so a parse miss degrades to a visible
* ribbon rather than black.
*/
function parseColor(css: string): [number, number, number] {
const s = css.trim();
if (s.startsWith('#')) {
let hex = s.slice(1);
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
return [r / 255, g / 255, b / 255];
}
}
}
const m = s.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
if (m) {
return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255];
}
return [0.5, 0.5, 0.5];
}
// ── Datum: the pre-downloaded loudness profile (spec §F). ────────────────────────
interface Datum {
/**
* GPU texture holding the loudness samples (R8). Laid out as a 2-D grid that
* respects GL_MAX_TEXTURE_SIZE (see uploadDatum) rather than a 1×N row, which
* blows past the max texture width for any mix over ~49 s at the ~333 samples/s
* datum density. The shader reads it with texelFetch (integer addressing), so no
* hardware filtering is used — see sampleAt for the manual interpolation.
*/
texture: WebGLTexture;
/** Texture width in texels (samples per row). */
texWidth: number;
/** Texture height in texels (number of rows). */
texHeight: number;
/** Number of real samples in the datum (≤ texWidth*texHeight; the tail row is padded). */
sampleCount: number;
/** Total mix duration in seconds — needed to map time <-> sample index. */
durationSeconds: 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 a Uint8Array suitable for
* direct upload as an R8 texture. Done once per datum, off the animation path. We
* keep the bytes as [0,255] and let the GPU normalize to [0,1] on sample (R8
* UNORM), which mirrors the predecessor's /255 and avoids a CPU float pass.
*/
function decodeSamples(base64: string): Uint8Array {
const binary = atob(base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
// ── Shaders. ─────────────────────────────────────────────────────────────────────
//
// Vertex: trivial pass-through. We draw a single triangle that more than covers the
// clip-space box ([-1,1]²) so every pixel of the canvas is rasterized once. (One
// oversized triangle is the standard full-screen trick — cheaper than two and has
// no diagonal seam.) gl_FragCoord in the fragment shader then gives us the pixel's
// screen position directly.
const VERTEX_SHADER = `#version 300 es
// Three clip-space vertices forming one big triangle covering [-1,1]².
const vec2 POSITIONS[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0)
);
void main() {
gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0);
}
`;
// Fragment: THE SCROLL + ZOOM MATH (spec §A, §B), ported intact from the Canvas
// predecessor's per-row loop into a per-fragment evaluation. Read this top to
// bottom to follow how a quarter-note-@-180-BPM becomes 0.333 s becomes a texture
// coordinate becomes a lit pixel.
//
// Coordinate model (matches the Canvas predecessor exactly):
// - gl_FragCoord.xy is in device pixels, origin BOTTOM-left in WebGL. The Canvas
// used a TOP-left origin with Y increasing downward. We flip Y once up front
// (screenYTop) so all the time math below reads identically to the Canvas
// version: screenYTop = 0 at the top edge, = uResolution.y at the bottom.
// - The "now" line is a fixed screen Y: nowY = height * NOW_ANCHOR_FROM_TOP.
// - Audio flows UP: newer audio is drawn lower and scrolls up past the now line.
// * audio BELOW the now line (screenYTop > nowY) is the lead-in (not yet played)
// * audio ABOVE the now line (screenYTop < nowY) is the trail-out (just played)
//
// Zoom -> time-span -> pixels:
// - uVisibleSeconds is the whole 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 = height / uVisibleSeconds. 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 uPlayheadSeconds.
// - Moving DOWN by 1 px (screenYTop +1) adds (1 / pixelsPerSecond) seconds.
// - So: timeAt(y) = playhead + (screenYTop - nowY) / pixelsPerSecond
//
// Sample at a given time:
// - time / durationSeconds is the normalized position along the mix; multiplied by
// the sample count it becomes a continuous sample index. sampleAt interpolates
// between the two bracketing samples by hand (texelFetch + fract lerp) — see the
// note on its definition for WHY we can't use hardware LINEAR with the 2-D layout.
// - Outside [0, durationSeconds] we force loudness to 0. That 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. (CLAMP_TO_EDGE on the texture would
// otherwise repeat the edge sample, so we gate explicitly here.)
const FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform vec2 uResolution; // canvas size in device pixels
uniform float uPlayheadSeconds; // current playback position (per-frame)
uniform float uTimeSeconds; // monotonic clock (per-frame) — reserved for Wave 3 motion
uniform float uVisibleSeconds; // zoom: window time-span (per change)
uniform float uDurationSeconds; // mix length (per datum)
uniform vec3 uColorAccent; // brightest stop, at the now line (per theme)
uniform vec3 uColorEdge; // dim stop, at the window edges (per theme)
uniform float uHasDatum; // 1.0 when a datum texture is bound, else 0.0
uniform sampler2D uDatum; // loudness profile, R8, 2-D grid, NEAREST (texelFetch)
uniform int uDatumWidth; // datum texture width in texels (samples per row)
uniform int uDatumSampleCount; // number of real samples (tail row is padded)
out vec4 fragColor;
const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)};
const float RIBBON_OPACITY = ${RIBBON_OPACITY.toFixed(4)};
const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)};
// Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D
// texture grid (col = i mod width, row = i / width). texelFetch ignores filtering
// and wrap modes — it reads the exact texel — so the row-wrap layout is invisible
// to the caller.
float fetchSample(int i) {
int col = i % uDatumWidth;
int row = i / uDatumWidth;
return texelFetch(uDatum, ivec2(col, row), 0).r;
}
// Loudness at an absolute mix time, or 0 outside the mix (drives scroll-in/out).
//
// Interpolation note: we cannot lean on hardware LINEAR filtering here. The datum
// is laid across a 2-D grid (1×N would exceed GL_MAX_TEXTURE_SIZE past ~49 s of
// mix), and a hardware 2D-LINEAR read would blend across the row-wrap seam at the
// end of every row — sample[width-1] would wrongly bleed into sample[width] of the
// next row, and bilinear would also pull in the row above/below. So we do the
// linear interpolation by hand along the TIME axis only: bracket the fractional
// sample position with the two neighbouring texels, texelFetch each (each correctly
// mapped to its own 2-D texel), and lerp. Exact, no seam artifact.
//
// Texel-centre convention: this reproduces the predecessor's 1-D LINEAR read bit for
// bit. There, u = t/duration sampled an N-texel LINEAR texture, whose texel centres
// sit at (i+0.5)/N — so u maps to texel-space position u*N - 0.5, interpolating
// between floor() and floor()+1 of that, with CLAMP_TO_EDGE at the ends. We mirror
// exactly that here: the -0.5 and the index clamps to [0, N-1] are the CLAMP_TO_EDGE
// behaviour at both extremes.
float sampleAt(float timeSeconds) {
if (uHasDatum < 0.5) return 0.0;
if (timeSeconds < 0.0 || timeSeconds >= uDurationSeconds) return 0.0;
float n = float(uDatumSampleCount);
// Continuous texel-space position, half-texel shifted to match LINEAR centres.
float p = (timeSeconds / uDurationSeconds) * n - 0.5;
int i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1);
int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1);
float f = clamp(p - floor(p), 0.0, 1.0);
return mix(fetchSample(i0), fetchSample(i1), f);
}
void main() {
float w = uResolution.x;
float h = uResolution.y;
// Flip to a top-left, downward-Y frame so the time math matches the Canvas port.
float screenYTop = h - gl_FragCoord.y;
float screenX = gl_FragCoord.x;
float nowY = h * NOW_ANCHOR_FROM_TOP;
float pixelsPerSecond = h / uVisibleSeconds;
// time at this fragment's row, then loudness there.
float t = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
float amp = sampleAt(t);
// Ribbon silhouette: symmetric about the horizontal centre, half-width scales
// with loudness. This is the signed-distance form of the Canvas mirrored path.
float centreX = w * 0.5;
float maxHalfWidth = (w * 0.5) * RIBBON_HALF_WIDTH_FRAC;
float halfWidth = amp * maxHalfWidth;
float distFromCentre = abs(screenX - centreX);
// Soft edge: a ~1.5px feather at the silhouette boundary so the ribbon reads as
// a glowy lit band rather than a hard-edged chart — the parity stand-in for the
// predecessor's shadowBlur halo, done with a cheap smoothstep instead of a
// per-frame CPU blur. (No CSS backdrop-filter, no shadowBlur — spec §5.2.)
float feather = 1.5;
float inside = 1.0 - smoothstep(halfWidth - feather, halfWidth + feather, distFromCentre);
// With no datum, amp is 0 everywhere and halfWidth collapses to 0 — but the
// feathered smoothstep still lights the ~feather-wide column at the exact centre,
// drawing a thin vertical line down the middle of an otherwise-empty backdrop.
// That is the at-rest "vertical bar" artifact. The predecessor drew nothing at
// rest; match it by forcing the silhouette empty whenever there is no datum.
if (uHasDatum < 0.5) inside = 0.0;
// Vertical gradient: brightest at the now line, dimming toward both edges. Same
// luminosity cue the predecessor drew (edge -> accent -> edge by screen Y).
float distFromNow = abs(screenYTop - nowY) / max(nowY, h - nowY);
vec3 ribbonColor = mix(uColorAccent, uColorEdge, clamp(distFromNow, 0.0, 1.0));
float alpha = inside * RIBBON_OPACITY;
// Pre-multiplied output (blend func is ONE / ONE_MINUS_SRC_ALPHA) so the
// translucent ribbon composites cleanly over the transparent canvas.
fragColor = vec4(ribbonColor * alpha, alpha);
}
`;
/** Compile one shader stage, throwing with the info log on failure. */
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) throw new Error('MixVisualizer: gl.createShader returned null.');
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`MixVisualizer: shader compile failed: ${log}`);
}
return shader;
}
/** Link the vertex + fragment shaders into a program, throwing on failure. */
function linkProgram(gl: WebGL2RenderingContext): WebGLProgram {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const program = gl.createProgram();
if (!program) throw new Error('MixVisualizer: gl.createProgram returned null.');
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
// Shaders can be deleted after link — the program retains the compiled code.
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`MixVisualizer: program link failed: ${log}`);
}
return program;
}
/** The no-op handle returned when WebGL2 is unavailable or setup fails. */
function noopHandle(): MixVisualizerHandle {
return {
setDatum() {},
setPlayback() {},
setZoom() {},
refreshTheme() {},
dispose() {},
};
}
export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// premultipliedAlpha so the translucent ribbon composites correctly over the
// page; antialias off (the soft-edge smoothstep handles AA in-shader, and MSAA
// would cost fill rate we don't need for a backdrop).
const maybeGl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
});
if (!maybeGl) {
// No WebGL2 (old engine / disabled): hand back a no-op handle so the
// component still functions as a plain backdrop (mirrors the predecessor's
// no-2d-context fallback, now guarding against no-WebGL2).
console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`);
return noopHandle();
}
// Non-null binding so the closures below keep the narrowing (TS does not carry
// control-flow narrowing of a captured `const` into nested functions).
const gl: WebGL2RenderingContext = maybeGl;
// GL_MAX_TEXTURE_SIZE is a per-context constant — query it once. The datum is
// laid out across a 2-D grid no wider than this (see uploadDatum); a 1×N row
// would exceed it for any mix over ~49 s at the ~333 samples/s datum density,
// and texImage2D would reject the upload (the bug this fix addresses).
const maxTextureSize: number = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
let program: WebGLProgram;
try {
program = linkProgram(gl);
} catch (err) {
// A compile/link failure on an exotic driver should degrade to the plain
// backdrop, not crash the page. Log for diagnosis; return the no-op handle.
console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err);
return noopHandle();
}
// An empty VAO is still required in WebGL2 core to issue a draw; the vertex
// shader sources its positions from gl_VertexID, so no attribute buffers.
const vao = gl.createVertexArray();
// Cache uniform locations once. A null here for a uniform we actually upload
// means either the name is misspelled or the GLSL compiler dead-stripped it
// (it isn't reachable in the shader) — both of which silently break a uniform's
// effect, so surface them. `uTimeSeconds` is reserved for Wave 3 and currently
// unused by the fragment shader, so the compiler is free to strip it; we exempt
// it from the warning to avoid a false alarm.
const u = {
resolution: gl.getUniformLocation(program, 'uResolution'),
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorAccent: gl.getUniformLocation(program, 'uColorAccent'),
colorEdge: gl.getUniformLocation(program, 'uColorEdge'),
hasDatum: gl.getUniformLocation(program, 'uHasDatum'),
datum: gl.getUniformLocation(program, 'uDatum'),
datumWidth: gl.getUniformLocation(program, 'uDatumWidth'),
datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'),
};
for (const [name, loc] of Object.entries(u)) {
if (loc === null && name !== 'timeSeconds') {
console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`);
}
}
// ── 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 {
const resolved: ResolvedTheme = {
// Brightest stop at the "now" line — the bespoke themes' primary accent.
accent: parseColor(readVar(canvas, '--mud-palette-primary', '#b08d57')),
// Dim stop at the edges — the surface colour so the ribbon fades into the page.
edge: parseColor(readVar(canvas, '--mud-palette-surface', '#1a1a1a')),
};
debugLog(`theme resolved — accent=[${resolved.accent.map((c) => c.toFixed(2)).join(', ')}], edge=[${resolved.edge.map((c) => c.toFixed(2)).join(', ')}].`);
return resolved;
}
let theme: ResolvedTheme = readTheme();
let rafId: number | null = null;
let disposed = false;
const startTimeMs = performance.now();
// One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
// first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
// backing store here means the canvas had no layout box when the first draw ran —
// the ResizeObserver will correct it, but the first paint would be degenerate.
let firstRealDrawLogged = 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;
// ── One-time GL pipeline setup. ──────────────────────────────────────────────
gl.useProgram(program);
gl.disable(gl.DEPTH_TEST);
// Pre-multiplied alpha blend: src already carries colour*alpha (see frag shader).
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// The datum lives in texture unit 0 for the program's lifetime.
gl.uniform1i(u.datum, 0);
// ── ResizeObserver: one-shot redraw when the container changes while idle. ────
//
// The ResizeObserver is the SOLE size writer (spec §2e): we never call
// getBoundingClientRect per frame. While playing, the rAF loop redraws every
// frame anyway and picks up the new backing-store size set here. While idle, the
// observer fires only on an actual size change and triggers a single redraw.
const resizeObserver = new ResizeObserver((entries) => {
if (disposed) return;
const entry = entries[0];
// contentBoxSize is the modern, layout-thrash-free size source. Fall back to
// contentRect for engines that don't populate it.
const box = entry.contentBoxSize?.[0];
const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
applySize(nextCssWidth, nextCssHeight);
// While idle, draw one still frame reflecting the new size. While playing,
// the running loop will redraw on its next tick — no action needed.
if (!playback.isPlaying) redrawOnce();
});
resizeObserver.observe(canvas);
/**
* Update the backing store to a CSS size × devicePixelRatio (capped at MAX_DPR)
* and the GL viewport. Only resizes when something changed — resizing clears the
* drawing buffer, so we avoid needless churn. This is the only place the canvas
* size is written (fed by the ResizeObserver, never by a per-frame measure).
*/
function applySize(nextCssWidth: number, nextCssHeight: number): void {
const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
if (nextCssWidth === cssWidth && nextCssHeight === cssHeight && nextDpr === dpr) {
return;
}
cssWidth = nextCssWidth;
cssHeight = nextCssHeight;
dpr = nextDpr;
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
}
/**
* Issue one GL draw with the current uniforms. The fragment shader does all the
* scroll/zoom/ribbon work; here we just push the per-frame uniforms and draw the
* full-screen triangle.
*/
function draw(): void {
if (canvas.width <= 0 || canvas.height <= 0) return;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindVertexArray(vao);
// Per-frame uniforms.
gl.uniform2f(u.resolution, canvas.width, canvas.height);
gl.uniform1f(u.playheadSeconds, playback.positionSeconds);
gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000);
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
// separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds);
gl.uniform3fv(u.colorAccent, theme.accent);
gl.uniform3fv(u.colorEdge, theme.edge);
if (datum) {
gl.uniform1f(u.hasDatum, 1);
gl.uniform1f(u.durationSeconds, datum.durationSeconds);
gl.uniform1i(u.datumWidth, datum.texWidth);
gl.uniform1i(u.datumSampleCount, datum.sampleCount);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, datum.texture);
} else {
gl.uniform1f(u.hasDatum, 0);
gl.uniform1f(u.durationSeconds, 1);
// Keep the divisor safe even though sampleAt early-outs on uHasDatum<0.5.
gl.uniform1i(u.datumWidth, 1);
gl.uniform1i(u.datumSampleCount, 1);
}
// One full-screen triangle (3 vertices), positions from gl_VertexID.
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
// First draw at a real (laid-out) size: report dimensions and any accumulated
// GL error. We hold this log until cssWidth/cssHeight are populated so the
// dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1.
// gl.getError() is a pipeline stall, so we only call it once, never per frame.
if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) {
firstRealDrawLogged = true;
debugLog(
`first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`,
);
const glErr = gl.getError();
if (glErr !== gl.NO_ERROR) {
console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`);
}
}
}
// ── 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 GPU is idle. The still slice stays correct via one-shot
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
// by the ResizeObserver. The shader clock (uTimeSeconds) advances every frame so
// motion is smooth at 60 FPS between Blazor's ~10 Hz playback ticks, not stepping
// at that cadence (spec §2e / §5.4).
/** Draw one still frame immediately, without scheduling a new rAF. */
function redrawOnce(): void {
if (disposed) return;
draw();
}
/** Start the rAF loop. No-op if already running or disposed (rafId guard). */
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 draws the scrolling
* waveform at the current playback position + shader clock, then reschedules
* itself — unless playback stopped since this frame was queued, in which case it
* draws one final still frame (already done above) 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 frames (spec §E / §5.3).
*/
function frame(): void {
if (disposed) {
rafId = null;
return;
}
draw();
if (playback.isPlaying) {
rafId = requestAnimationFrame(frame);
} else {
// Playback stopped between queue and now; final still frame drawn above.
rafId = null;
}
}
// Read the initial size synchronously (one getBoundingClientRect at setup is
// fine — it is the ResizeObserver that must not measure per-frame), then draw a
// still frame so the canvas isn't blank before the first play command.
{
const rect = canvas.getBoundingClientRect();
applySize(rect.width, rect.height);
redrawOnce();
}
/**
* Upload the loudness samples as a 2-D R8 texture that respects
* GL_MAX_TEXTURE_SIZE, returning the Datum (with the grid dimensions the shader
* needs to map a sample index → texel) or null on empty/invalid input.
*
* Why 2-D and not 1×N: the mix datum runs at ~333 samples/s, so any mix over
* ~49 s produces more samples than GL_MAX_TEXTURE_SIZE (commonly 409616384),
* and `texImage2D(…, width=N, height=1, …)` is rejected outright
* ("Requested size at this level is unsupported"), leaving the waveform texture
* uncreated and the ribbon blank. Laying the N samples row-major across a grid
* of width = min(N, safeWidth) keeps every dimension well within the limit.
*
* Filtering: the shader reads with texelFetch and does its own time-axis
* interpolation (see sampleAt), so NEAREST is correct here — hardware LINEAR on
* a 2-D grid would bleed across the row-wrap seam. The final row is zero-padded
* (texture init is zero-filled, then we overwrite the real samples); padding is
* never read because sampleAt clamps the index to sampleCount-1.
*/
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
if (durationSeconds <= 0 || !samplesBase64) {
// Expected before the player reports a duration: the bridge pushes an empty
// datum until then. Not an error, but worth seeing while diagnosing.
debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
return null;
}
const samples = decodeSamples(samplesBase64);
const sampleCount = samples.length;
if (sampleCount === 0) {
console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
return null;
}
// Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
// under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
// still clamp it to the actual max in case a driver reports something smaller.
const SAFE_WIDTH = 4096;
const texWidth = Math.min(sampleCount, Math.min(SAFE_WIDTH, maxTextureSize));
const texHeight = Math.ceil(sampleCount / texWidth);
debugLog(
`uploadDatum — ${sampleCount} samples for ${durationSeconds.toFixed(2)}s mix ` +
`(${(sampleCount / durationSeconds).toFixed(1)} samples/s); ` +
`datum texture ${texWidth}x${texHeight} for N=${sampleCount} samples, maxTextureSize=${maxTextureSize}.`,
);
// Pad the final partial row with zeros so the full grid uploads in one call.
const padded = texWidth * texHeight === sampleCount
? samples
: (() => {
const buf = new Uint8Array(texWidth * texHeight);
buf.set(samples);
return buf;
})();
const texture = gl.createTexture();
if (!texture) return null;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// R8 rows are 1-byte-per-texel and texWidth is not guaranteed 4-aligned;
// relax the default 4-byte unpack alignment so rows aren't read with stride
// padding the source array doesn't have.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.R8,
texWidth, texHeight, 0,
gl.RED, gl.UNSIGNED_BYTE, padded,
);
// NEAREST: texelFetch ignores the filter anyway, but be honest about it — the
// shader interpolates manually to avoid the row-wrap seam (see sampleAt).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null);
return { texture, texWidth, texHeight, sampleCount, durationSeconds };
}
return {
setDatum(samplesBase64: string, durationSeconds: number): void {
debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
// Free the previous datum's GPU texture before replacing it (no leak
// across re-pushes / mix changes — spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — refresh the still slice immediately
// when idle. If playing, the running loop picks it up next frame.
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.
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
startLoop();
} else if (!isPlaying && wasPlaying) {
// Transition playing → paused/stopped: the in-flight frame draws the
// final still position and exits on its own (frame() checks
// playback.isPlaying before rescheduling). We do NOT stopLoop() here —
// that would cancel the in-flight frame before it draws, leaving a
// stale canvas. Let the frame run out.
}
// isPlaying unchanged (position-only update): the running loop (if any)
// redraws next frame; nothing to do here.
},
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));
if (!playback.isPlaying) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (!playback.isPlaying) redrawOnce();
},
dispose(): void {
disposed = true;
stopLoop();
resizeObserver.disconnect();
// Release all GL resources so nothing leaks on navigation (spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
if (vao) gl.deleteVertexArray(vao);
gl.deleteProgram(program);
},
};
}