@@ -114,11 +108,10 @@ else
@code {
protected override string PersistKey => "mix-detail";
- // Lava-lamp settings popover open state. Pure presentation over MixVisualizerControlState — the
- // popover discloses the four knobs; toggling it touches no control value or bridge push.
- private bool _settingsOpen;
+ // Lava-lamp inline knob-bar expanded state. Pure presentation over MixVisualizerControlState — the
+ // bar discloses the seven knobs and animates open/closed; toggling it touches no control value or
+ // bridge push. The lava-lamp button's filled/outline glyph is driven off this same flag.
+ private bool _controlsExpanded;
- private void ToggleSettings() => _settingsOpen = !_settingsOpen;
-
- private void CloseSettings() => _settingsOpen = false;
+ private void ToggleSettings() => _controlsExpanded = !_controlsExpanded;
}
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor.css b/DeepDrftPublic.Client/Pages/MixDetail.razor.css
index 0f0b971..8af5797 100644
--- a/DeepDrftPublic.Client/Pages/MixDetail.razor.css
+++ b/DeepDrftPublic.Client/Pages/MixDetail.razor.css
@@ -4,6 +4,28 @@
z-index: 1;
}
+/* The lava-lamp toggle + its inline knob-bar. The anchor stacks the button over the bar and lets the
+ bar grow downward/leftward in place when expanded, without shoving the masthead. The bar itself is
+ absolutely positioned under the button (top-right of the detail body), so its open/close animation
+ reads as the controls growing out from the icon rather than reflowing the page (lava reframe §7b). */
+.mix-visualizer-controls-anchor {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+}
+
+/* MixVisualizerControls renders the .mix-visualizer-controls-bar as its single root. It is a child
+ Razor component, so its scope attribute is not stamped here — reach the bar with ::deep to position
+ it as a floating-but-inline element anchored to the toggle's bottom-right. */
+.mix-visualizer-controls-anchor ::deep .mix-visualizer-controls-bar {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ z-index: 3;
+ transform-origin: top right;
+}
+
/* Medium square cover — deliberately smaller than the 360px cut cover so the
waveform backdrop keeps room. The placeholder/art MudPaper fills this frame. */
.mix-detail-cover {
diff --git a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
index 954367b..c26e04c 100644
--- a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
+++ b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
@@ -1,89 +1,103 @@
namespace DeepDrftPublic.Client.Services;
///
-/// Holds the Mix visualizer's four continuous-control positions for the lifetime of the WASM app
+/// Holds the Mix visualizer's seven continuous-control positions for the lifetime of the WASM app
/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a
-/// second mix and the sliders keep where you left them — but a fresh page load (F5) constructs a new
+/// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new
/// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh
-/// load" without any cookie/localStorage round-trip (see mix-visualizer-webgl-renderer §3c).
+/// load" without any cookie/localStorage round-trip (lava reframe §7c).
///
-/// One state object, four properties — not four sibling holders (Daniel's decided shape, spec §3c).
-/// Each C#-side default mirrors a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as
-/// the existing DefaultVisibleSeconds / DEFAULT_VISIBLE_SECONDS pair does.
+/// One state object, seven properties — not seven sibling holders, and (deliberately) NO constructor
+/// parameters: this is a plain scoped value holder, so widening it from four to seven properties adds
+/// fields + defaults only and never forces a consumer constructor to grow. Each C#-side default mirrors
+/// a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as the existing
+/// DefaultVisibleSeconds / DEFAULT_VISIBLE_SECONDS pair does.
///
///
-/// is the decoupling seam between the controls row and the visualizer bridge.
+/// is the decoupling seam between the controls bar and the visualizer bridge.
/// The controls component (a sibling of the backdrop in the page tree) only mutates this shared state
/// and raises ; the bridge component (MixWaveformVisualizer) subscribes
-/// and pushes the affected uniform to the JS module. This keeps the JS module handle single-owned by
-/// the bridge — no handle sharing, no service-locator, no cross-component interop.
+/// and pushes the affected uniform/dial to the JS module. This keeps the JS module handle single-owned
+/// by the bridge — no handle sharing, no service-locator, no cross-component interop.
///
///
public sealed class MixVisualizerControlState
{
- ///
- /// Default opening window. Mirrors DEFAULT_VISIBLE_SECONDS in MixVisualizer.ts; keep the
- /// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
- ///
- public const double DefaultVisibleSeconds = 10.0;
-
- // R2 TEMP (Phase 10 reframe Wave R2): the FOUR controls below are re-routed to the new
- // lava physics for Daniel's in-browser test — the JS handle setters map them as:
- // Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH,
- // Resolution (VisibleSeconds knob) → WAVEFORM WIDTH (see MixVisualizerControls.razor).
- // The defaults are tuned to Daniel's sweet spot (~20% gravity, ~100% heat). Wave R4
- // replaces this with the proper seven-knob set + its own typed properties. Keep these
- // mirrored to the DEFAULT_* anchors in MixVisualizer.ts, as the existing sync discipline.
+ // ── The seven control defaults (lava reframe §7a). Each mirrors a DEFAULT_* anchor in
+ // MixVisualizer.ts; keep the two in sync, as the existing default-sync discipline requires.
+ // Feel-anchors only — Daniel tunes on screen; the ~20% gravity / ~100% heat pair is his stated
+ // sweet spot (§4c).
///
- /// Default GRAVITY dial (R2 temp; was bulge). Mirrors DEFAULT_BUBBLYNESS in MixVisualizer.ts.
- /// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's
- /// ~20% sweet spot so the wax is buoyant-dominated and flows.
+ /// Default scroll-speed dial. Mirrors DEFAULT_SCROLL_SPEED in MixVisualizer.ts. Normalized
+ /// [0,1] → mapped to the visible time-span via (0 = slow/wide window,
+ /// 1 = fast/tight window). Opens mid-range.
///
- public const double DefaultBubblyness = 0.2;
+ public const double DefaultScrollSpeed = 0.5;
///
- /// Default HEAT dial (R2 temp; was detach). Mirrors DEFAULT_DETACH in MixVisualizer.ts.
- /// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = lots of small turbulent
- /// bubbles. Tuned to Daniel's ~100% sweet spot.
+ /// Default gradient-rotation-speed dial. Mirrors DEFAULT_GRADIENT_ROTATION_SPEED in
+ /// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation. INERT until Wave R3 builds the
+ /// OKLab three-colour gradient that consumes it.
///
- public const double DefaultDetach = 1.0;
+ public const double DefaultGradientRotationSpeed = 0.3;
///
- /// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors
- /// DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts. Normalized [0,1]; 0 = soft mush,
- /// 1 = hard elastic throw.
+ /// Default lava-gravity dial. Mirrors DEFAULT_LAVA_GRAVITY in MixVisualizer.ts. Normalized
+ /// [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's ~20% sweet spot.
///
- public const double DefaultColorShiftSpeed = 0.5;
+ public const double DefaultLavaGravity = 0.2;
///
- /// Default WAVEFORM-WIDTH dial (R2 temp; routed to the resolution/zoom knob this wave). Mirrors
- /// DEFAULT_WAVEFORM_WIDTH in MixVisualizer.ts. Normalized [0,1]; 1 = full ribbon width
- /// (prior look), lower narrows the band so the lava gets more room. Opens at full width.
+ /// Default lava-heat dial. Mirrors DEFAULT_LAVA_HEAT in MixVisualizer.ts. Normalized [0,1];
+ /// 0 = wax rests at the bottom (collision-only), 1 = many small turbulent rising bubbles. Tuned to
+ /// Daniel's ~100% sweet spot.
///
- public const double DefaultWaveformWidth = 1.0;
-
- ///
Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.
- public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
-
- ///
Bulge amount, normalized [0,1]. Inert until Wave 3 consumes the uniform.
- public double Bubblyness { get; set; } = DefaultBubblyness;
-
- ///
Lava-lamp detachment, normalized [0,1]. Inert until Wave 3 consumes the uniform.
- public double Detach { get; set; } = DefaultDetach;
-
- ///
Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.
- public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
+ public const double DefaultLavaHeat = 1.0;
///
- /// Waveform width, normalized [0,1]. R2 TEMP: routed to the resolution/zoom knob for in-browser
- /// testing (Wave R4 gives it its own knob and restores the resolution knob to VisibleSeconds).
+ /// Default blob-density dial. Mirrors DEFAULT_BLOB_DENSITY in MixVisualizer.ts. Normalized
+ /// [0,1]; 0 = a few large lazy blobs, 1 = many smaller active blobs.
///
+ public const double DefaultBlobDensity = 0.4;
+
+ ///
+ /// Default collision-strength dial. Mirrors DEFAULT_COLLISION_STRENGTH in MixVisualizer.ts.
+ /// Normalized [0,1]; 0 = soft mush, 1 = hard elastic up-and-out throw.
+ ///
+ public const double DefaultCollisionStrength = 0.5;
+
+ ///
+ /// Default waveform-width dial. Mirrors DEFAULT_WAVEFORM_WIDTH in MixVisualizer.ts.
+ /// Normalized [0,1]; 1 = full ribbon width, lower narrows the band so the lava gets more room.
+ ///
+ public const double DefaultWaveformWidth = 0.6;
+
+ ///
Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
+ /// time-span via ; the standalone resolution/zoom control is gone.
+ public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
+
+ ///
Gradient anchor-rotation rate, normalized [0,1]. Inert until Wave R3 consumes it.
+ public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed;
+
+ ///
Downward force on the wax, normalized [0,1].
+ public double LavaGravity { get; set; } = DefaultLavaGravity;
+
+ ///
Energy into the lava system, normalized [0,1]. 0 = rest-at-bottom, 1 = roiling.
+ public double LavaHeat { get; set; } = DefaultLavaHeat;
+
+ ///
Amount of wax (blob count/size), normalized [0,1].
+ public double BlobDensity { get; set; } = DefaultBlobDensity;
+
+ ///
Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw.
+ public double CollisionStrength { get; set; } = DefaultCollisionStrength;
+
+ ///
Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
///
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
- /// affected uniform(s). Mutators set the property then raise this; subscribers re-read the values.
+ /// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
///
public event Action? Changed;
diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
index ab1fb46..a6f743f 100644
--- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
+++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
@@ -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) {
diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
index 8d2a03c..f27154a 100644
--- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
+++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
@@ -364,15 +364,6 @@ h2, h3, h4, h5, h6,
max-width: 360px;
}
-/* The lava-lamp visualizer-settings popover (Wave 4). Holds the four RadialKnobs in a row; sized so the
- four read clearly with comfortable padding, wrapping to 2×2 on narrow viewports (the inner
- .mix-visualizer-controls owns the flex-wrap). MudPopover renders into the popover-provider portal at
- the document root, so this is a global class — not component-scoped. */
-.mix-visualizer-popover {
- padding: 0.75rem;
- max-width: 360px;
-}
-
/* Monospace snippet so the iframe markup stays legible inside the readonly field. */
.deepdrft-share-embed-field {
flex: 1 1 auto;
diff --git a/DeepDrftShared.Client/Common/DDIcons.cs b/DeepDrftShared.Client/Common/DDIcons.cs
index 5be0a7e..b25eb55 100644
--- a/DeepDrftShared.Client/Common/DDIcons.cs
+++ b/DeepDrftShared.Client/Common/DDIcons.cs
@@ -33,4 +33,22 @@ public static class DDIcons
public const string LavaLamp = """
""";
+
+ ///
+ /// Lava lamp — the FILLED variant, shown when the Mix visualizer controls are EXPANDED. Same
+ /// SVG-Repo glyph as (scale(0.48), inner markup only — no outer
+ /// <svg> wrapper) but the BASE and the WAX BUBBLES are filled solid with theme accents
+ /// instead of reading as an outline, so the toggle button visibly switches to an "active" state.
+ ///
+ /// The vessel silhouette stays currentColor so it themes for free (Color.Secondary,
+ /// light/dark) exactly as the existing icons do. The two accent fills genuinely must be literal
+ /// hex (an SVG fill in a raw-string const cannot resolve var(--mud-palette-*)): the wax fluid
+ /// is NAVY and the bubbles are MOSS. These mirror DeepDrftShared.Client.Common.DeepDrftPalettes —
+ /// navy = PaletteLight.Primary (#17283f), moss = PaletteLight.Secondary (#3D7A68). Same
+ /// currentColor-where-possible + commented-literal-where-not discipline the gas-lamp flame uses.
+ /// If the palette navy/moss tokens change, update these two literals to match.
+ ///
+ public const string LavaLampFilled = """
+
+ """;
}