Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon
This commit is contained in:
@@ -27,11 +27,17 @@
|
||||
* gradient is Wave R3. No glass, no screen-space noise (removed in R1).
|
||||
*
|
||||
* The Blazor component owns the canvas element and the inputs (datum, playback,
|
||||
* zoom, theme, the control dials); this module owns the requestAnimationFrame loop,
|
||||
* 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`. The handle SHAPE is unchanged from Phase 10 — the three
|
||||
* effect setters are temporarily re-routed to the lava params for this wave (see
|
||||
* their definitions); Wave R4 gives them proper names + a six-knob UI.
|
||||
* returned by `create`. As of Wave R4 the handle exposes SEVEN dedicated control setters
|
||||
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity /
|
||||
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. Gradient rotation is
|
||||
* stored but inert until Wave R3 builds the OKLab gradient.
|
||||
*
|
||||
* 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. ──────────
|
||||
@@ -51,47 +57,51 @@ export const DEFAULT_VISIBLE_SECONDS = 10;
|
||||
|
||||
// ── Control tuning anchors. These mirror the C#-side defaults in ──────────────────
|
||||
// MixVisualizerControlState.cs — keep the two in sync, exactly as the
|
||||
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are
|
||||
// normalized [0,1].
|
||||
// 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).
|
||||
//
|
||||
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper seven-knob set):
|
||||
// the FOUR existing control knobs are re-purposed to drive the new lava physics so
|
||||
// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say
|
||||
// the old thing; the SETTERS below route them to the new physics params. Mapping:
|
||||
// • "Detach" knob (Air icon) → lava HEAT (setDetach)
|
||||
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY (setBubblyness)
|
||||
// • "Color-shift" knob (Palette) → COLLISION STRENGTH (setColorShiftSpeed)
|
||||
// • "Resolution" knob (ZoomIn) → WAVEFORM WIDTH (setWaveformWidth) ← R2 NEW
|
||||
// The resolution/zoom knob is repurposed because scroll speed is not critical for
|
||||
// evaluating the lava: the controls row no longer mutates VisibleSeconds, so the window
|
||||
// holds at DEFAULT_VISIBLE_SECONDS (setZoom is still seeded once with that default).
|
||||
// Blob DENSITY has no live knob this wave; it sits at
|
||||
// DEFAULT_BLOB_DENSITY (R4 adds it). The defaults below are tuned to Daniel's sweet spot
|
||||
// (~20% gravity, ~100% heat) so the lava looks ALIVE and fluid on open — he then tunes
|
||||
// on screen. ALL of this temp wiring is removed in R4 for the real knob set.
|
||||
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the
|
||||
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping:
|
||||
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
|
||||
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — INERT
|
||||
// until Wave R3 builds the OKLab gradient that consumes it
|
||||
// • Lava gravity → gravity dial (setLavaGravity)
|
||||
// • Lava heat → heat dial (setLavaHeat)
|
||||
// • Blob density/size → density dial (setBlobDensity)
|
||||
// • 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 (was bulge). Mirrors C# DefaultBubblyness.
|
||||
* Tuned to Daniel's R2 sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
|
||||
export const DEFAULT_BUBBLYNESS = 0.2;
|
||||
/** 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 (was detach). Mirrors C# DefaultDetach.
|
||||
* Tuned to Daniel's R2 sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
|
||||
export const DEFAULT_DETACH = 1.0;
|
||||
/** 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 (was color-shift). Mirrors C# DefaultColorShiftSpeed.
|
||||
/** 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_COLOR_SHIFT_SPEED = 0.5;
|
||||
export const DEFAULT_COLLISION_STRENGTH = 0.5;
|
||||
|
||||
/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */
|
||||
/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */
|
||||
export const DEFAULT_BLOB_DENSITY = 0.4;
|
||||
|
||||
/**
|
||||
* Default WAVEFORM-WIDTH dial (R2 TEMP — mapped to the resolution/zoom knob for in-browser
|
||||
* test; R4 gives it its own knob). 1 = full ribbon width (the prior behaviour); lower values
|
||||
* narrow the waveform band so the lava fluid gets more room to move on loud songs. Mirrors C#
|
||||
* DefaultWaveformWidth. Opens at full width so the default look matches the prior ribbon.
|
||||
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
|
||||
* [0,1] → slow→fast anchor rotation. INERT until Wave R3 builds the OKLab three-colour gradient
|
||||
* that consumes it — stored and round-tripped through the handle so the knob persists, but it
|
||||
* drives nothing this wave (the R2 flat placeholder fill ignores it).
|
||||
*/
|
||||
export const DEFAULT_WAVEFORM_WIDTH = 1.0;
|
||||
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
|
||||
|
||||
/**
|
||||
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower
|
||||
* values narrow the waveform band so the lava fluid gets more room to move on loud songs.
|
||||
*/
|
||||
export const DEFAULT_WAVEFORM_WIDTH = 0.6;
|
||||
|
||||
/**
|
||||
* Where the "now" line sits within the window, as a fraction from the top.
|
||||
@@ -320,9 +330,9 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
|
||||
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
|
||||
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
|
||||
// while the runtime fix is being verified through the browser.
|
||||
// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser;
|
||||
// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the
|
||||
// look is approved.
|
||||
// NOTE: ON for the Phase 10 reframe Wave R4 controls pass. Daniel tests in-browser; the FPS lines
|
||||
// (which should hold ~60 even while paused, confirming the continuous-loop power cost is acceptable)
|
||||
// + the seven-dial lava line confirm the controls + pause fix. Flip back to false at reframe close.
|
||||
const DEBUG = true;
|
||||
|
||||
const TAG = '[MixVisualizer]';
|
||||
@@ -447,7 +457,8 @@ interface Playback {
|
||||
* effectivePlayhead (see draw()), anchored on this value.
|
||||
*/
|
||||
positionSeconds: number;
|
||||
/** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
|
||||
/** 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
|
||||
@@ -462,14 +473,19 @@ interface Playback {
|
||||
export interface MixVisualizerHandle {
|
||||
setDatum(samplesBase64: string, durationSeconds: number): void;
|
||||
setPlayback(positionSeconds: number, isPlaying: boolean): void;
|
||||
setZoom(visibleSeconds: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */
|
||||
setBubblyness(value: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */
|
||||
setDetach(value: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */
|
||||
setColorShiftSpeed(value: number): void;
|
||||
/** [0,1]. R2 TEMP: routes the "Resolution"/zoom knob to WAVEFORM WIDTH (R4 gives it its own knob). */
|
||||
/** 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. INERT until Wave R3 (stored + round-tripped only). */
|
||||
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/size. */
|
||||
setBlobDensity(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;
|
||||
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
|
||||
refreshTheme(): void;
|
||||
@@ -854,10 +870,12 @@ function noopHandle(): MixVisualizerHandle {
|
||||
return {
|
||||
setDatum() {},
|
||||
setPlayback() {},
|
||||
setZoom() {},
|
||||
setBubblyness() {},
|
||||
setDetach() {},
|
||||
setColorShiftSpeed() {},
|
||||
setScrollSpeed() {},
|
||||
setGradientRotationSpeed() {},
|
||||
setLavaGravity() {},
|
||||
setLavaHeat() {},
|
||||
setBlobDensity() {},
|
||||
setCollisionStrength() {},
|
||||
setWaveformWidth() {},
|
||||
refreshTheme() {},
|
||||
dispose() {},
|
||||
@@ -937,14 +955,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
|
||||
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
|
||||
|
||||
// ── Lava physics control values (the R2 TEMP knob re-mapping — see the control-default
|
||||
// consts at the top of this file). These are the dials the existing knobs feed, routed
|
||||
// here by the handle setters. They drive the CPU physics step below, NOT a shader uniform.
|
||||
let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat
|
||||
let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity
|
||||
let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness
|
||||
let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it)
|
||||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // "Resolution" knob → ribbon width (R2 TEMP, R4 own knob)
|
||||
// ── 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 is stored but INERT until Wave R3 builds the colour gradient.
|
||||
let lavaHeat = DEFAULT_LAVA_HEAT;
|
||||
let lavaGravity = DEFAULT_LAVA_GRAVITY;
|
||||
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
|
||||
let blobDensity = DEFAULT_BLOB_DENSITY;
|
||||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
|
||||
// INERT until Wave R3 — held so the knob round-trips and persists; nothing reads it this wave.
|
||||
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
|
||||
|
||||
/**
|
||||
* The *authoritative* playhead for this instant: the last pushed position advanced
|
||||
@@ -1374,11 +1395,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// Wall-clock anchor for the physics dt (separate from the playhead decay clock).
|
||||
let lastPhysicsMs = performance.now();
|
||||
|
||||
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
|
||||
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
|
||||
// the two failure modes: a rate near the display refresh (~60) with the playhead
|
||||
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
|
||||
// to the playback pushes instead of free-running. Reset when the loop (re)starts.
|
||||
// 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;
|
||||
|
||||
@@ -1418,9 +1439,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
|
||||
const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
|
||||
applySize(nextCssWidth, nextCssHeight);
|
||||
// While idle, draw one still frame reflecting the new size. While playing,
|
||||
// the running loop will redraw on its next tick — no action needed.
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
// 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);
|
||||
|
||||
@@ -1517,20 +1538,25 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ─────────────
|
||||
// ── rAF loop lifecycle (lava reframe Part C: sim animates while paused; only scroll freezes). ─
|
||||
//
|
||||
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
|
||||
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
|
||||
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
|
||||
// by the ResizeObserver.
|
||||
// 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): 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.)
|
||||
// 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 {
|
||||
@@ -1565,15 +1591,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* The animation loop. Runs only while playing. Each frame draws the scrolling
|
||||
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
|
||||
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
|
||||
* stopped since this frame was queued, in which case it draws one final still
|
||||
* frame (already done above) and exits the loop.
|
||||
*
|
||||
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
|
||||
* that the loop does not run at all when paused, so a foregrounded-but-paused
|
||||
* mix burns no frames (spec §E / §5.3).
|
||||
* 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) {
|
||||
@@ -1618,21 +1643,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
if (playback.isPlaying) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
// Playback stopped between queue and now; final still frame drawn above.
|
||||
rafId = null;
|
||||
}
|
||||
// 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), then draw a
|
||||
// still frame so the canvas isn't blank before the first play command.
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1722,9 +1762,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
datum = null;
|
||||
}
|
||||
datum = uploadDatum(samplesBase64, durationSeconds);
|
||||
// New datum changes what is drawn — refresh the still slice immediately
|
||||
// when idle. If playing, the running loop picks it up next frame.
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
// 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 {
|
||||
@@ -1739,7 +1780,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -1749,9 +1792,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// authoritative position. When pushes are regular the gap is ~0, so offset is
|
||||
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
|
||||
//
|
||||
// Only smooth while continuously playing. On a play/pause edge or while idle
|
||||
// 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 still frame must be
|
||||
// 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();
|
||||
@@ -1759,78 +1802,75 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
correctionOffset = 0;
|
||||
}
|
||||
|
||||
if (isPlaying && !wasPlaying) {
|
||||
// Transition paused/stopped → playing: start the rAF loop.
|
||||
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
|
||||
startLoop();
|
||||
} else if (!isPlaying && wasPlaying) {
|
||||
// Transition playing → paused/stopped: the in-flight frame draws the
|
||||
// final still position and exits on its own (frame() checks
|
||||
// playback.isPlaying before rescheduling). We do NOT stopLoop() here —
|
||||
// that would cancel the in-flight frame before it draws, leaving a
|
||||
// stale canvas. Let the frame run out.
|
||||
// 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.`);
|
||||
}
|
||||
// isPlaying unchanged (position-only update): the running loop (if any)
|
||||
// redraws next frame; nothing to do here.
|
||||
},
|
||||
|
||||
setZoom(seconds: number): void {
|
||||
// Clamp into the supported span so a stray value can't break the math.
|
||||
// ── 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));
|
||||
// While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the
|
||||
// loop is stopped (spec §E), so a zoom change must force one still frame here or the new
|
||||
// span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never.
|
||||
const idleRedraw = !playback.isPlaying;
|
||||
debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`);
|
||||
if (idleRedraw) redrawOnce();
|
||||
debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
// ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob
|
||||
// set). The bridge still calls these three setters by their OLD names — the names are
|
||||
// a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one
|
||||
// wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel
|
||||
// can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions
|
||||
// still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI.
|
||||
// setBubblyness ← "Bubblyness" knob → lava GRAVITY
|
||||
// setDetach ← "Detach" knob → lava HEAT
|
||||
// setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH
|
||||
// Idle redraw mirrors setZoom so a paused tweak still updates the still frame.
|
||||
setBubblyness(value: number): void {
|
||||
lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity
|
||||
debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`);
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
// Gradient rotation speed: INERT until Wave R3. Stored so the knob round-trips/persists; the
|
||||
// R2 flat placeholder fill ignores it, so there is nothing to redraw.
|
||||
setGradientRotationSpeed(value: number): void {
|
||||
gradientRotationSpeed = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)} (inert until R3).`);
|
||||
},
|
||||
|
||||
setDetach(value: number): void {
|
||||
lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat
|
||||
debugLog(`setHeat (via setDetach) → ${lavaHeat.toFixed(3)}.`);
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
setLavaGravity(value: number): void {
|
||||
lavaGravity = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setLavaGravity → ${lavaGravity.toFixed(3)}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
setColorShiftSpeed(value: number): void {
|
||||
collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness
|
||||
debugLog(`setCollisionStrength (via setColorShiftSpeed) → ${collisionStrength.toFixed(3)}.`);
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
setLavaHeat(value: number): void {
|
||||
lavaHeat = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setLavaHeat → ${lavaHeat.toFixed(3)}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
setBlobDensity(value: number): void {
|
||||
blobDensity = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setBlobDensity → ${blobDensity.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();
|
||||
},
|
||||
|
||||
// R2 TEMP: the resolution/zoom knob is repurposed to the waveform-width param this wave
|
||||
// (scroll speed isn't critical for evaluating the lava). The bridge calls this with the
|
||||
// raw knob fraction [0,1]; 1 = full ribbon, lower narrows the band. R4 gives width its
|
||||
// own knob and restores the resolution knob to setZoom.
|
||||
setWaveformWidth(value: number): void {
|
||||
waveformWidth = Math.min(1, Math.max(0, value));
|
||||
debugLog(`setWaveformWidth (via resolution knob) → ${waveformWidth.toFixed(3)}.`);
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
refreshTheme(): void {
|
||||
theme = readTheme();
|
||||
if (!playback.isPlaying) redrawOnce();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user