feat(visualizer): Phase 15 control-deck rework

Centered tinted MudOverlay (NowPlayingCard chrome) replaces the anchored popover; eight dials become a deterministic three-row LAVA/WAVE layout; lava + waveform lamp toggles drive a genuine per-subsystem draw-skip; scroll/zoom becomes a slider; playful tooltips; green=interactive/light=static.
This commit is contained in:
daniel-c-harvey
2026-06-17 14:28:15 -04:00
parent fe481d0417
commit dd4f8ddded
8 changed files with 465 additions and 184 deletions
@@ -527,6 +527,19 @@ export interface WaveformVisualizerHandle {
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;
@@ -613,6 +626,8 @@ 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
@@ -877,6 +892,10 @@ vec3 anchorAtPhase(float phase) {
// 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
@@ -1072,6 +1091,8 @@ function noopHandle(): WaveformVisualizerHandle {
setFluidViscosity() {},
setCollisionStrength() {},
setWaveformWidth() {},
setLavaEnabled() {},
setWaveformEnabled() {},
refreshTheme() {},
dispose() {},
};
@@ -1129,6 +1150,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
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'),
@@ -1167,6 +1189,12 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
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).
@@ -1365,6 +1393,9 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
* 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;
@@ -1731,6 +1762,14 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
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);
@@ -1756,6 +1795,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
// 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);
@@ -1769,8 +1809,15 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
const nowMs = performance.now();
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
lastPhysicsMs = nowMs;
stepPhysics(physicsDt);
const liveCount = packBlobs();
// 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);
@@ -2156,6 +2203,22 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
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();