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

1518 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MixVisualizer — the scrolling Mix waveform background (Phase 10, Waves 13).
*
* 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);
// Frosted translucency: a subtle noise modulation of alpha so edges read soft/lit
// rather than hard — the "frosted glass" cue done in-shader (no CSS blur, §4f.3).
float frost = 0.85 + 0.15 * valueNoise(vec2(screenX * 0.05, screenYTop * 0.05));
// 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, and
// softened by the frost. Pre-multiplied output for the ONE/ONE_MINUS_SRC_ALPHA blend.
float alpha = inside * RIBBON_OPACITY * frost;
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 409616384),
* and `texImage2D(…, width=N, height=1, …)` is rejected outright
* ("Requested size at this level is unsupported"), leaving the waveform texture
* uncreated and the ribbon blank. Laying the N samples row-major across a grid
* of width = min(N, safeWidth) keeps every dimension well within the limit.
*
* Filtering: the shader reads with texelFetch and does its own time-axis
* interpolation (see sampleAt), so NEAREST is correct here — hardware LINEAR on
* a 2-D grid would bleed across the row-wrap seam. The final row is zero-padded
* (texture init is zero-filled, then we overwrite the real samples); padding is
* never read because sampleAt clamps the index to sampleCount-1.
*/
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
if (durationSeconds <= 0 || !samplesBase64) {
// Expected before the player reports a duration: the bridge pushes an empty
// datum until then. Not an error, but worth seeing while diagnosing.
debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
return null;
}
const samples = decodeSamples(samplesBase64);
const sampleCount = samples.length;
if (sampleCount === 0) {
console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
return null;
}
// Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well
// under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we
// still clamp it to the actual max in case a driver reports something smaller.
const SAFE_WIDTH = 4096;
const texWidth = Math.min(sampleCount, Math.min(SAFE_WIDTH, maxTextureSize));
const texHeight = Math.ceil(sampleCount / texWidth);
debugLog(
`uploadDatum — ${sampleCount} samples for ${durationSeconds.toFixed(2)}s mix ` +
`(${(sampleCount / durationSeconds).toFixed(1)} samples/s); ` +
`datum texture ${texWidth}x${texHeight} for N=${sampleCount} samples, maxTextureSize=${maxTextureSize}.`,
);
// Pad the final partial row with zeros so the full grid uploads in one call.
const padded = texWidth * texHeight === sampleCount
? samples
: (() => {
const buf = new Uint8Array(texWidth * texHeight);
buf.set(samples);
return buf;
})();
const texture = gl.createTexture();
if (!texture) return null;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// R8 rows are 1-byte-per-texel and texWidth is not guaranteed 4-aligned;
// relax the default 4-byte unpack alignment so rows aren't read with stride
// padding the source array doesn't have.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.R8,
texWidth, texHeight, 0,
gl.RED, gl.UNSIGNED_BYTE, padded,
);
// NEAREST: texelFetch ignores the filter anyway, but be honest about it — the
// shader interpolates manually to avoid the row-wrap seam (see sampleAt).
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null);
return { texture, texWidth, texHeight, sampleCount, durationSeconds };
}
return {
setDatum(samplesBase64: string, durationSeconds: number): void {
debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
// Free the previous datum's GPU texture before replacing it (no leak
// across re-pushes / mix changes — spec §5.11).
if (datum) {
gl.deleteTexture(datum.texture);
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — refresh the still slice immediately
// when idle. If playing, the running loop picks it up next frame.
if (!playback.isPlaying) redrawOnce();
},
setPlayback(positionSeconds: number, isPlaying: boolean): void {
const wasPlaying = playback.isPlaying;
// 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);
},
};
}