/** * WaveformVisualizer — the scrolling waveform background (Phase 10 + Lava Reframe). * * 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, plus (as of the Lava Reframe Wave R2) a * CPU-side per-frame PHYSICS step that drives the wax lava. The scroll/zoom geometry * and the loudness-datum-as-texture sampling carry forward from Phase 10 Waves 1–2; * the playhead wall-clock interpolation + jitter correction carry forward as-is. * * THE LAVA (Wave R2 — this revision): * The rejected analytic-metaball "lava" (scripted blobs that read as giant * disconnected circles) is replaced by a real Lagrangian wax-blob simulation: * • 16–32 blobs carry position / velocity / temperature / radius and are * integrated each frame with real dt (gravity, temperature-buoyancy, viscous * damping, soft floor contact) — see stepPhysics(). * • 2D ELASTIC COLLISION: blob↔waveform (the ribbon is a read-only boundary the * wax is pushed out of along its surface normal) and blob↔blob, both with a * soft↔hard strength dial — see stepPhysics()'s collision passes. * • The blobs upload as a uBlobs[] uniform array; the fragment shader unions them * with smin metaballs + the waveform SDF into one liquid surface (liquidSdf). * Colour (Wave R3) is the OKLab THREE-COLOUR gradient: A→B linear from the centre line * outward, A and B rotating among three theme anchors (navy/moss/off-white) at the * gradient-rotation dial's rate, with a per-segment mix-time sinusoid (colour "waves" * baked per segment) and a per-bar curve shift (A-dominant low → B-dominant high). OKLab * keeps the blend faithful — no HSL cyan excursion. No glass, no screen-space noise (R1). * * The Blazor component owns the canvas element and the inputs (datum, playback, * scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop, * the physics step, and all the GL math. The component drives it through the handle * returned by `create`. As of Phase 10 the handle exposes EIGHT dedicated control setters * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount / * setFluidViscosity / setCollisionStrength / setWaveformWidth) — the single density knob is split into * fluid-amount + fluid-viscosity. As of Wave R3 the * gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation. * * PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and * the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio * is paused; only the waveform scroll/playhead freezes (effectivePlayhead() holds the static pushed * position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose. */ // ── 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; // ── Control tuning anchors. These mirror the C#-side defaults in ────────────────── // WaveformVisualizerControlState.cs — keep the two in sync, exactly as the // DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All seven are // normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it // reaches setScrollSpeed; it arrives here already in seconds). // // Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The // single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping: // • Scroll speed → visible time-span / scroll rate (setScrollSpeed) // • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE // as of Wave R3; drives the OKLab gradient's anchor rotation // • Lava gravity → gravity dial (setLavaGravity) // • Lava heat → heat dial (setLavaHeat) // • Fluid amount → blob count + per-blob volume (setFluidAmount) // • Fluid viscosity/cohesion → sphere-restoration: smin blend + wobble (setFluidViscosity) // • Collision strength → collision hardness dial (setCollisionStrength) // • Waveform width → ribbon half-width uniform (setWaveformWidth) // The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he // tunes on screen from here. /** Default GRAVITY dial. Mirrors C# DefaultLavaGravity. * Tuned to Daniel's sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */ export const DEFAULT_LAVA_GRAVITY = 0.2; /** Default HEAT dial. Mirrors C# DefaultLavaHeat. * Tuned to Daniel's sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */ export const DEFAULT_LAVA_HEAT = 1.0; /** Default COLLISION-STRENGTH dial. Mirrors C# DefaultCollisionStrength. * Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */ export const DEFAULT_COLLISION_STRENGTH = 0.5; /** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10 * split): how much wax is in the container — blob count + per-blob volume. 0 = few small blobs, * 1 = many larger blobs (more fluid). */ export const DEFAULT_FLUID_AMOUNT = 0.4; /** Default FLUID VISCOSITY / COHESION. Mirrors C# DefaultFluidViscosity. The "bubbles" knob's second * half (Phase 10 split): how strongly the wax holds a spherical shape. 1 = high cohesion (crisp * spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged under inertia). * Default leans cohesive so the at-rest look is rounded wax. */ export const DEFAULT_FLUID_VISCOSITY = 0.6; /** * Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized * [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at * which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z). */ export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.45; /** * Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor * step X→Y, so 3 ring-units is a full X→Y→Z→X cycle). 0.18 → a full three-colour cycle in * ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end. * Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead. */ // Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full // three-colour cycle took ~17 s at full dial and ~49 s at the 0.3 default — below the threshold of // "this is moving". Raised so the dial has obvious effect: 0.6 → a full cycle in ~5 s at full speed, // and the default (now 0.45) cycles in ~7 s — clearly rotating, still meditative not strobing. const GRADIENT_ROTATION_RATE_MAX = 0.6; const GRADIENT_ROTATION_RATE_MIN = 0.03; /** * Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful * 10%–95% ribbon-extent band (Phase 10 §3.7 — see effectiveWaveformWidth); 0.5 opens mid-band. */ export const DEFAULT_WAVEFORM_WIDTH = 0.5; /** * 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; /** * 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; // ════════════════════════════════════════════════════════════════════════════════════ // R2 — the wax-blob lava physics (CPU step + uniform upload). The lava is now a real // Lagrangian particle system integrated each frame on the JS side and rendered as // smin metaballs in the fragment shader. EVERYTHING below is the tuning surface for // the lava look; Daniel reads + tunes these. // // Coordinate convention (shared with the shader): physics runs in HEIGHT-NORMALIZED // space — every position/velocity/radius is in units of (pixel / canvasHeight). So // y ∈ [0, 1] top→bottom, x ∈ [0, W/H], and the FLOOR (footer / lava rest line) is the // bottom edge of the canvas at y = 1 (the canvas is already CSS-clipped to the footer // top in R1, so its own bottom edge IS the footer line — no extra clip uniform needed). // Using one isotropic unit keeps blobs round and collisions correct at any aspect. // ════════════════════════════════════════════════════════════════════════════════════ /** * Hard upper bound on the simulated/rendered blob population. The per-fragment shader * loops to this constant, so it caps GPU cost; the CPU step is O(MAX_BLOBS²) for * blob↔blob (≤ ~1k pair tests — trivial). 32 is the spec's upper band (§4g). The live * count varies with the density dial but never exceeds this. */ const MAX_BLOBS = 32; /** Lower end of the live blob count (density dial 0 → a few large lazy blobs, §4e). */ const MIN_BLOB_COUNT = 16; /** Blob radius band in height-normalized units. 0.025·H ≈ 20px and 0.13·H ≈ 100px on a * ~760px-tall canvas — the spec's ~20–100px range (§4b). Each blob picks a fixed radius * in this band at construction; the density dial biases the average. */ const BLOB_RADIUS_MIN = 0.025; const BLOB_RADIUS_MAX = 0.13; /** * Gravity acceleration at the gravity dial = 1, in height-units / s². Downward (+y). * Tuned so wax at full gravity falls back to the floor in well under a second from * mid-screen (0.5 height) — a firm settle — while still letting buoyancy win when hot. * The dial scales this 0→1 linearly (dial 0 = near-weightless float, §4d). */ const GRAVITY_ACCEL_MAX = 2.2; /** Floor of gravity even at dial 0, so wax never becomes truly weightless (always settles). */ const GRAVITY_ACCEL_MIN = 0.15; /** * Buoyancy lift coefficient: upward accel = BUOYANCY_COEFF · heatScale · (T − T_ambient). * Hot wax (T high) rises; cool wax (T low) sinks. This is the OTHER half of the lamp's * core tension against gravity. Tuned against GRAVITY_ACCEL_MAX so that at full heat a * hot blob (T≈1) overcomes mid-gravity and climbs, and at heat 0 (heatScale 0) buoyancy * vanishes entirely → wax just obeys gravity and rests on the floor (spec §4c endpoint). */ const BUOYANCY_COEFF = 4.0; const TEMP_AMBIENT = 0.5; // the neutral temperature; above it lifts, below it sinks /** * Heat transfer rates (per second), the engine of the convection cycle: * - near the FLOOR a blob HEATS toward 1 (the "lamp bulb" at the bottom), * - near the TOP it COOLS toward 0 (loses heat at the cold cap), * - everywhere it relaxes gently toward ambient. * heatScale (the heat dial's transfer-function output, see heatScaleFromDial) gates the * floor-heating rate: at dial 0 the floor adds NO heat, so nothing ever becomes buoyant * and the pool rests; at dial 1 the floor pumps heat fast → many blobs go buoyant and * rise per second (the busy roiling lamp, §4c max endpoint). */ const HEAT_FLOOR_RATE = 1.6; // °/s toward T=1 when sitting on the floor (× heatScale) const HEAT_TOP_RATE = 1.2; // °/s toward T=0 when near the top const HEAT_AMBIENT_RATE = 0.25; // °/s relaxation toward ambient everywhere const HEAT_FLOOR_ZONE = 0.16; // height-fraction above the floor counted as "hot zone" const HEAT_TOP_ZONE = 0.16; // height-fraction below the top counted as "cold zone" /** * Viscous (linear) velocity damping per second. Applied as v *= exp(−DAMPING·dt) each * step, so it is frame-rate independent. * * R2 tuning (Daniel #2 — "too viscous, needs to melt into a single fluid"): dropped from * 1.4 to 0.55 so the wax is markedly LESS stiff and flows together instead of holding as * distinct globs. Combined with the larger smin coalescence (BLOB_SMOOTHMIN_K below) and * the elastic throw, the surface now reads as a unified fluid body rather than separate * stiff blobs. Still > water so it stays a lazy lava, not a splash. */ const VISCOUS_DAMPING = 0.55; /** * Hard speed clamp in height-units/s, applied after every substep. R2 jitter fix (Daniel * #5): with the lower viscosity + higher elasticity, a deep overlap resolved by the elastic * impulse could occasionally fling a blob fast enough to tunnel and re-collide next step — * the buzz. Capping the per-axis speed keeps the integrator stable (no explosive feedback) * while being far above any speed real convection produces, so it never throttles the look. */ const MAX_BLOB_SPEED = 2.5; /** * Soft floor contact: instead of a hard clamp that jitters, a resting blob is pushed up * by a spring proportional to its penetration below the floor, and its downward velocity * is killed on contact so pooled wax flattens and settles rather than bouncing forever. */ const FLOOR_SPRING = 26.0; // restoring accel per unit penetration (height-units/s²) const FLOOR_CONTACT_DAMPING = 6.0; // extra damping applied while in floor contact (settle) /** * Blob↔blob collision: the soft↔hard knob (collision strength) blends a penalty SPRING * (soft displacement, blobs squish and partially overlap then ease apart) toward an * elastic IMPULSE (hard, crisp restitution along the centre line). These are the two * endpoints the strength dial interpolates (§5c). Restitution is the bounciness of the * hard end; the spring stiffness is the firmness of the soft end. */ const BLOB_COLLIDE_SPRING = 9.0; // soft penalty stiffness (height-units/s² per overlap) const BLOB_RESTITUTION_HARD = 1.15; // elastic restitution at strength = 1 — over-unity = the springy "throw" (Daniel #6) const BLOB_RESTITUTION_SOFT = 0.05; // residual restitution at strength = 0 (almost pure mush, Daniel #3) /** * Blob↔waveform collision (always on, independent of heat — §5b). The waveform's * half-width at a blob's row is sampled CPU-side each frame; a blob whose centre is * within (halfWidth + radius) of the centre line is penetrating the ribbon and is pushed * out along the surface normal. Same soft↔hard blend as blob↔blob: a penalty spring at * the soft end → elastic reflection of the inward velocity at the hard end. The waveform * is read-only authority: it pushes the fluid, the fluid never moves it. */ // Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax). // Restitution is now SUB-unity: a real bounce conserves-or-loses energy, never adds it — // over-unity (the old 1.1) injected energy each contact and read as "explosive". 0.85 at the hard end // is lively/springy; the soft end stays near-zero (mush). const WAVE_COLLIDE_SPRING = 10.0; // soft penalty stiffness pushing wax off the ribbon (slightly softer) const WAVE_RESTITUTION_HARD = 0.85; // springy but energy-bounded reflection at full hardness (no explosion) const WAVE_RESTITUTION_SOFT = 0.05; // near-pure mush at the soft end (Daniel #3) /** * Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates * the ribbon we add a small UPWARD (−y) nudge so loud transients lift bubbles toward the surface * rather than only shoving them sideways. * * Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration × * hardness × dt, accumulated on a sustained loud passage and launched bubbles off-screen — the * "explosive" feel. Cut to a gentle lift and CAPPED per contact (see the clamp in stepPhysics) so a * deep/sustained overlap can't pump unbounded upward speed. Reads as a bouncy bob, not a rocket. */ const WAVE_THROW_UP = 9.0; /** Hard cap on the per-contact upward throw velocity (height-units/s) so a sustained loud transient * can never accumulate into an off-screen launch. Well above a natural bob, far below escape speed. */ const WAVE_THROW_UP_MAX = 0.6; /** * Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a * blob tunnel through the floor or another blob in one step (explosive overlap). We clamp * dt so the integrator stays stable — a long stall just means the sim advances a little * slowly that frame, which is invisible. (We also sub-step within this cap below.) */ const PHYSICS_MAX_DT = 1 / 30; /** * Sub-steps per frame: splitting dt makes the spring/penalty collisions stiffer-stable. * R2 jitter fix (Daniel #5): raised 2 → 4. The lower viscosity + higher restitution make * the contact response stiffer relative to the step, so each frame's dt is now resolved in * four smaller passes — the collisions settle smoothly instead of buzzing. 4 × ≤32² pair * tests ≈ 4k/frame, still trivial, and the frame budget is untouched (FPS holds at 60). */ const PHYSICS_SUBSTEPS = 4; /** * Energy-coupled dynamics (Daniel #7 — "at higher heat, bubbles are SMALLER and move with * MORE TURBULENCE"). Heat (the heatScale transfer output) drives two effects each step: * * • SIZE: a blob's effective radius shrinks toward HEAT_RADIUS_MIN_SCALE of its base radius * as it heats. High heat ⇒ a swarm of small lively bubbles; low heat ⇒ fewer, larger, * calmer wax. The shrink is applied to the SIMULATED radius (so collisions match what's * drawn) and tracks temperature continuously, so a blob grows back as it cools at the top. * * • TURBULENCE: a divergence-free-ish curl of value-noise injects a small random velocity * each step, scaled by heatScale × the blob's own temperature, so only HOT wax churns. * This is what makes high heat read as turbulent and low heat as a calm pool. */ const HEAT_RADIUS_MIN_SCALE = 0.45; // hottest blob shrinks to 45% of its base radius const TURBULENCE_ACCEL = 3.2; // peak turbulent accel (height-units/s²) at full heat × full temp const TURBULENCE_RATE = 1.9; // how fast the turbulence field evolves (rad/s scale) /** * 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 `[WaveformVisualizer]`). 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: defaults to false; set true temporarily to surface verbose seams in-browser. const DEBUG = false; const TAG = '[WaveformVisualizer]'; function debugLog(...args: unknown[]): void { if (DEBUG) console.log(TAG, ...args); } // ── Theme: the THREE colour anchors (X, Y, Z), read live from the active 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 R3 binding — the THREE anchors of the OKLab gradient (spec §6a/§6b motion 1). // The gradient's two stops (A = centre/root, B = outer/edge) ROTATE among these three // over time. The palette's signature triad is navy / moss / off-white — the identity // `DeepDrftPalettes` is built on (see the class doc comment there). All three are read // from the live palette vars (single source of truth — spec §6a): no hardcoded hexes. // - X = NAVY (the dark ground / navy-mid) // - Y = MOSS (the interactive green) // - Z = OFF-WHITE (the warm paper ground) — the chosen third anchor (surfaced in handoff) // // The cross-mode problem (spec §6a, explicit): navy / moss / off-white are NOT a single // stable set 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); off-white is the // `--mud-palette-background` ground in LIGHT but `--mud-palette-secondary` in DARK. No one // var holds a given identity 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 three anchors per mode. refreshTheme re-runs this on // a dark-mode toggle, so the field re-themes live. interface ResolvedTheme { /** Navy anchor (X) RGB [0,1] — uploaded to uColorNavy. */ navy: [number, number, number]; /** Moss-green anchor (Y) RGB [0,1] — uploaded to uColorMoss. */ moss: [number, number, number]; /** Off-white anchor (Z) RGB [0,1] — uploaded to uColorPaper. */ paper: [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; /** * The decoded loudness bytes [0,255], retained for CPU-side sampling by the physics * step (the waveform-collision boundary is sampled per blob per frame — R2 §5). The * GPU has its own copy in `texture`; this is the CPU mirror, kept because re-reading * the texture back from the GPU each frame would be a stall. */ samples: Uint8Array; } 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 whether the playhead ADVANCES (scroll) or HOLDS * (freeze) — NOT whether the rAF loop runs (the loop is continuous now, Part C). */ 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 WaveformVisualizerHandle { setDatum(samplesBase64: string, durationSeconds: number): void; setPlayback(positionSeconds: number, isPlaying: boolean): void; /** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */ setScrollSpeed(visibleSeconds: number): void; /** [0,1]. Colour anchor-rotation rate — drives the OKLab gradient's Motion 1 (live, R3). */ setGradientRotationSpeed(value: number): void; /** [0,1]. Downward force on the wax. */ setLavaGravity(value: number): void; /** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */ setLavaHeat(value: number): void; /** [0,1]. Amount of wax — blob count + per-blob volume. */ setFluidAmount(value: number): void; /** [0,1]. Fluid viscosity / cohesion — how strongly wax restores to a sphere (1) vs stays * deformed/gooey (0). Drives the metaball smin blend + wobble; no per-fragment cost change. */ setFluidViscosity(value: number): void; /** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */ setCollisionStrength(value: number): void; /** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */ setWaveformWidth(value: number): void; /** * Enable/disable the LAVA subsystem (Phase 15). When disabled the wax is genuinely NOT rendered: * the per-frame physics step is skipped and zero blobs are uploaded (uBlobCount = 0), so the * shader's blob loop unions nothing — no render cost, not a dimmed/visible=false uniform (§10.1). */ setLavaEnabled(enabled: boolean): void; /** * Enable/disable the WAVEFORM-ribbon subsystem (Phase 15). When disabled the ribbon SDF is skipped * in the shader (uWaveformEnabled = 0 makes waveformSdf return "fully outside") and its CPU * collision boundary is dropped (sampleLoudnessAt reads 0), so the ribbon contributes nothing to * the surface and the wax stops bouncing off an invisible wall — a genuine skip, not a dim (§10.1). */ setWaveformEnabled(enabled: boolean): 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 uniform float uVisibleSeconds; // zoom: window time-span (per change) uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room) uniform float uWaveformEnabled; // [0,1] Phase 15: 1 = ribbon drawn, 0 = ribbon subsystem skipped (no // contribution to the surface — see waveformSdf's early-out) uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres, // low = gooey/deformed (drives the smin blend width + wobble below) // NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms // in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old // uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason; // the JS handle still receives those control values and routes them to the physics (the // R2 TEMP knob re-mapping documented at the control-default consts above). uniform float uDurationSeconds; // mix length (per datum) // R3 — the three OKLab gradient anchors (X/Y/Z), read live from the palette (per theme). uniform vec3 uColorNavy; // X — navy anchor uniform vec3 uColorMoss; // Y — moss anchor uniform vec3 uColorPaper; // Z — off-white anchor // R3 — gradient anchor-rotation PHASE (radians), integrated CPU-side from the same // uTimeSeconds clock at the rotation-speed dial's rate (so a speed change never snaps the // phase). Drives Motion 1: which two of the three anchors A and B are right now (spec §6b). uniform float uGradientPhase; 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) // ── R2 wax-blob uniforms (the CPU physics step uploads these every frame). ────────── // Each blob is packed as a vec4: xy = centre in HEIGHT-NORMALIZED space (pixel/H, so // y is 0 at the top edge and 1 at the footer/floor, x spans [0, W/H]); z = radius in // the SAME height-normalized unit (so circles are round on screen); w = temperature // 0..1 (drives the warm tint on hot rising wax). uBlobCount is how many of the // MAX_BLOBS slots are live this frame. Working in height-normalized units keeps the // metaball SDF isotropic regardless of the canvas aspect ratio. const int MAX_BLOBS = ${MAX_BLOBS}; uniform vec4 uBlobs[MAX_BLOBS]; uniform int uBlobCount; out vec4 fragColor; const float NOW_ANCHOR_FROM_TOP = ${NOW_ANCHOR_FROM_TOP.toFixed(4)}; const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)}; // ── R2 in-shader tuning constants (Daniel tunes by editing here). ─────────────────── // Background opacity of the wax + waveform fill. Kept simple/serviceable for R2 — the // beautiful OKLab three-colour gradient is Wave R3. Just enough to read the physics. const float RIBBON_OPACITY_R2 = 0.62; // smin blend radius for the wax metaball union, in height-normalized units. Larger = the // "necks" where two blobs merge are fatter → a gooier, more-connected wax that splits and // recombines (the organic non-circular look the spec wants, §4b). This + varied radii are // what kill the "giant disconnected circles" failure. // R2 (Daniel #2 — "melt into a single fluid"): raised 0.045 → 0.085 so neighbouring blobs // fuse into one continuous fluid body across wider gaps rather than reading as separate globs. const float BLOB_SMOOTHMIN_K = 0.085; // smin blend radius for merging the wax into the WAVEFORM ribbon, so resting/pooled wax // reads as continuous with the ribbon surface rather than a disc sitting on a wall. // R2: raised 0.03 → 0.055 to match the fattier wax-union neck (continuous fluid surface). const float WAVE_SMOOTHMIN_K = 0.055; // Low-frequency, blob-tied radius wobble: a slow per-blob breathing so each wax shape is // organic, not a perfect circle (§4b). This is FLUID-tied noise (keyed to blob identity + // the wall clock), NOT the screen-space "dirt" R1 removed (§3) — it travels with the wax. const float BLOB_WOBBLE_AMOUNT = 0.12; // ± fraction of radius const float BLOB_WOBBLE_RATE = 0.7; // breathing speed (rad/s scale) // Warm tint on hot, rising wax. A hot blob (temperature → 1) shifts slightly toward a // warm highlight so the eye reads "this one is rising"; cool wax stays the cool field // colour. Subordinate to the R3 gradient (spec §4f) — kept subtle. const vec3 HOT_TINT = vec3(0.95, 0.72, 0.45); // warm amber the hottest wax leans toward const float HOT_TINT_AMOUNT = 0.25; // max lean at temperature 1 (above ambient) // ── R3 colour-gradient tuning (the three motions, spec §6b). Daniel tunes by editing here. ── // Motion 1 — the phase OFFSET between anchor A (centre/root) and anchor B (outer/edge) as // they rotate among the three anchors. A non-zero offset means A and B sit at different // points on the X→Y→Z ring, so the gradient always spans two distinct colours rather than // collapsing to one. 1.0 = a full one-anchor lead (e.g. A on navy while B is on moss). const float GRADIENT_AB_PHASE_OFFSET = 1.0; // Motion 2 — per-bar sinusoidal variation, KEYED TO MIX-TIME (spec §6b motion 2, decided // realization). Because mix-time is fixed for a given segment, the sinusoid is a pure // function of mix-time and therefore baked-per-segment by construction: it travels with the // segment as it scrolls, no ring buffer. AMOUNT is the ± phase wobble it adds to the anchor // rotation (in ring units); FREQ is how many colour "waves" pack into one second of mix. const float SEG_WAVE_AMOUNT = 0.35; // ± ring-phase wobble per segment const float SEG_WAVE_FREQ = 1.7; // colour waves per second of mix-time // Motion 3 — per-bar gradient CURVE shift with scroll height (spec §6b motion 3). A bar is // mostly A at the bottom and mostly B by the top: we bias the centre→outer A→B mix toward A // low on screen and toward B high on screen, so colour appears to climb outward as the bar // scrolls up. This is the max ± shift applied to the A→B mix fraction across the screen. const float CURVE_SHIFT_AMOUNT = 0.45; // 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); // Smootherstep (C1-continuous Hermite) blend between the two bracketing samples instead of a // straight linear lerp. Linear reconstruction connects samples with straight segments, so the // ribbon edge reads as faceted polygons; the Hermite ease gives a smooth sinusoid-shaped contour // between samples with zero slope at each sample point (Phase 10 tuning — smooth, not polygonal). float fs = f * f * (3.0 - 2.0 * f); return mix(fetchSample(i0), fetchSample(i1), fs); } // ════════════════════════════════════════════════════════════════════════════════════ // R2 — wax metaballs + waveform SDF. The blobs are integrated on the CPU (see the JS // physics step) and uploaded as uBlobs[]; the shader composites them with smin and the // waveform ribbon into one liquid surface, then shades it with a simple theme fill. // ════════════════════════════════════════════════════════════════════════════════════ // ── Value-noise (used now only for the organic, blob-tied radius wobble). ──────────── // A standard hash → smooth value-noise. Cheap (a few mixes), no texture lookup, and // continuous. Fed blob-identity + the wall clock it gives each wax shape its own slow // breathing so the silhouette is organic rather than a perfect circle (§4b). 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); } // ── Signed-distance primitives + smooth-min (the metaball machinery). ─────────────── // Circle SDF (a metaball centre) — the wax blob primitive. 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); } // ── OKLab colour interpolation (spec §6c — replaces the rejected HSL mixHsl/vivify). ─ // // OKLab is a perceptually-uniform space (Björn Ottosson). A straight line between two // colours in OKLab stays perceptually faithful — no hue drift, no saturation pumping, no // rainbow excursion. That is the structural fix for the navy→moss cyan bug: HSL hue-lerp // between blue and green passes through cyan; OKLab does not. We convert each anchor // linear-sRGB → OKLab, mix() in OKLab, convert back — all per-fragment (cheap: a cube // root and two 3×3 matmuls each way). // // The uColor* uniforms arrive as GAMMA sRGB [0,1] (parsed straight from the CSS hex), so // we linearise on the way in and re-encode gamma on the way out. float srgbToLinear(float c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); } float linearToSrgb(float c) { return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1.0 / 2.4) - 0.055; } vec3 srgbToLinear3(vec3 c) { return vec3(srgbToLinear(c.r), srgbToLinear(c.g), srgbToLinear(c.b)); } vec3 linearToSrgb3(vec3 c) { return vec3(linearToSrgb(c.r), linearToSrgb(c.g), linearToSrgb(c.b)); } // linear-sRGB → OKLab (Ottosson's standard matrices). vec3 linearToOklab(vec3 c) { float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b; float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b; float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b; float l_ = pow(l, 1.0 / 3.0); float m_ = pow(m, 1.0 / 3.0); float s_ = pow(s, 1.0 / 3.0); return vec3( 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ ); } // OKLab → linear-sRGB (the inverse). vec3 oklabToLinear(vec3 lab) { float l_ = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z; float m_ = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z; float s_ = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z; float l = l_ * l_ * l_; float m = m_ * m_ * m_; float s = s_ * s_ * s_; return vec3( 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s ); } // Chroma (vibrancy) boost in OKLab (Phase 10 — Daniel: "colours too muted, more punch"). OKLab's L // is lightness; (a,b) is the chroma vector. Scaling (a,b) about the neutral axis raises saturation // while preserving hue (the a:b ratio) and lightness (L untouched), so the palette-sourced navy/moss/ // off-white stay themselves — just more vivid. No hardcoded hexes: the anchors remain the live palette // vars (spec §6a), this only amplifies their existing chroma. >1 = more punch. const float CHROMA_BOOST = 1.45; vec3 vivifyOklab(vec3 lab) { return vec3(lab.x, lab.y * CHROMA_BOOST, lab.z * CHROMA_BOOST); } // Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → boost chroma → lerp → back to gamma // sRGB. The chroma boost gives the gradient punch (Phase 10) while OKLab keeps the blend faithful. vec3 mixOklab(vec3 a, vec3 b, float t) { vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a))); vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b))); vec3 m = mix(la, lb, t); return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0); } // One of the three anchors as a continuous function of a phase in [0,3): a triangular // blend around the ring X→Y→Z→X so the picked anchor travels smoothly through all three // (OKLab-interpolated, so the transitions stay faithful). phase need not be wrapped — // we fract it to the ring here. vec3 anchorAtPhase(float phase) { float p = fract(phase / 3.0) * 3.0; // [0,3) float seg = floor(p); // 0,1,2 float f = p - seg; // [0,1) within the segment if (seg < 0.5) return mixOklab(uColorNavy, uColorMoss, f); else if (seg < 1.5) return mixOklab(uColorMoss, uColorPaper, f); else return mixOklab(uColorPaper, uColorNavy, f); } // ── The waveform ribbon SDF, in HEIGHT-NORMALIZED space (negative inside). ────────── // // The waveform is the same symmetric ±loudness ribbon about the centre line as before, // but evaluated in height-normalized coords (pixel/H) so it shares one space with the // wax blobs. p = (x, y) where x ∈ [0, W/H] across the canvas and y ∈ [0, 1] top→bottom. // We map the row's mix-time → loudness → a half-width about the centre x, and return the // distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked // here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon. float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) { // Phase 15: ribbon subsystem off → return "fully outside" so the smin union ignores it entirely // (a far positive distance never pulls the surface toward the centre line). This is the genuine // skip — the ribbon contributes nothing, rather than being drawn-then-hidden. if (uWaveformEnabled < 0.5) return 1e9; // Mix-time at this row: rows below the now-line are future audio, above are past. float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; float amp = sampleAt(t); // loudness 0..1 at this row float centreX = aspect * 0.5; // canvas centre x in height-norm units // Ribbon half-width here, scaled by the waveform-width dial (R2 #8): at width 1 it is the // full prior band; lower narrows it so the lava fluid gets more room on loud songs. float halfW = amp * (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * uWaveformWidth; // Distance to a centred vertical band of half-width halfW: |x − centre| − halfW. // Negative inside the band, positive outside. (A pure horizontal band; the vertical // extent is the whole column, which is what the scrolling ribbon is.) return abs(p.x - centreX) - halfW; } // ── The combined wax + waveform liquid SDF at a height-normalized point. ───────────── // // Unions all live wax blobs (smin metaballs) and the waveform ribbon into one continuous // surface. The blob radii carry a slow blob-tied wobble so each is organic, not a perfect // circle. Returns the signed distance and, via out params, the nearest-blob temperature // (for the warm hot-wax tint) and whether the point is dominated by wax vs. ribbon. float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight, out float hotOut) { // Waveform ribbon first — the always-present base surface. float field = waveformSdf(p, aspect, nowYn, secondsPerHeight); float hotAccum = 0.0; float hotWeight = 0.0; // Phase 10 cohesion (viscosity knob): low cohesion → a wider smin neck (blobs fuse and stay // gooey/deformed) and more wobble (less sphere-like); high cohesion → a tight neck and minimal // wobble (crisp spheres that read as "snapped back to round"). Pure uniform scaling of the two // existing constants — no extra per-fragment loop iterations, so weaker hardware is unaffected. // Range chosen so cohesion 1 still keeps a small organic neck/wobble (never a hard-edged circle). float blobK = BLOB_SMOOTHMIN_K * (1.0 + (1.0 - uCohesion) * 1.4); // ×1.0 (crisp) → ×2.4 (gooey) float wobbleAmt = BLOB_WOBBLE_AMOUNT * (0.35 + (1.0 - uCohesion) * 1.4); // less wobble when cohesive // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set. for (int i = 0; i < MAX_BLOBS; i++) { if (i >= uBlobCount) break; vec4 b = uBlobs[i]; vec2 c = b.xy; // centre, height-norm float r = b.z; // radius, height-norm float temp = b.w; // temperature 0..1 // Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so // the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok). // Amount scaled by cohesion (low cohesion deforms more — Phase 10 viscosity split). float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5) * 2.0 * wobbleAmt; float rr = r * (1.0 + wob); float blob = sdCircle(p - c, rr); field = smin(field, blob, blobK); // Weight this blob's temperature by proximity so the tint follows the nearest wax. float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0); hotAccum += temp * prox; hotWeight += prox; } hotOut = hotWeight > 1e-3 ? hotAccum / hotWeight : 0.0; return field; } void main() { float w = uResolution.x; float h = uResolution.y; // Empty backdrop when there is no datum (no thin-centre-line artifact — Wave 1 note). if (uHasDatum < 0.5) { fragColor = vec4(0.0); return; } // Height-normalized fragment coordinate (pixel / H), top-left origin, y down. This is // the shared space the CPU physics works in — the blob uniforms are already in it. float aspect = w / h; // canvas width in height units vec2 p = vec2(gl_FragCoord.x / h, (h - gl_FragCoord.y) / h); float nowYn = NOW_ANCHOR_FROM_TOP; // now-line, height-norm (y ∈ [0,1]) float secondsPerHeight = uVisibleSeconds; // one full height spans uVisibleSeconds // ── Evaluate the combined liquid SDF only (no normal/gradient shading this wave). ── // R2 cone fix (Daniel #1): the previous build derived a surface NORMAL from the SDF // gradient and shaded the fill by it (mix(0.82, 1.12, lightUp)). On a metaball the // gradient points outward from each blob centre, so that shading lit a bright spot at // every centre and darkened the rims — the wax read as a CONE with a pointed bright tip, // not a flat fluid surface. We DROP the normal shading entirely: a metaball / fluid // surface is FLAT, distinguished only by a soft anti-aliased edge. (Form/colour is the // Wave R3 job; here we only flatten.) This also removes the 4 extra SDF evaluations the // central-difference gradient cost — a small frame-budget win. float hot; float d = liquidSdf(p, aspect, nowYn, secondsPerHeight, hot); // Inside-ness: SDF negative = inside. Feather ~1.2px (in height-norm units) for an // anti-aliased edge instead of a hard chart line (no blur — spec §2/§3). This soft edge // is the ONLY shading on the surface now — the body is flat. float pxFeather = 1.2 / h; float inside = 1.0 - smoothstep(-pxFeather, pxFeather, d); if (inside <= 0.0) { fragColor = vec4(0.0); return; } // ── R3 — the three-colour OKLab gradient (the three combined motions, spec §6b). ── // // The static structure is a LINEAR A→B from the centre line outward (A at the root, B at // the extended edge), with A and B drawn from the rotating three-anchor ring. On top of // that, three motions combine — all OKLab-interpolated, so no rainbow/cyan excursion. // Centre-outward fraction [0,1] for this fragment: 0 at the centre line (root), 1 at the // canvas edge. This is the axis the A→B linear runs along (spec §6b: "from the 0 centre // line outward along the waveform"). float xnAbs = clamp(abs(p.x - aspect * 0.5) / (aspect * 0.5), 0.0, 1.0); // Motion 2 — per-bar sinusoid keyed to MIX-TIME (spec §6b motion 2). The mix-time at this // fragment's row is identical to the waveform's row-time, so the sinusoid is fixed for a // given segment and travels up with it as it scrolls — baked-per-segment by construction, // no ring buffer. It nudges the anchor-ring phase, so neighbouring segments sit on slightly // different colours: "waves" of colour across the waveform rather than one uniform gradient. float segTime = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight; float segWave = sin(segTime * SEG_WAVE_FREQ * 6.2831853) * SEG_WAVE_AMOUNT; // Motion 1 — anchor rotation among X/Y/Z (spec §6b motion 1). uGradientPhase advances at // the gradient-rotation-speed dial's rate (CPU-integrated from uTimeSeconds). A leads B by // a fixed ring offset so the gradient always spans two distinct anchors. The per-segment // wave (Motion 2) is folded into the phase so each segment is offset on the ring. float phaseA = uGradientPhase + segWave; float phaseB = uGradientPhase + GRADIENT_AB_PHASE_OFFSET + segWave; vec3 colorA = anchorAtPhase(phaseA); // centre/root colour vec3 colorB = anchorAtPhase(phaseB); // outer/edge colour // Motion 3 — per-bar curve shift with scroll height (spec §6b motion 3). p.y is 0 at the // top and 1 at the floor; (0.5 - p.y) is +0.5 at the top, −0.5 at the floor. We shift the // A→B mix fraction toward A (negative) low on screen and toward B (positive) high on // screen, so a bar is mostly A at the bottom and mostly B by the top — colour climbs // outward as the bar scrolls up. float curveShift = (0.5 - p.y) * 2.0 * CURVE_SHIFT_AMOUNT; float mixFrac = clamp(xnAbs + curveShift, 0.0, 1.0); vec3 fill = mixOklab(colorA, colorB, mixFrac); // Warm tint on hot, rising wax so the eye reads convection (subordinate to the gradient, // spec §4f). A flat per-blob temperature lean — no spatial falloff, so no cone is reintroduced. float hotLean = clamp((hot - ${TEMP_AMBIENT.toFixed(2)}) * 2.0, 0.0, 1.0) * HOT_TINT_AMOUNT; fill = mix(fill, HOT_TINT, hotLean); float alpha = inside * RIBBON_OPACITY_R2; fragColor = vec4(fill * alpha, alpha); // pre-multiplied for ONE/ONE_MINUS_SRC_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('WaveformVisualizer: 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(`WaveformVisualizer: 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('WaveformVisualizer: 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(`WaveformVisualizer: program link failed: ${log}`); } return program; } /** The no-op handle returned when WebGL2 is unavailable or setup fails. */ function noopHandle(): WaveformVisualizerHandle { return { setDatum() {}, setPlayback() {}, setScrollSpeed() {}, setGradientRotationSpeed() {}, setLavaGravity() {}, setLavaHeat() {}, setFluidAmount() {}, setFluidViscosity() {}, setCollisionStrength() {}, setWaveformWidth() {}, setLavaEnabled() {}, setWaveformEnabled() {}, refreshTheme() {}, dispose() {}, }; } export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle { // 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. No reserved-unused exemptions remain: every uniform // below is genuinely consumed by the R2 shader (the old inert Wave-3 control // uniforms are gone — the lava params drive the CPU physics, not the shader). const u = { resolution: gl.getUniformLocation(program, 'uResolution'), playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'), waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'), cohesion: gl.getUniformLocation(program, 'uCohesion'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), colorNavy: gl.getUniformLocation(program, 'uColorNavy'), colorMoss: gl.getUniformLocation(program, 'uColorMoss'), colorPaper: gl.getUniformLocation(program, 'uColorPaper'), gradientPhase: gl.getUniformLocation(program, 'uGradientPhase'), hasDatum: gl.getUniformLocation(program, 'uHasDatum'), datum: gl.getUniformLocation(program, 'uDatum'), datumWidth: gl.getUniformLocation(program, 'uDatumWidth'), datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'), blobs: gl.getUniformLocation(program, 'uBlobs'), blobCount: gl.getUniformLocation(program, 'uBlobCount'), }; for (const [name, loc] of Object.entries(u)) { if (loc === null) { 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; // ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default // consts at the top of this file). These are the dials the seven knobs feed, routed here by the // handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader // uniform; gradientRotationSpeed drives the OKLab gradient's anchor rotation (live as of R3). let lavaHeat = DEFAULT_LAVA_HEAT; let lavaGravity = DEFAULT_LAVA_GRAVITY; let collisionStrength = DEFAULT_COLLISION_STRENGTH; // Phase 10 — the split "bubbles" knob: fluidAmount drives count + per-blob volume; fluidViscosity // (cohesion) drives the shader's sphere-restoration (smin blend + wobble) via uCohesion. let fluidAmount = DEFAULT_FLUID_AMOUNT; let fluidViscosity = DEFAULT_FLUID_VISCOSITY; let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1). let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED; // Phase 15 — subsystem on/off. Default ON (mirrors C# DefaultLavaEnabled / DefaultWaveformEnabled), // so out of the box both subsystems run exactly as before. "Off" is a genuine draw-skip: lava off // skips stepPhysics + uploads zero blobs; waveform off skips the ribbon SDF (uWaveformEnabled) and // its CPU collision boundary. With both off, draw() short-circuits to a clear — no SDF eval at all. let lavaEnabled = true; let waveformEnabled = true; /** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1] * travel maps onto the useful 10%–95% band (full-width 100% read too wide; sub-10% vanished). * Both the shader uniform and the CPU collision boundary read this so they stay aligned. */ function effectiveWaveformWidth(): number { return 0.10 + waveformWidth * 0.85; } // ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the // shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by // rate·dt, where dt is the delta of (performance.now()−startTimeMs)/1000 (== uTimeSeconds). // Integrating rate·dt (rather than computing phase = t·rate in the shader) keeps the phase // CONTINUOUS when the dial changes — a rate change alters the slope, never snaps the value. let gradientPhase = 0; let lastGradientClockSeconds = 0; /** * 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 three colour anchors (navy / moss / off-white) from the live palette * vars on the canvas — the single source of truth (spec §6a). * * Detects light vs dark by the page background luminance, then binds each anchor 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: navy = --mud-palette-primary (#17283f), moss = --mud-palette-secondary (#3D7A68), * off-white = --mud-palette-background (#FAFAF8) * DARK: navy = --mud-palette-background (#0D1B2A), moss = --mud-palette-primary (#3D7A68), * off-white = --mud-palette-secondary (#FAFAF8) * This yields the navy / moss / off-white triad the gradient rotates among in either theme. */ function readTheme(): ResolvedTheme { const background = parseColor(readVar(canvas, '--mud-palette-background', '#FAFAF8')); const isDark = luminance(background) < 0.5; const navy = isDark ? background // the dark ground (#0D1B2A) IS the navy anchor on dark : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); const moss = isDark ? parseColor(readVar(canvas, '--mud-palette-primary', '#3D7A68')) : parseColor(readVar(canvas, '--mud-palette-secondary', '#3D7A68')); const paper = isDark ? parseColor(readVar(canvas, '--mud-palette-secondary', '#FAFAF8')) : background; // the light ground (#FAFAF8) IS the off-white anchor on light const resolved: ResolvedTheme = { navy, moss, paper }; // Report all THREE anchors the OKLab gradient rotates among, as 0-255 RGB + relative // luminance — confirms the navy / moss / off-white triad resolved off the canvas vars // in the active mode (no hardcoded hexes; a dark-mode toggle re-themes live). 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'}) — NAVY(X)=${fmt(navy)} MOSS(Y)=${fmt(moss)} PAPER(Z)=${fmt(paper)}.`); return resolved; } let theme: ResolvedTheme = readTheme(); // ════════════════════════════════════════════════════════════════════════════════ // R2 — the CPU wax-blob physics. Integrated each frame (real dt), then packed into // `blobUpload` and sent to the shader as uBlobs[]. Allocation-free per frame: the // blob pool and the upload buffer are built once here and mutated in place. // // Space: height-normalized (pixel / canvasHeight). y ∈ [0,1] top→floor, x ∈ [0, aspect] // where aspect = canvasWidth/canvasHeight. The FLOOR is y = 1 (the canvas bottom edge, // already CSS-clipped to the footer top in R1). One isotropic unit → round blobs. // ════════════════════════════════════════════════════════════════════════════════ interface Blob { x: number; y: number; // centre, height-norm vx: number; vy: number; // velocity, height-norm/s r0: number; // UNBIASED base radius, height-norm (fixed per blob — the blob's // identity size; the density dial scales it LIVE into r each frame) r: number; // DENSITY-biased base radius this step = r0 × density bias (Daniel // #1: density's "size" half is live — recomputed each frame, not // baked at seed, so turning the dial visibly resizes live wax) er: number; // EFFECTIVE radius this step = r shrunk by heat (Daniel #7); used by // collisions AND uploaded to the shader so the two always agree temp: number; // temperature 0..1 noiseSeed: number; // fixed per-blob phase offset so each blob's turbulence is decorrelated } // The blob pool — MAX_BLOBS slots, all constructed once. liveCount (≤ MAX_BLOBS, // driven by the density dial) decides how many we simulate + upload this frame. const blobs: Blob[] = []; // The packed upload buffer (vec4 per blob). Reused every frame — no per-frame alloc. const blobUpload = new Float32Array(MAX_BLOBS * 4); /** Cheap deterministic PRNG (mulberry32) so blob spawn is varied but reproducible. */ function makeRng(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(0x1a2b3c4d); /** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE * each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live * blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean), * amount 1 → ×1.15 (fat, lots of wax). */ function fluidSizeBias(): number { return 0.6 + fluidAmount * 0.55; } /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */ function seedBlob(b: Blob, aspect: number): void { // Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame. const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN); const r = r0 * fluidSizeBias(); b.r0 = r0; b.r = r; b.er = r; // starts at full size (cool); shrinks as it heats b.x = r + rng() * Math.max(aspect - 2 * r, 0.001); // somewhere across the width b.y = 1 - r - rng() * 0.1; // pooled near the floor b.vx = 0; b.vy = 0; b.temp = rng() * 0.3; // cool to start (heats at the floor) b.noiseSeed = rng() * 100; // decorrelate this blob's turbulence field } /** (Re)build the whole pool — called once at setup and whenever the canvas aspect is first known. */ function initBlobs(aspect: number): void { blobs.length = 0; for (let i = 0; i < MAX_BLOBS; i++) { const b: Blob = { x: 0, y: 0, vx: 0, vy: 0, r0: 0, r: 0, er: 0, temp: 0, noiseSeed: 0 }; seedBlob(b, aspect); blobs.push(b); } } let blobsInitialized = false; /** Live blob count for the current fluid-amount dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */ function liveBlobCount(): number { return Math.round(MIN_BLOB_COUNT + fluidAmount * (MAX_BLOBS - MIN_BLOB_COUNT)); } /** * CPU loudness sample at an absolute mix time, in [0,1], or 0 outside the mix. This * mirrors the shader's sampleAt() (same texel-centre convention) so the CPU collision * boundary matches the rendered waveform exactly. Reads the retained datum.samples. */ function sampleLoudnessAt(timeSeconds: number): number { // Phase 15: waveform off → no ribbon boundary. Reporting zero loudness collapses the collision // half-width to 0, so wax never bounces off an invisible wall (matches the skipped ribbon draw). if (!waveformEnabled) return 0; const d = datum; if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0; const n = d.sampleCount; const p = (timeSeconds / d.durationSeconds) * n - 0.5; const i0 = Math.min(Math.max(Math.floor(p), 0), n - 1); const i1 = Math.min(Math.max(Math.floor(p) + 1, 0), n - 1); const f = Math.min(Math.max(p - Math.floor(p), 0), 1); const s0 = d.samples[i0] / 255; const s1 = d.samples[i1] / 255; // Smootherstep (Hermite) blend — mirrors the shader's sampleAt so the CPU collision boundary // follows the same smooth sinusoid contour the ribbon is drawn with (no faceted mismatch). const fs = f * f * (3 - 2 * f); return s0 + (s1 - s0) * fs; } /** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in. * Designed so dial 0 = NO floor heating (wax rests, collision-only — §4c endpoint) and * dial 1 = vigorous heating (many blobs go buoyant per second). A slight ease-in (square * toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */ function heatScaleFromDial(dial: number): number { const d = Math.min(Math.max(dial, 0), 1); // Smoothstep toe (gentle at 0) scaled by 1.2 — Phase 10 §3.4: uniform +20% across the curve // (every non-zero dial position is raised by 20%; the shape of the toe is preserved but the // overall output is higher at every point). return d * d * (3 - 2 * d) * 1.2; } /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive), * dial 1 = hard (elastic, high restitution). Returns the restitution coefficient to * use; the penalty-spring stiffness is held constant and the IMPULSE is scaled by the * same dial so soft = mostly spring/no-bounce, hard = full elastic reflection (§5c). */ function restitution(soft: number, hard: number): number { const d = Math.min(Math.max(collisionStrength, 0), 1); return soft + (hard - soft) * d; } /** * Cheap continuous 1-D-ish noise in [−1,1] for the turbulence field (Daniel #7). A pair of * out-of-phase sines is enough for an organic, smoothly-evolving churn — far cheaper than a * lattice value-noise and we only need it twice per hot blob per substep. Decorrelated per * blob via the seed so neighbouring bubbles don't churn in lock-step. */ function turbNoise(seed: number, t: number): number { return Math.sin(seed + t) * 0.6 + Math.sin(seed * 1.7 + t * 0.53) * 0.4; } /** * Advance the physics by dt seconds. Sub-stepped for spring stability. The collision * model: blob↔floor (soft contact), blob↔waveform (elastic deflect off the ribbon * surface normal + an upward throw, always on), blob↔blob (elastic, soft↔hard via the * strength dial). Heat shrinks each blob's effective radius and injects turbulence so * high heat reads as a swarm of small lively bubbles (Daniel #7). */ function stepPhysics(dtTotal: number): void { if (canvas.height <= 0) return; const aspect = canvas.width / canvas.height; if (!blobsInitialized) { initBlobs(aspect); blobsInitialized = true; } const count = liveBlobCount(); const sizeBias = fluidSizeBias(); // fluid-amount dial → live size scale (Phase 10, recomputed each step) const heatScale = heatScaleFromDial(lavaHeat); // Gravity range remap (Phase 10 §3.3): the knob's full [0,1] travel now covers only the useful // 0%–75% of the old gravity span — the top quarter was too heavy (wax slammed down). So the dial // is scaled to 0.75 before mapping onto [MIN, MAX], keeping the low/mid feel and dropping the slam. const gravityDial = lavaGravity * 0.75; const gravity = GRAVITY_ACCEL_MIN + gravityDial * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN); const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD); const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD); const collideHardness = Math.min(Math.max(collisionStrength, 0), 1); // Mix-time mapping at the current playhead (the waveform a blob's row sits over). const nowYn = NOW_ANCHOR_FROM_TOP; const secondsPerHeight = visibleSeconds; const centreX = aspect * 0.5; // Match the shader's width-dialled ribbon so the collision boundary lines up with what // is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped // effective width as the uniform (Phase 10 §3.7) so the boundary never drifts from the ribbon. const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * effectiveWaveformWidth(); const playhead = effectivePlayhead(); const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS; // Wall-clock seconds for the turbulence field (separate from the playhead/scroll). const turbTime = (performance.now() - startTimeMs) / 1000 * TURBULENCE_RATE; for (let s = 0; s < PHYSICS_SUBSTEPS; s++) { // ── Per-blob: heat exchange, size, buoyancy, gravity, turbulence, damping, floor. ── for (let i = 0; i < count; i++) { const b = blobs[i]; // Heat exchange: floor heats (× heat dial), top cools, all relax to ambient. const distFromFloor = 1 - b.y; const distFromTop = b.y; if (distFromFloor < HEAT_FLOOR_ZONE) { const near = 1 - distFromFloor / HEAT_FLOOR_ZONE; // 1 at floor → 0 at zone edge b.temp += (1 - b.temp) * HEAT_FLOOR_RATE * heatScale * near * dt; } if (distFromTop < HEAT_TOP_ZONE) { const near = 1 - distFromTop / HEAT_TOP_ZONE; b.temp += (0 - b.temp) * HEAT_TOP_RATE * near * dt; } b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt; b.temp = Math.min(Math.max(b.temp, 0), 1); // Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid- // amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the // "size" half is not baked at seed). r feeds the heat-shrink below and the // collisions/upload via er, so the dial moves the actual drawn + simulated size. b.r = b.r0 * sizeBias; // Energy → SIZE (Daniel #7): the hotter the wax, the smaller it shrinks. Driven by // heatScale × temperature so it only shrinks when the lamp is actually hot AND this // blob is hot — at heat 0 every blob stays full size. Tracks temp continuously, so a // bubble grows back as it cools near the top. Effective radius feeds both collision // and the upload, so the sim and the render never disagree. const shrink = 1 - heatScale * b.temp * (1 - HEAT_RADIUS_MIN_SCALE); b.er = b.r * shrink; // Forces: gravity down (+y), buoyancy from temperature (up = −y when hot). const buoyancy = BUOYANCY_COEFF * heatScale * (b.temp - TEMP_AMBIENT); b.vy += (gravity - buoyancy) * dt; // Energy → TURBULENCE (Daniel #7): a small churn injected into hot wax only, scaled // by heatScale × temperature. High heat ⇒ lots of small bubbles jittering with life; // low heat ⇒ a calm pool. Two decorrelated noise reads give an x/y churn vector. const turbAmp = TURBULENCE_ACCEL * heatScale * b.temp; if (turbAmp > 0) { b.vx += turbNoise(b.noiseSeed, turbTime) * turbAmp * dt; b.vy += turbNoise(b.noiseSeed + 50.0, turbTime) * turbAmp * dt; } // Viscous damping (lazy wax): frame-rate-independent exponential decay. const damp = Math.exp(-VISCOUS_DAMPING * dt); b.vx *= damp; b.vy *= damp; // Velocity clamp (Daniel #5 jitter fix): keep the integrator stable under the // lower viscosity + over-unity restitution so a deep overlap can't fling a blob // fast enough to tunnel and re-collide (the buzz). Far above real convection speed. if (b.vx > MAX_BLOB_SPEED) b.vx = MAX_BLOB_SPEED; else if (b.vx < -MAX_BLOB_SPEED) b.vx = -MAX_BLOB_SPEED; if (b.vy > MAX_BLOB_SPEED) b.vy = MAX_BLOB_SPEED; else if (b.vy < -MAX_BLOB_SPEED) b.vy = -MAX_BLOB_SPEED; // Integrate position. b.x += b.vx * dt; b.y += b.vy * dt; // Boundaries use the EFFECTIVE radius so shrunk hot bubbles sit correctly. // Floor: soft contact spring + extra damping so resting wax pools and flattens. const floorY = 1 - b.er; if (b.y > floorY) { const pen = b.y - floorY; b.vy -= FLOOR_SPRING * pen * dt; // spring pushes up out of the floor if (b.vy > 0) b.vy *= Math.exp(-FLOOR_CONTACT_DAMPING * dt); // kill the downward drive b.y = floorY + pen * 0.5; // ease the penetration out (soft, no snap) } // Ceiling: a gentle clamp so a very hot blob doesn't fly off-screen — it cools // at the top and falls back; just keep it inside the box. const ceilY = b.er; if (b.y < ceilY) { b.y = ceilY; if (b.vy < 0) b.vy = 0; } // Side walls: reflect softly so wax stays on screen. if (b.x < b.er) { b.x = b.er; if (b.vx < 0) b.vx = -b.vx * 0.3; } if (b.x > aspect - b.er) { b.x = aspect - b.er; if (b.vx > 0) b.vx = -b.vx * 0.3; } } // ── Blob ↔ waveform boundary (always on, independent of heat — §5b). ── // The waveform is a centred vertical band of half-width = loudness(row). A blob // whose centre is within (halfWidth + r) of the centre line penetrates it and is // pushed out along the band's surface normal (horizontal). Read-only authority: // the waveform is never moved, only the wax responds. for (let i = 0; i < count; i++) { const b = blobs[i]; const t = playhead + (b.y - nowYn) * secondsPerHeight; const amp = sampleLoudnessAt(t); if (amp <= 0) continue; const halfW = amp * maxHalf; const dx = b.x - centreX; const sideSign = dx >= 0 ? 1 : -1; // outward surface normal (in x) const penetration = halfW + b.er - Math.abs(dx); if (penetration <= 0) continue; // Capture the inward velocity ONCE up front (Daniel #5 jitter fix). The prior // build read b.vx for the elastic term AFTER the spring had already mutated it, // so the spring and reflection fought each other within one pass — a buzz source. // Now each contribution reads the same pre-collision state and they sum cleanly. const inwardSpeed = -sideSign * b.vx; // >0 means moving toward the centre line // Soft penalty spring (soft end of the dial): a gentle outward shove proportional // to penetration. Softened for R2 (Daniel #3) so the low end genuinely mushes the // wax around. The (1 − hardness) factor hands the work to the elastic term as the // dial climbs, so we never double-drive at the hard end. b.vx += sideSign * WAVE_COLLIDE_SPRING * penetration * dt * (1 - collideHardness); // Hard elastic reflection (hard end): bounce the inward velocity back out, scaled // by restitution × hardness. Restitution is sub-unity (≤ 0.85) — bounded reflection, // no energy added. if (inwardSpeed > 0) { b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness; } // UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob // bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the // accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a // sustained/deep overlap lifts firmly but never launches the bubble off-screen. const throwUp = Math.min(WAVE_THROW_UP * penetration * dt * collideHardness, WAVE_THROW_UP_MAX); b.vy -= throwUp; // Positional push-out: ejects the wax progressively out of the ribbon along the normal // (converges across substeps, so no stuck wax). The soft end eases it out gently // (mushy), the hard end snaps it clean. b.x += sideSign * penetration * (0.5 + 0.5 * collideHardness); } // ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ── // O(count²) ≤ ~1k pair tests — trivial. Mass ∝ er² so big blobs shove small ones, and // hot shrunk bubbles are correspondingly lighter (Daniel #7). Geometry uses effective // radii so collisions match what's drawn. for (let i = 0; i < count; i++) { const a = blobs[i]; for (let j = i + 1; j < count; j++) { const c = blobs[j]; const dx = c.x - a.x; const dy = c.y - a.y; const dist = Math.hypot(dx, dy); const minDist = a.er + c.er; if (dist >= minDist || dist <= 1e-6) continue; const nx = dx / dist, ny = dy / dist; // collision normal a→c const overlap = minDist - dist; const ma = a.er * a.er, mc = c.er * c.er; // mass ∝ area const invSum = 1 / (ma + mc); // Capture the approach velocity ONCE from pre-collision state (Daniel #5 jitter // fix): the prior build read the relative velocity for the elastic impulse AFTER // the penalty spring had already mutated it, so spring and impulse fought within // one pass — buzz. Now both read the same state and sum cleanly. const velAlongNormal = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny; // Positional separation along the normal, mass-weighted (split the overlap). // Soft at low strength (Daniel #3: gentle, blobs squish and overlap), firm at high. const sep = overlap * (0.15 + 0.6 * collideHardness); a.x -= nx * sep * (mc * invSum); a.y -= ny * sep * (mc * invSum); c.x += nx * sep * (ma * invSum); c.y += ny * sep * (ma * invSum); // Soft penalty spring along the normal (gentle shove, dominant at the soft end). const springAcc = BLOB_COLLIDE_SPRING * overlap * (1 - collideHardness) * dt; a.vx -= nx * springAcc; a.vy -= ny * springAcc; c.vx += nx * springAcc; c.vy += ny * springAcc; // Elastic impulse along the normal (hard end), with restitution + mass. Over-unity // restitution at full hardness gives the springy throw (Daniel #6). if (velAlongNormal < 0) { // approaching const e = collideRest * collideHardness; const impulse = -(1 + e) * velAlongNormal * invSum; a.vx -= impulse * mc * nx; a.vy -= impulse * mc * ny; c.vx += impulse * ma * nx; c.vy += impulse * ma * ny; } } } } } /** Pack the live blobs into the upload buffer. Returns the live count. */ function packBlobs(): number { const count = liveBlobCount(); for (let i = 0; i < count; i++) { const b = blobs[i]; const o = i * 4; blobUpload[o] = b.x; blobUpload[o + 1] = b.y; blobUpload[o + 2] = b.er; // effective (heat-shrunk) radius — matches the collision geometry blobUpload[o + 3] = b.temp; } return count; } let rafId: number | null = null; let disposed = false; const startTimeMs = performance.now(); // Wall-clock anchor for the physics dt (separate from the playhead decay clock). let lastPhysicsMs = performance.now(); // FPS diagnostic (verification aid — gated on DEBUG). Counts actual rAF callbacks and logs the // rate ~once/sec while the loop runs (which is now continuously, playing or paused — Part C). A // rate near the display refresh (~60) confirms the continuous loop holds frame rate; a paused-but- // foregrounded lamp should still read ~60 (the cheap sim + one draw), confirming the power cost of // running while paused is acceptable. 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); // The continuous loop redraws on its next tick. Only force a still frame if the loop is // stopped (tab hidden) so a resize while hidden is reflected when the tab returns. if (rafId === null) 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); // Phase 15 — both subsystems off: there is nothing to draw. Short-circuit past the physics // step, the blob upload, and the full-screen SDF evaluation entirely — a genuine no-render-cost // empty field (§10.1), not a shader that runs and outputs transparent. The cleared (transparent) // buffer above is the result. The gradient/playhead clocks are not advanced while fully off; // they resume from their held value when a subsystem is turned back on (no visible snap, since // an off field shows nothing to snap). if (!lavaEnabled && !waveformEnabled) return; 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. const clockSeconds = (performance.now() - startTimeMs) / 1000; gl.uniform2f(u.resolution, canvas.width, canvas.height); gl.uniform1f(u.playheadSeconds, renderedPlayhead()); gl.uniform1f(u.timeSeconds, clockSeconds); // Advance the gradient-rotation phase (Motion 1) off the SAME clock as uTimeSeconds — the // delta since the last drawn frame, scaled by the dial's rate. Integrating rate·dt keeps // the phase continuous across a dial change (no snap). Idle one-shot redraws advance it by // their real dt too, so the field keeps morphing while paused (the loop runs continuously). const gradientDt = Math.max(0, clockSeconds - lastGradientClockSeconds); lastGradientClockSeconds = clockSeconds; const rotationRate = GRADIENT_ROTATION_RATE_MIN + gradientRotationSpeed * (GRADIENT_ROTATION_RATE_MAX - GRADIENT_ROTATION_RATE_MIN); gradientPhase += gradientDt * rotationRate; // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no // separate dirty-tracking needed for scalars/vec3s). gl.uniform1f(u.visibleSeconds, visibleSeconds); gl.uniform1f(u.waveformWidth, effectiveWaveformWidth()); gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0); gl.uniform1f(u.cohesion, fluidViscosity); gl.uniform1f(u.gradientPhase, gradientPhase); gl.uniform3fv(u.colorNavy, theme.navy); gl.uniform3fv(u.colorMoss, theme.moss); gl.uniform3fv(u.colorPaper, theme.paper); // Advance the wax-blob physics by the real elapsed time, then upload the blobs. // Stepping here (rather than in the loop) means idle one-shot redraws also advance // the sim by their actual dt — clamped by PHYSICS_MAX_DT, so a long paused gap just // means the lamp barely moves while paused (it animates with playback, spec §E). const nowMs = performance.now(); const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000); lastPhysicsMs = nowMs; // Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop // (`for … if (i >= uBlobCount) break;`) then unions nothing, so no wax is drawn and no physics // runs — a genuine subsystem skip (§10.1), not a hidden-but-simulated field. The wax keeps its // last positions for free (we just stop integrating); turning lava back on resumes from there. let liveCount = 0; if (lavaEnabled) { stepPhysics(physicsDt); liveCount = packBlobs(); } gl.uniform4fv(u.blobs, blobUpload); gl.uniform1i(u.blobCount, liveCount); 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 (lava reframe Part C: sim animates while paused; only scroll freezes). ─ // // DESIGN (changed in Wave R4): the loop runs whenever the component is ALIVE and the tab is // VISIBLE — it is NO LONGER gated on playback.isPlaying. A real lava lamp keeps convecting // regardless of the music, so the fluid sim (physics + render) keeps animating while audio is // paused; only the waveform SCROLL / playhead freezes. That freeze falls straight out of // effectivePlayhead(): while !isPlaying it returns the static last-pushed position, so the // waveform holds at its paused row while the physics dt clock (lastPhysicsMs in draw()) keeps // advancing the wax. Power-saving is preserved by stopping the loop on tab-hidden (visibilitychange) // and on dispose — just not merely because audio paused. A foregrounded-but-paused lamp runs only // the cheap CPU sim + one GL draw per frame, which holds 60 FPS comfortably. // // Smoothness (spec §2e / §5.4): while playing, 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 // drives the blob-radius wobble in the shader; the CPU physics uses its own wall-clock dt — neither // drives the scroll, which is the playhead alone, and the playhead is frozen while paused.) /** 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(); // Re-base the physics clock too, so the first frame's dt is one frame, not the idle // gap since the last redraw (which would advance the lamp by a clamped jump on resume). lastPhysicsMs = 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 continuously while the component is alive and the tab is visible * (lava reframe Part C) — NOT gated on playback. Each frame advances the wax physics and draws. * While playing, it draws at the wall-clock-interpolated playhead (effectivePlayhead, advancing * smoothly between the ~10 Hz pushes); while paused, effectivePlayhead() holds the static pushed * position so the waveform freezes in place while the lava keeps convecting. It reschedules itself * every frame; the only things that stop it are dispose() and the tab going hidden (the * visibilitychange handler calls stopLoop). A backgrounded tab also gets rAF throttled by the * browser, and we stop the loop entirely when hidden, so a backgrounded lamp burns no frames. */ 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.`); // Lava diagnostic: the dials in play + how many blobs are currently buoyant // (temp above ambient) and how many are pooled on the floor. Daniel watches // this to confirm heat 0 = all-resting and heat-up = rising count climbs. const live = liveBlobCount(); let buoyant = 0; let pooled = 0; let avgTemp = 0; let avgShrink = 0; // mean effective/base radius ratio — shows the heat→size coupling for (let i = 0; i < live; i++) { const b = blobs[i]; avgTemp += b.temp; avgShrink += b.r > 0 ? b.er / b.r : 1; if (b.temp > TEMP_AMBIENT) buoyant++; if (b.y > 1 - b.er - 0.04) pooled++; } debugLog( `lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` + `collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} ` + `fluidAmount=${fluidAmount.toFixed(2)} viscosity=${fluidViscosity.toFixed(2)} | ` + `blobs=${live} buoyant=${buoyant} pooled=${pooled} ` + `avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`, ); // Colour diagnostic (R3): the rotation dial + the live gradient phase. Daniel watches // phase advance (faster at a higher dial, near-static at the low end) to confirm Motion 1 // is live, and that the dial visibly changes the rate. phase mod 3 = the ring position. debugLog( `colour — rotationSpeed=${gradientRotationSpeed.toFixed(2)} ` + `gradientPhase=${gradientPhase.toFixed(2)} (ring ${(gradientPhase % 3).toFixed(2)}/3).`, ); fpsFrameCount = 0; fpsWindowStartMs = nowMs; } } // Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is // stopped only by dispose() or the tab going hidden, never by audio pausing. rafId = requestAnimationFrame(frame); } // ── Tab-visibility gating (lava reframe Part C power-saving). ──────────────────── // The loop runs continuously while alive, but a HIDDEN tab should not animate at all // (the browser throttles rAF anyway, but we stop outright to be sure). On becoming // visible again we restart the loop; startLoop re-bases the dt clocks so the wax // doesn't lurch by the whole hidden gap on the first resumed frame. function onVisibilityChange(): void { if (disposed) return; if (document.hidden) { stopLoop(); } else { startLoop(); } } document.addEventListener('visibilitychange', onVisibilityChange); // Read the initial size synchronously (one getBoundingClientRect at setup is // fine — it is the ResizeObserver that must not measure per-frame), draw a still // frame so the canvas isn't blank, then START the continuous loop (Part C: the lava // animates from the moment the visualizer mounts, paused or playing) — unless the tab // is already hidden, in which case the visibilitychange handler will start it later. { const rect = canvas.getBoundingClientRect(); applySize(rect.width, rect.height); redrawOnce(); if (!document.hidden) startLoop(); } /** * 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, samples }; } 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 — the continuous loop picks it up next frame. Only force // a still frame if the loop is stopped (tab hidden) so a datum that arrives while hidden is // reflected the moment the tab becomes visible-and-draws. if (rafId === null) 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. While paused, effectivePlayhead() // returns this static position, so the waveform freezes here (Part C) — the // continuous loop keeps animating the lava, but the scroll holds. 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 paused // we want the exact authoritative position, not a glide from a stale render: // a resume should land on the real position, and a paused 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; } // NOTE (Part C): we do NOT start/stop the rAF loop on the play/pause edge anymore — the // loop runs continuously while the tab is visible so the lava keeps convecting when paused. // The play-state only changes whether effectivePlayhead() advances (scroll) or holds // (freeze); the loop itself is owned by setup + the visibilitychange handler + dispose. if (isPlaying !== wasPlaying) { debugLog(`playback ${isPlaying ? 'resumed' : 'paused'} — position ${positionSeconds.toFixed(2)}s; scroll ${isPlaying ? 'advancing' : 'frozen'}, lava keeps animating.`); } }, // ── Wave R4 — the seven dedicated control setters. Each routes its value to the one dial it // drives; no more R2 temp-remapping. The lava loop now runs continuously (see startLoop / // the visibility handling), so a paused tweak is already picked up by the next frame — but we // keep a redrawOnce() guard for the rare fully-stopped case (loop not running, e.g. tab // hidden) so a tweak still lands a still frame when it resumes-and-draws. // Scroll speed: arrives already mapped to a visible time-span (seconds) on the C# side. Clamp // into the supported span so a stray value can't break the scroll math. setScrollSpeed(seconds: number): void { visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds)); debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`); if (rafId === null) redrawOnce(); }, // Gradient rotation speed: LIVE as of Wave R3 — sets the anchor-rotation rate (Motion 1). // The phase integrator (draw()) reads this; changing it alters the slope, never snaps the // phase, so the gradient speeds up/slows down smoothly. redrawOnce guards the fully-stopped // (tab-hidden) case so a tweak still lands a still frame when it resumes-and-draws. setGradientRotationSpeed(value: number): void { gradientRotationSpeed = Math.min(1, Math.max(0, value)); debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, setLavaGravity(value: number): void { lavaGravity = Math.min(1, Math.max(0, value)); debugLog(`setLavaGravity → ${lavaGravity.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, setLavaHeat(value: number): void { lavaHeat = Math.min(1, Math.max(0, value)); debugLog(`setLavaHeat → ${lavaHeat.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, // Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount) // AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it // visibly adds/removes wax and resizes the already-live blobs. setFluidAmount(value: number): void { fluidAmount = Math.min(1, Math.max(0, value)); debugLog(`setFluidAmount → ${fluidAmount.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, // Fluid viscosity / cohesion (Phase 10 — second half of the split knob): drives the shader's // uCohesion, which scales the metaball smin blend + wobble. High = crisp spheres that snap back; // low = gooey/deformed wax. Uniform-only — no per-fragment cost change, weaker hardware unaffected. setFluidViscosity(value: number): void { fluidViscosity = Math.min(1, Math.max(0, value)); debugLog(`setFluidViscosity → ${fluidViscosity.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, setCollisionStrength(value: number): void { collisionStrength = Math.min(1, Math.max(0, value)); debugLog(`setCollisionStrength → ${collisionStrength.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, setWaveformWidth(value: number): void { waveformWidth = Math.min(1, Math.max(0, value)); debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, // Phase 15 — subsystem enables. "Off" is a genuine draw-skip (§10.1): lava off stops the physics // step + uploads zero blobs (handled in draw()); waveform off skips the ribbon SDF + collision // boundary. redrawOnce guards the fully-stopped (tab-hidden) case so the toggle lands a still // frame when the loop resumes — including the both-off → cleared empty field. setLavaEnabled(enabled: boolean): void { lavaEnabled = enabled; debugLog(`setLavaEnabled → ${enabled}.`); if (rafId === null) redrawOnce(); }, setWaveformEnabled(enabled: boolean): void { waveformEnabled = enabled; debugLog(`setWaveformEnabled → ${enabled}.`); if (rafId === null) redrawOnce(); }, refreshTheme(): void { theme = readTheme(); if (rafId === null) redrawOnce(); }, dispose(): void { disposed = true; stopLoop(); document.removeEventListener('visibilitychange', onVisibilityChange); 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); }, }; }