/** * MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 1–3). * * 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 was a wholesale renderer swap from * the Canvas 2D predecessor (8.K) — the four effects below 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 3 (this revision) brings the four in-shader effects to life, all driven by * the control uniforms Wave 2 wired (see the fragment shader's main()): * 1. A morphing navy↔moss 2-D colour field (uColorShiftSpeed × uTimeSeconds). * 2. Bubblyness — box→metaball SDF bulge (uBubblyness). * 3. Detach — lava-lamp pinch-off + rising blobs (uDetach, uTimeSeconds). * 4. Glass — specular + Fresnel + frosted + refraction, all shader math, no CSS * backdrop-filter / no per-frame CPU blur (the original perf killer). * The Wave 1 scroll/zoom geometry and the Wave 2 bridge contract are unchanged; * the effects layer on top of them. * * 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; // ── Wave 2 control tuning anchors. These mirror the C#-side defaults in ─────────── // MixVisualizerControlState.cs — keep the two in sync, exactly as the // DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are // normalized [0,1]. They are wired through to GPU uniforms now (Wave 2 plumbing) but // the parity shader does NOT consume them visually yet — they come alive in Wave 3. /** Default bulge amount, normalized [0,1]. Mirrors C# DefaultBubblyness. */ export const DEFAULT_BUBBLYNESS = 0.35; /** Default lava-lamp detach amount, normalized [0,1]. Mirrors C# DefaultDetach. */ export const DEFAULT_DETACH = 0; /** Default gradient-morph rate, normalized [0,1]. Mirrors C# DefaultColorShiftSpeed. */ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3; /** * 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. Raised from the parity 0.22 → 0.55 for the * Wave-3-rework "vivid/glassy" pass: at 0.22 the page (off-white / navy) showed through * ~78% of every pixel and washed the field toward grey. 0.55 lets the saturated navy/moss * read as real colour while still keeping it a translucent glass backdrop, not an opaque * chart. (The rim/Fresnel lift on top pushes edges higher.) */ const RIBBON_OPACITY = 0.55; /** * 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; /** * Playhead-correction smoothing time constant, in seconds. Governs how fast the * rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push. * * The problem: the player's position reports are irregular at startup (buffering / * playback ramp-up), so each push lands a position that doesn't match where the * wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior * behaviour) made that gap a visible snap every push — the startup jitter. * * The fix (classic netcode-style entity reconciliation): the player stays the sole * source of truth, but instead of rendering the authoritative position directly, we * render authoritative + a small *correction offset* that decays toward zero every * frame. On each push we fold the re-anchor discontinuity into that offset so the * rendered playhead is continuous across the push, then bleed the offset off over * ~this time constant. This eases the snap into a sub-perceptible glide. * * Why an offset that decays to zero, not an absolute lerp toward target: a lerp * toward the target leaves a steady-state lag proportional to velocity (the render * always trailing real playback). Decaying the *error* to zero converges the * rendered playhead back onto the authoritative one, so once pushes steady the * offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and * steady-state is unchanged as required. * * 0.12 s is a sensible default: long enough to dissolve the worst startup snaps * (tens of ms of position error), short enough that the correction is imperceptible * and the render never trails real playback by more than a few ms. Tunable. */ const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12; /** * Below this absolute correction (seconds) we snap the offset to 0 and stop easing — * an exponential decay never mathematically reaches zero, and carrying a sub-ms * residual forever is pointless. ~0.5 ms is well under one frame of motion at any * real zoom, so collapsing it is invisible. */ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005; // ── 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. // NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser; // the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the // look is approved. const DEBUG = true; const TAG = '[MixVisualizer]'; function debugLog(...args: unknown[]): void { if (DEBUG) console.log(TAG, ...args); } // ── Theme: the navy↔moss field poles, 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 swap those vars // when dark mode toggles, so re-reading + re-uploading them re-themes the field with // no reload. The component just calls `refreshTheme()` after a dark-mode change. // // Wave 3 binding — the two poles of the morphing colour field (spec §4b/§4c): // - `uColorAccent` carries MOSS (the interactive green). // - `uColorEdge` carries NAVY (the dark ground / navy-mid). // (The names are inherited from the parity two-stop gradient; in Wave 3 they are the // two field poles, not a now-line→edge luminance ramp. Kept rather than renamed to // avoid touching the bridge's uniform-location cache and the well-tested upload path.) // // The cross-mode problem (spec §4c, explicit): navy and moss are NOT a single stable // pair of CSS vars across both palettes. Navy is `--mud-palette-primary` in LIGHT but // the `--mud-palette-background` ground in DARK; moss is `--mud-palette-secondary` in // LIGHT but `--mud-palette-primary` in DARK (where green IS primary). No one var holds // "navy" or "moss" in both modes. So we detect the mode in JS (by the luminance of the // page background — the bespoke dark ground #0D1B2A is near-black, the light ground // #FAFAF8 is near-white) and bind the poles per the spec's stated mapping. refreshTheme // re-runs this on a dark-mode toggle, so the field re-themes live. interface ResolvedTheme { /** Moss-green pole RGB [0,1] — uploaded to uColorAccent. */ accent: [number, number, number]; /** Navy pole RGB [0,1] — uploaded to uColorEdge. */ edge: [number, number, number]; } /** sRGB relative luminance (cheap Rec.709 weights) of a normalized RGB triple. */ function luminance([r, g, b]: [number, number, number]): number { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * 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 { /** * Last playback head pushed from Blazor, in seconds. This is the *authoritative* * position the player last reported — it updates only on the ~10 Hz setPlayback * push, NOT every frame. The per-frame scroll uses the interpolated * effectivePlayhead (see draw()), anchored on this value. */ positionSeconds: number; /** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */ isPlaying: boolean; /** * performance.now() (ms) captured when positionSeconds was pushed. The rAF loop * advances the playhead by wall-clock elapsed since this anchor so the ribbon * scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes, * instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on * every push, so each push is a small correction rather than a hard reset. */ pushWallClockMs: number; } export interface MixVisualizerHandle { setDatum(samplesBase64: string, durationSeconds: number): void; setPlayback(positionSeconds: number, isPlaying: boolean): void; setZoom(visibleSeconds: number): void; /** Bulge amount [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ setBubblyness(value: number): void; /** Lava-lamp detach [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ setDetach(value: number): void; /** Gradient-morph rate [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */ setColorShiftSpeed(value: 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) — drives field morph + blob rise uniform float uVisibleSeconds; // zoom: window time-span (per change) uniform float uBubblyness; // bulge amount [0,1] (per change) — box→metaball SDF blend uniform float uDetach; // lava-lamp detach [0,1] (per change) — pinch-off + rise uniform float uColorShiftSpeed; // gradient-morph rate [0,1] (per change) — field cycle rate uniform float uDurationSeconds; // mix length (per datum) uniform vec3 uColorAccent; // MOSS pole of the field (per theme) uniform vec3 uColorEdge; // NAVY pole of the field (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)}; // ── Wave 3 tuning constants (all in-shader; Daniel tunes by editing here). ────────── // Colour-shift speed → cycle period (seconds). The slider is normalized [0,1]; we map // it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3 // rework): the old 60 s slow end made even the default look frozen — Daniel reported the // slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s // (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential // map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3 // (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated // poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey. const float COLORSHIFT_PERIOD_SLOW = 24.0; // s at slider 0 — slow but perceptible drift const float COLORSHIFT_PERIOD_FAST = 2.0; // s at slider 1 — unmistakably brisk morph // Vividness (W3 rework). The raw theme tokens are muted UI colours (navy text / moss // secondary, both dark + low-saturation); a naive RGB lerp between them passes through a // muddy grey midpoint, which is exactly the "mostly grey" Daniel rejected. We mix the // field in HSL instead (hue/sat/lum interpolate independently, so the path between two // saturated colours stays saturated — no grey midpoint), and lift saturation + luminance // of the result so the field reads as rich glassy navy-blue ↔ vivid moss-green. These are // the punch dials. const float VIVID_SATURATION_FLOOR = 0.62; // min saturation of any field pixel [0,1] const float VIVID_SATURATION_BOOST = 0.30; // extra saturation pushed in on top of the lerp const float VIVID_LUMINANCE_LIFT = 0.14; // lifts the dark poles off black so colour reads // Bubblyness: how far the metaball field spreads to neighbours at max bulge, as a // fraction of the half-window. Larger = more liquid coalescence between bars. const float BUBBLE_SMOOTHMIN_K = 0.18; // Bubbling motion (W3 rework). Bubblyness used to only thicken the ribbon statically. // Now it also drives a time-varying swell of the ribbon surface (a lava-lamp roil): a // low-frequency noise displaces the bar half-width up and down over time, with amplitude // and churn rate growing with uBubblyness. At 0 the displacement is zero (flat parity // bars); rising = an increasingly active, undulating surface. const float BUBBLE_SWELL_AMPLITUDE = 0.35; // max half-width swell (xn units) at bubblyness 1 const float BUBBLE_SWELL_RATE = 0.55; // churn speed (rad/s scale) of the swell noise const float BUBBLE_SWELL_FREQ = 2.2; // spatial frequency of the swell along the ribbon // Detach: how many independent rising blobs we evaluate, and how far (in window // heights) a blob travels over its life before fading + recycling. Bounded so it reads // as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT // the waveform surface (where loudness is) and pinch off from it, rather than spawning in // empty space — see ribbonField's detach block. const int DETACH_BLOB_COUNT = 6; const float DETACH_RISE_SPAN = 1.15; // window-heights a blob climbs across its life const float DETACH_BLOB_DRIFT = 0.05; // horizontal lava-lamp wobble amplitude (xn units) // Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic // (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3 // rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy"). const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot const float GLASS_FRESNEL_POWER = 2.2; // lower = broader, more visible rim glow const float GLASS_REFRACT_WARP = 0.10; // field-distortion amount at curved surfaces // 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); } // ════════════════════════════════════════════════════════════════════════════════════ // WAVE 3 — the four in-shader effects. Helpers first, then main() layers them in the // spec §6 order: (1) gradient field, (2) bubblyness, (3) detach, (4) glass. // ════════════════════════════════════════════════════════════════════════════════════ // ── Value-noise (for the flowing colour field + organic blob jitter). ─────────────── // A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and // continuous so the field flows rather than flickers. This is the "low-frequency base // field" the spec §4b calls for — sampled over (time-axis, amplitude-axis) it gives the // coherent navy↔moss morph; sampled at higher frequency it gives per-bar liveness. float hash21(vec2 p) { p = fract(p * vec2(123.34, 345.45)); p += dot(p, p + 34.345); return fract(p.x * p.y); } float valueNoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); // Smoothstep (Hermite) interpolation of the four lattice corners — C1-continuous. vec2 u = f * f * (3.0 - 2.0 * f); float a = hash21(i + vec2(0.0, 0.0)); float b = hash21(i + vec2(1.0, 0.0)); float c = hash21(i + vec2(0.0, 1.0)); float d = hash21(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // ── HSL conversion (for the VIVID field — see VIVID_* consts). ────────────────────── // Mixing two saturated colours in linear RGB drags the midpoint through grey; mixing in // HSL keeps hue/sat/lum independent so the path between navy and moss stays colourful. // Standard branchless RGB↔HSL. h,s,l ∈ [0,1]. vec3 rgb2hsl(vec3 c) { float mx = max(max(c.r, c.g), c.b); float mn = min(min(c.r, c.g), c.b); float l = (mx + mn) * 0.5; float d = mx - mn; float s = 0.0; float h = 0.0; if (d > 1e-5) { s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn); if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); else if (mx == c.g) h = (c.b - c.r) / d + 2.0; else h = (c.r - c.g) / d + 4.0; h /= 6.0; } return vec3(h, s, l); } float hue2rgb(float p, float q, float t) { if (t < 0.0) t += 1.0; if (t > 1.0) t -= 1.0; if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; if (t < 1.0 / 2.0) return q; if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; return p; } vec3 hsl2rgb(vec3 hsl) { float h = hsl.x, s = hsl.y, l = hsl.z; if (s < 1e-5) return vec3(l); float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; float p = 2.0 * l - q; return vec3(hue2rgb(p, q, h + 1.0 / 3.0), hue2rgb(p, q, h), hue2rgb(p, q, h - 1.0 / 3.0)); } // Interpolate two RGB colours through HSL, taking the SHORT way around the hue circle so // navy↔moss travels the rich teal/blue arc rather than wrapping through red. Returns the // result back in RGB with no extra vividness applied (the caller adds the punch). vec3 mixHsl(vec3 a, vec3 b, float t) { vec3 ha = rgb2hsl(a); vec3 hb = rgb2hsl(b); float dh = hb.x - ha.x; if (dh > 0.5) dh -= 1.0; // go the short way round the hue wheel if (dh < -0.5) dh += 1.0; float h = fract(ha.x + dh * t); float s = mix(ha.y, hb.y, t); float l = mix(ha.z, hb.z, t); return hsl2rgb(vec3(h, s, l)); } // Push a colour toward vivid: raise saturation (with a floor) and lift luminance off // black so the dark theme poles actually read as colour rather than near-grey. amp ∈ [0,1] // (loudness) lifts a loud bar a little further for the "own living thing" read. vec3 vivify(vec3 rgb, float amp) { vec3 hsl = rgb2hsl(rgb); hsl.y = max(hsl.y, VIVID_SATURATION_FLOOR); hsl.y = clamp(hsl.y + VIVID_SATURATION_BOOST + amp * 0.10, 0.0, 1.0); hsl.z = clamp(hsl.z + VIVID_LUMINANCE_LIFT + amp * 0.06, 0.0, 0.92); return hsl2rgb(hsl); } // ── Signed-distance primitives + smooth-min (the metaball machinery). ─────────────── // Box SDF (centred at origin, half-extents b): negative inside, positive outside. float sdBox(vec2 p, vec2 b) { vec2 d = abs(p) - b; return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); } // Rounded-box SDF: a box whose corners are rounded by radius r — the "liquid" silhouette. float sdRoundBox(vec2 p, vec2 b, float r) { return sdBox(p, b - vec2(r)) - r; } // Circle SDF (a metaball centre) — used for detached rising blobs. float sdCircle(vec2 p, float r) { return length(p) - r; } // Polynomial smooth-min (Inigo Quilez). Blends two SDFs into one continuous surface — // the metaball union. k controls the blend radius (the size of the liquid "neck" where // two blobs merge). As k→0 it becomes a hard min (discrete shapes); larger k fuses them. float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } // ── The ribbon/blob signed-distance field at a screen point. ──────────────────────── // // Returns the signed distance (in normalized half-window-width units, negative inside) // to the liquid surface at screen pixel (px). This is the heart of effects 2+3: it // blends a sharp box (bubblyness 0) toward swelling metaballs (bubblyness 1), and // peels detached blobs upward (detach). main() then renders the surface and shades it. // // Coordinate model: x normalized so |x|=1 is the full ribbon half-width at the canvas // edge; y is screen-row time as before. Loudness at this row sets the attached // half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce // vertically into a continuous liquid column rather than discrete per-row slabs. float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, out float ampOut) { float screenYTop = px.y; float screenX = px.x; // Time + loudness at this row (the geometry from Wave 1, unchanged). float t = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; float amp = sampleAt(t); ampOut = amp; // Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width. float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth; // --- BUBBLING MOTION (§4d rework) ------------------------------------------------- // Bubblyness now drives a real over-time roil, not just a thicker static ribbon. A // low-frequency noise sampled over (this row's mix-time, the wall clock) swells the // bar's half-width up and down continuously — the surface churns like a lava lamp's. // Amplitude AND churn rate both scale with uBubblyness, so at 0 the term vanishes // (flat parity bars) and rising = an increasingly active, undulating surface. We key // the noise to mix-time (not screen-Y) so the swell travels WITH the audio as it // scrolls, rather than sitting still in screen space. Only applied where there is // loudness (amp gates it) so silence stays flat. float swellNoise = valueNoise(vec2(t * BUBBLE_SWELL_FREQ, uTimeSeconds * BUBBLE_SWELL_RATE)) - 0.5; // ±0.5 float swell = swellNoise * BUBBLE_SWELL_AMPLITUDE * uBubblyness * amp * 2.0; float halfWidthN = max(amp + swell, 0.0); // box half-extent in xn units, now animated // --- ATTACHED SHAPE --------------------------------------------------------------- // At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular // bar (each row independent, sharp edges). At bubblyness 1: round the slab and let // it swell outward from the zero-line, and smooth-min with the rows above/below so // the column fuses into one liquid silhouette (the metaball union, §4d). // // Row thickness in xn space: half a screen-row's worth, so a single sampled row is // a thin horizontal slab. Bubblyness grows the vertical reach (the swell) and the // corner rounding. float rowHalfY = 0.5 / max(maxHalfWidth, 1.0); // ~half a pixel in xn units // Sample neighbour rows (±a few rows) to build a vertical metaball stack. The offset // grows with bubblyness so bulges reach further and merge more at higher settings. float reach = mix(rowHalfY, rowHalfY + 0.18, uBubblyness); vec2 q = vec2(xn, 0.0); // Box half-extents: width = loudness, height = the row reach. Rounding radius grows // with bubblyness (sharp rect → rounded capsule). The swell-from-centre is the box // width itself scaling with amp, so a loud row bulges wider. // (cornerR, not "round" — that name shadows the GLSL built-in round() and reads badly.) float cornerR = mix(0.0, halfWidthN * 0.9 + 0.02, uBubblyness); vec2 boxB = vec2(max(halfWidthN, 0.001), reach); float attached = sdRoundBox(q, boxB, min(cornerR, min(boxB.x, boxB.y) - 1e-3)); // Vertical coalescence: blend with neighbour rows' loudness so the column is liquid, // not a stack of disks. We approximate by smooth-min'ing against the loudness one // "reach" above and below in time — cheap (two extra texture taps) and gives the // continuous-liquid read the spec wants. Only meaningful when bubbly. if (uBubblyness > 0.001) { float dtRow = reach * maxHalfWidth / pixelsPerSecond; // xn-reach back to seconds float ampUp = sampleAt(t - dtRow); float ampDn = sampleAt(t + dtRow); vec2 boxUp = vec2(max(ampUp, 0.001), reach); vec2 boxDn = vec2(max(ampDn, 0.001), reach); float up = sdRoundBox(vec2(xn, reach), boxUp, min(cornerR, min(boxUp.x, boxUp.y) - 1e-3)); float dn = sdRoundBox(vec2(xn, -reach), boxDn, min(cornerR, min(boxDn.x, boxDn.y) - 1e-3)); float k = BUBBLE_SMOOTHMIN_K * uBubblyness; attached = smin(attached, smin(up, dn, k), k); } // --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------ // Reworked from the old "fixed-column blobs floating in empty space that vibrate" to // bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge // (where the loudness is) near the now-line, pinches off, and rises smoothly. Two // fixes for the rejected version: // 1. ORIGIN AT THE WAVEFORM. A bubble's birth column sits at ±(loudness) — the bar // EDGE at its birth time — not a hash-picked column in empty space. We sample the // datum at the birth time so a bubble only exists where there was actually sound, // and it starts attached to the surface there. // 2. NO VIBRATION. The vertical scale now matches the horizontal (xn) scale via the // screen aspect (yAspect below), so blobs are round, not squashed — the old code // normalised a vertical distance by maxHalfWidth (a HORIZONTAL scale), which // stretched blobs and made the SDF-gradient normal unstable → shimmer. Motion is // a single smooth fract(uTimeSeconds·rate); the only hash use is per-index // identity (time-invariant), so there is no per-frame jitter. float field = attached; if (uDetach > 0.001) { // Map a vertical screen-pixel distance into the same xn units the SDF circle uses, // so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth // (≈ half the canvas width); to match, vertical must divide by the same, hence the // 1.0 here keeps both axes in maxHalfWidth units (screenY already in px like screenX). float yToXn = 1.0 / maxHalfWidth; for (int i = 0; i < DETACH_BLOB_COUNT; i++) { float fi = float(i); // Per-blob identity from a hash — stable over time (no per-frame term), so the // blob set is a calm repeating drift, never a random storm. float seed = hash21(vec2(fi, 7.0)); float seed2 = hash21(vec2(fi, 19.0)); float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off // Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase // offset so they don't pulse in unison; rise rate scales gently with detach. float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach); float life = fract(uTimeSeconds * rate + seed); // Birth time: the mix-time at the now-line, nudged per blob so they're born at // staggered moments. The bubble emanates from the surface AS IT WAS at birth. float birthT = uPlayheadSeconds - seed * 0.15; float birthAmp = sampleAt(birthT); // No surface there (silence) → no bubble. This is what ties bubbles to the // waveform: they only appear where there was loudness to shed them. if (birthAmp < 0.02) continue; // Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts // ON the surface. As it rises it drifts slightly inward/outward (lava wobble). float birthX = side * birthAmp; float driftX = birthX + side * DETACH_BLOB_DRIFT * sin(uTimeSeconds * 0.6 + seed * 6.28); // Rise: starts at the now-line (the surface) and climbs upward (screen-up = // decreasing screenYTop), travelling DETACH_RISE_SPAN window-heights over life. float riseN = life * DETACH_RISE_SPAN; // window-heights climbed float blobYTop = nowY - riseN * uResolution.y; // screen Y of the blob centre // Radius: bigger from a louder birth surface; grows then shrinks across life so // the bubble swells out of the surface and fades near the top — no hard pop. float envelope = smoothstep(0.0, 0.15, life) * (1.0 - smoothstep(0.80, 1.0, life)); float radius = (0.04 + 0.07 * seed) * (0.5 + 0.5 * birthAmp) * uDetach * envelope; if (radius < 1e-4) continue; // fully faded — skip (also avoids a 0-radius SDF) // Blob centre in the (xn, xn) eval frame. Both axes now in maxHalfWidth units // (driftX already in xn; vertical px scaled by yToXn) → the circle is round. vec2 pBlob = vec2(xn - driftX, (screenYTop - blobYTop) * yToXn); float blob = sdCircle(pBlob, radius); // Pinch-off neck: while young (low life) and at low detach the bubble stays // linked to the parent surface via a fat smooth-min neck; as it rises (life→1) // or detach→1 the neck thins toward a hard union, so it reads as separated. float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7); field = smin(field, blob, max(neckK, 0.004)); } } return field; } 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; float maxHalfWidth = (w * 0.5) * RIBBON_HALF_WIDTH_FRAC; // Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note). if (uHasDatum < 0.5) { fragColor = vec4(0.0); return; } // ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ── // The gradient of the SDF is the outward surface normal — we need it for the glass // (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the // step is one device-pixel mapped into the field's xn/yTop frame. // (The old playhead-feed hoist was removed in the W3 rework: detach now samples a // per-blob birth-time loudness inside the loop, so there is no single shared tap to // lift out. The taps remain uniform-only expressions, the same order of cost as before.) vec2 px = vec2(screenX, screenYTop); float amp; float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, amp); float e = 1.0; // 1px central-difference step float ignore; float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); // Surface normal in screen space (points OUT of the liquid). y flipped because our // field y is screen-down. Guard the zero-length case at flat interiors. vec2 grad = vec2(dRx - dLx, dUy - dDy); vec2 normal = length(grad) > 1e-4 ? normalize(grad) : vec2(0.0, -1.0); // Inside-ness: SDF negative = inside. Feather the boundary (~1px in field units) for // an anti-aliased, glowy lit edge instead of a hard chart line (spec §5.2, no blur). float pxFeather = 1.5 / maxHalfWidth; // 1.5px expressed in xn units float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d); // ── EFFECT 1: the morphing navy↔moss 2-D colour field (§4b). ─────────────────────── // Two composed layers: // (a) Low-frequency BASE FIELD over (time-axis, amplitude-axis), flowing on the // uTimeSeconds clock at a rate set by uColorShiftSpeed → coherent across the // window, never static. // (b) Higher-frequency PER-BAR modulation keyed off the row's own loudness/phase → // adjacent bars related but distinct, louder = brighter/greener (its own life). // // Colour-shift cycle: exponential map normalized speed → period (see consts), so the // slider's slow end barely drifts and its fast end morphs briskly, never frozen. float period = COLORSHIFT_PERIOD_SLOW * pow(COLORSHIFT_PERIOD_FAST / COLORSHIFT_PERIOD_SLOW, uColorShiftSpeed); float phase = uTimeSeconds * (6.28318 / period); // Time-axis coordinate of this fragment (where along the mix it sits) drives the // field's along-scroll dimension; amplitude (|xn|) drives the along-bar dimension. float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0); // (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit // sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps // the whole field navy↔moss once per cycle, so dragging the slider visibly changes // how fast the field morphs (the old version relied on noise drifting through a // near-grey lerp, so the morph was invisible — Daniel's "slider does nothing"). // The noise rides on top for organic, non-repeating variation across the window. float sweep = 0.5 + 0.5 * sin(phase); // 0→1, one cycle per period float drift = valueNoise(vec2(tHere * 0.15 + phase * 0.5, phase)); drift += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); drift = clamp(drift / 1.5, 0.0, 1.0); float base = clamp(sweep * 0.6 + drift * 0.4, 0.0, 1.0); // time-sweep dominant // (b) Along-bar: blend more toward MOSS at the peak, NAVY near the zero-line — gives // each bar internal structure (spec §4b axis 1). Per-bar liveness: perturb by a // noise keyed to this bar's time so neighbours differ. float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter float fieldMix = clamp(base * 0.55 + xnAbs * 0.30 + perBar * 0.20 + amp * 0.15, 0.0, 1.0); // VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB, // so the path between them stays saturated instead of passing through the muddy grey // midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance // off the dark UI tokens so it reads as rich glassy navy ↔ vivid moss. accent = MOSS // (peak/lively), edge = NAVY (zero-line/calm). vec3 baseColor = vivify(mixHsl(uColorEdge, uColorAccent, fieldMix), amp); // Pre-vivified accent for the glass rim/sheen below, so those highlights are vivid moss // rather than the dull raw token (the rim is the strongest glass cue — keep it punchy). vec3 vividAccent = vivify(uColorAccent, 1.0); // ── EFFECT 4: glass (§4f) — specular + Fresnel + frosted + refraction, all in-shader. // Fixed virtual light from the upper-left; view direction is straight at the screen. vec3 N = vec3(normal, 0.6); // lift normal off the plane so it has a z-face N = normalize(N); vec3 L = normalize(vec3(-0.5, 0.7, 0.8)); // light: upper-left, toward viewer vec3 V = vec3(0.0, 0.0, 1.0); // view: straight on vec3 Hh = normalize(L + V); // Blinn-Phong half-vector // (1) Refraction read: where the surface curves (near the edge), warp the field // sample coords along the normal so the colour appears bent through the glass. // Strongest at the rim, vanishing in the flat interior (uses |grad| as curvature). float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0); vec2 warp = normal * GLASS_REFRACT_WARP * curvature; float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0); // Warped read uses the same VIVID HSL mix as the straight read, so refraction bends a // saturated colour through the lens rather than revealing the dull raw lerp. vec3 glassColor = vivify(mixHsl(uColorEdge, uColorAccent, warpMix), amp); glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read // (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the // half-vector aligns with the normal; drifts as blobs move (normal changes). float spec = pow(max(dot(N, Hh), 0.0), GLASS_SPECULAR_POWER); // (3) Broad sheen along the upper edge — a soft secondary gloss band. float sheen = pow(max(dot(N, L), 0.0), 2.0) * 0.25; // (4) Fresnel rim glow — brightest at grazing angles (silhouette edges). The single // most effective "this is glass" cue (spec §4f.4). float fresnel = pow(1.0 - max(dot(N, V), 0.0), GLASS_FRESNEL_POWER); // REMOVED (Phase 10 W1 de-noise, spec §3): the screen-space "frost" alpha noise — // frost = 0.85 + 0.15 * valueNoise(vec2(screenX*0.05, screenYTop*0.05)). It was a // static value-noise keyed to SCREEN coordinates (not the moving fluid), so it sat as // a fixed grainy/dirty film over the whole ribbon — exactly the "static-looking texture // that makes the screen look dirty" Daniel rejected. Alpha is now the clean backdrop // opacity with no grain. (Fluid-tied noise — the bubble swell and the colour-field drift, // both keyed to mix-time — is retained: it moves with the audio and does not read as dirt.) // Compose the lit glass colour: field base + warped refraction, lifted by sheen and // a Fresnel rim toward the VIVID moss accent, plus a white-hot specular dot. Using the // vivified accent (not the dull raw token) keeps the glass cues punchy and glassy. vec3 lit = glassColor; lit += sheen * vividAccent; lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss lit += spec * vec3(1.0); // specular is white light // Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light. // (Frost removed — see the de-noise note above.) Pre-multiplied output for the // ONE/ONE_MINUS_SRC_ALPHA blend. float alpha = inside * RIBBON_OPACITY; alpha = clamp(alpha + inside * fresnel * RIBBON_OPACITY * 0.8, 0.0, 1.0); fragColor = vec4(lit * 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() {}, setBubblyness() {}, setDetach() {}, setColorShiftSpeed() {}, 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. The Wave-3-reserved uniforms (`uTimeSeconds`, // `uBubblyness`, `uDetach`, `uColorShiftSpeed`) are declared and uploaded but not // yet consumed by the parity shader, so the compiler is free to dead-strip them; // we exempt them from the warning to avoid a false alarm. Their values still reach // the GPU when a location survives (verifiable in Wave 3). const RESERVED_UNUSED = new Set(['timeSeconds', 'bubblyness', 'detach', 'colorShiftSpeed']); const u = { resolution: gl.getUniformLocation(program, 'uResolution'), playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), bubblyness: gl.getUniformLocation(program, 'uBubblyness'), detach: gl.getUniformLocation(program, 'uDetach'), colorShiftSpeed: gl.getUniformLocation(program, 'uColorShiftSpeed'), 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 && !RESERVED_UNUSED.has(name)) { 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, pushWallClockMs: performance.now() }; let visibleSeconds = DEFAULT_VISIBLE_SECONDS; // Wave 2 control values, fed through the handle. Uploaded as uniforms in draw() but inert in the // parity shader (Wave 3 consumes them). Seeded to the defaults that mirror MixVisualizerControlState. let bubblyness = DEFAULT_BUBBLYNESS; let detach = DEFAULT_DETACH; let colorShiftSpeed = DEFAULT_COLOR_SHIFT_SPEED; /** * The *authoritative* playhead for this instant: the last pushed position advanced * by wall-clock elapsed since the push while playing, or the pushed position while * idle. The player remains the sole source of truth — this is display-only and is * never written back (read-only contract, spec §D / §5.10). This is the target the * rendered playhead converges onto; the shader uploads the *rendered* value (see * renderedPlayhead) so a re-anchor at a push doesn't snap on screen. */ function effectivePlayhead(): number { if (!playback.isPlaying) return playback.positionSeconds; const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000; return playback.positionSeconds + elapsedSeconds; } // ── Rendered-playhead reconciliation (startup-jitter fix). ─────────────────────── // // The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset, // where correctionOffset decays exponentially toward 0 each frame (time constant // PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the // authoritative target; without correction that re-anchor would teleport the // rendered playhead. Instead we *preserve the rendered position across the push* by // folding the discontinuity into correctionOffset (see setPlayback), then bleed it // off — turning each snap into a brief, sub-perceptible glide. // // Steady-state: when pushes are regular, the authoritative target barely moves at a // push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour // is then identical to uploading effectivePlayhead() directly (the prior renderer). let correctionOffset = 0; let lastRenderWallClockMs = performance.now(); /** * The playhead the shader actually scrolls to this frame. Equals the authoritative * effectivePlayhead() plus a correction offset that decays to zero, so the rendered * motion is continuous across the irregular startup pushes. Advances the decay by * real elapsed time since the previous render, making it frame-rate-independent * (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn * frame — it mutates the decay state. */ function renderedPlayhead(): number { const nowMs = performance.now(); const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000); lastRenderWallClockMs = nowMs; // Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate // independent — the fraction retained depends only on wall-clock dt, not frame // count. Snap tiny residuals to 0 (an exponential never reaches it). if (correctionOffset !== 0) { correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS); if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0; } return effectivePlayhead() + correctionOffset; } /** * Resolve the navy↔moss field poles from the live palette vars on the canvas. * * Detects light vs dark by the page background luminance, then binds each pole to * the var that carries that identity in the active palette (see the note above the * ResolvedTheme interface for why no single var works across both modes): * LIGHT: moss = --mud-palette-secondary (#3D7A68), navy = --mud-palette-primary (#17283f) * DARK: moss = --mud-palette-primary (#3D7A68), navy = --mud-palette-background (#0D1B2A) * This yields the maximal navy↔moss spread the field wants in either theme. */ function readTheme(): ResolvedTheme { const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8')); const isDark = luminance(background) < 0.5; const moss = isDark ? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68')) : parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68')); const navy = isDark ? background // the dark ground (#0D1B2A) IS the navy pole on dark : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); const resolved: ResolvedTheme = { accent: moss, edge: navy }; // Report BOTH poles the shader will actually use, as 0-255 RGB + relative luminance. // This is the line Daniel watches to confirm the "grey" cause: if the poles are dull // here (low luminance / low spread) the fix is the in-shader vivify(); if they look // saturated here the muddying was the old linear-RGB midpoint lerp (now HSL). const fmt = (c: [number, number, number]) => `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`; debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`); return resolved; } let theme: ResolvedTheme = readTheme(); let rafId: number | null = null; let disposed = false; const startTimeMs = performance.now(); // FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts // actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes // the two failure modes: a rate near the display refresh (~60) with the playhead // interpolated means motion is smooth; a rate near ~10 would mean the loop is gated // to the playback pushes instead of free-running. Reset when the loop (re)starts. let fpsFrameCount = 0; let fpsWindowStartMs = 0; // 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. The playhead is the wall-clock-interpolated value, not // the raw last-pushed position — that is what makes the scroll advance every // animation frame instead of stepping at Blazor's ~10 Hz push cadence. gl.uniform2f(u.resolution, canvas.width, canvas.height); gl.uniform1f(u.playheadSeconds, renderedPlayhead()); 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); // Wave 2 control uniforms. Uploaded every frame (cheap scalars); inert in the parity shader. // gl.uniform1f with a null location (dead-stripped uniform) is a documented silent no-op, so // these are safe to set unconditionally even before the Wave 3 shader references them. gl.uniform1f(u.bubblyness, bubblyness); gl.uniform1f(u.detach, detach); gl.uniform1f(u.colorShiftSpeed, colorShiftSpeed); 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. // // Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not // step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating // the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() // + the decaying jitter-correction offset), which advances the last pushed position by real time // elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds // monotonic clock is reserved for Wave 3's field/blob motion and is unused by this parity shader; // it is NOT what drives the scroll here.) /** 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; // Reset the FPS window so the first measured second reflects the run we're // starting, not a stale count from a previous play session. fpsFrameCount = 0; fpsWindowStartMs = performance.now(); // Re-base the decay clock to now so the first frame's dt is one frame, not the // (possibly long) idle gap since the last redrawOnce — otherwise a stale dt // would collapse the offset in one step. (Offset is 0 at play-start today, so // this is belt-and-braces, but it keeps the decay honest if that ever changes.) lastRenderWallClockMs = performance.now(); 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 wall-clock-interpolated playhead (effectivePlayhead, advancing * smoothly between the ~10 Hz pushes), 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(); // FPS tally: count this callback, and once per elapsed second emit the rate. // performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log // fires at most once/sec, so this adds no meaningful per-frame cost. if (DEBUG) { fpsFrameCount++; const nowMs = performance.now(); const windowMs = nowMs - fpsWindowStartMs; if (windowMs >= 1000) { const fps = (fpsFrameCount * 1000) / windowMs; debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`); fpsFrameCount = 0; fpsWindowStartMs = nowMs; } } 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; // Preserve on-screen continuity across the re-anchor. The rendered playhead // right now is effectivePlayhead() (old anchor) + correctionOffset; capture // it before we replace the anchor. We read effectivePlayhead() without going // through renderedPlayhead() so we don't advance the decay clock here — the // decay belongs to the render loop, ticked once per drawn frame. const renderedBefore = effectivePlayhead() + correctionOffset; // Anchor the pushed position to wall-clock NOW: the rAF loop interpolates // forward from here each frame (effectivePlayhead), so the scroll advances // smoothly between these ~10 Hz pushes. playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() }; // Fold the re-anchor discontinuity into the correction offset so the rendered // playhead doesn't jump: choose offset such that effectivePlayhead() (new // anchor) + offset == renderedBefore. The render loop then decays this offset // to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the // authoritative position. When pushes are regular the gap is ~0, so offset is // ~0 and steady-state matches the prior hard-anchor behaviour exactly. // // Only smooth while continuously playing. On a play/pause edge or while idle // we want the exact authoritative position, not a glide from a stale render: // a resume should land on the real position, and a paused still frame must be // truthful (read-only contract — never show a position the player isn't at). if (isPlaying && wasPlaying) { correctionOffset = renderedBefore - effectivePlayhead(); } else { correctionOffset = 0; } 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)); // While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the // loop is stopped (spec §E), so a zoom change must force one still frame here or the new // span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never. const idleRedraw = !playback.isPlaying; debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`); if (idleRedraw) redrawOnce(); }, // The three Wave 2 controls. Each clamps to [0,1], stores the value (uploaded as a uniform in // draw()), and forces one still frame while idle — mirroring setZoom — so the new value reaches // the GPU even when paused. INERT in Wave 2: the parity shader does not read these uniforms, so // a change does not visibly alter the render; the value is verifiable in Wave 3. setBubblyness(value: number): void { bubblyness = Math.min(1, Math.max(0, value)); if (!playback.isPlaying) redrawOnce(); }, setDetach(value: number): void { detach = Math.min(1, Math.max(0, value)); if (!playback.isPlaying) redrawOnce(); }, setColorShiftSpeed(value: number): void { colorShiftSpeed = Math.min(1, Math.max(0, value)); 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); }, }; }