/** * 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 4096–16384), * 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); }, }; }