@if (!string.IsNullOrEmpty(release.ImagePath))
diff --git a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
new file mode 100644
index 0000000..4c69f18
--- /dev/null
+++ b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs
@@ -0,0 +1,68 @@
+namespace DeepDrftPublic.Client.Services;
+
+///
+/// Holds the Mix visualizer's four 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
+/// 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).
+///
+/// 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.
+///
+///
+/// is the decoupling seam between the controls row 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.
+///
+///
+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;
+
+ ///
+ /// Default bulge amount. Mirrors DEFAULT_BUBBLYNESS in MixVisualizer.ts. Normalized [0,1];
+ /// 0 = straight rectangular bars, 1 = fully rounded liquid silhouettes (still attached).
+ ///
+ public const double DefaultBubblyness = 0.35;
+
+ ///
+ /// Default detach amount. Mirrors DEFAULT_DETACH in MixVisualizer.ts. Normalized [0,1];
+ /// 0 = fully attached, 1 = blobs separate and float upward. Off by default.
+ ///
+ public const double DefaultDetach = 0.0;
+
+ ///
+ /// Default color-shift speed. Mirrors DEFAULT_COLOR_SHIFT_SPEED in MixVisualizer.ts.
+ /// Normalized [0,1], mapped to a gradient-morph cycle period in the shader (slow → quick).
+ ///
+ public const double DefaultColorShiftSpeed = 0.3;
+
+ ///
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;
+
+ ///
+ /// 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.
+ ///
+ public event Action? Changed;
+
+ ///
Raise . Called by the controls component after mutating a value.
+ public void NotifyChanged() => Changed?.Invoke();
+}
diff --git a/DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs b/DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs
deleted file mode 100644
index de00606..0000000
--- a/DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace DeepDrftPublic.Client.Services;
-
-///
-/// Holds the Mix visualizer's zoom (visible time-span in seconds) 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 slider keeps where you left it — but a fresh page load (F5) constructs a new
-/// instance, resetting to the default. That matches the spec's "persist within session, reset on
-/// fresh load" without any cookie/localStorage round-trip (see phase-9-mix-visualizer-redesign §B).
-///
-public sealed class MixVisualizerZoomState
-{
- ///
- /// 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;
-
- ///
Visible time-span in seconds. Survives navigation; resets on fresh page load.
- public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
-}
diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs
index 80d245e..39ca11b 100644
--- a/DeepDrftPublic.Client/Startup.cs
+++ b/DeepDrftPublic.Client/Startup.cs
@@ -27,9 +27,9 @@ public static class Startup
services.AddScoped
();
services.AddScoped();
- // Mix visualizer zoom — scoped so it persists across navigation within a session and
- // resets on a fresh page load (see MixVisualizerZoomState).
- services.AddScoped();
+ // Mix visualizer controls — scoped so the four slider positions persist across navigation
+ // within a session and reset on a fresh page load (see MixVisualizerControlState).
+ services.AddScoped();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
index aafda76..24f0671 100644
--- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
+++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts
@@ -44,6 +44,21 @@ export const MAX_VISIBLE_SECONDS = 30;
/** Default opening window when a mix is first opened. Tunable. */
export const DEFAULT_VISIBLE_SECONDS = 10;
+// ── Wave 2 control tuning anchors. These mirror the C#-side defaults in ───────────
+// MixVisualizerControlState.cs — keep the two in sync, exactly as the
+// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are
+// normalized [0,1]. They are wired through to GPU uniforms now (Wave 2 plumbing) but
+// the parity shader does NOT consume them visually yet — they come alive in Wave 3.
+
+/** Default bulge amount, normalized [0,1]. Mirrors C# DefaultBubblyness. */
+export const DEFAULT_BUBBLYNESS = 0.35;
+
+/** Default lava-lamp detach amount, normalized [0,1]. Mirrors C# DefaultDetach. */
+export const DEFAULT_DETACH = 0;
+
+/** Default gradient-morph rate, normalized [0,1]. Mirrors C# DefaultColorShiftSpeed. */
+export const DEFAULT_COLOR_SHIFT_SPEED = 0.3;
+
/**
* Where the "now" line sits within the window, as a fraction from the top.
* 0.5 = vertical centre (default): a short lead-in below, a short trail-out above.
@@ -231,6 +246,12 @@ export interface MixVisualizerHandle {
setDatum(samplesBase64: string, durationSeconds: number): void;
setPlayback(positionSeconds: number, isPlaying: boolean): void;
setZoom(visibleSeconds: number): void;
+ /** Bulge amount [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
+ setBubblyness(value: number): void;
+ /** Lava-lamp detach [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
+ setDetach(value: number): void;
+ /** Gradient-morph rate [0,1]. Wave 2: sets the uniform; the parity shader does not consume it yet. */
+ setColorShiftSpeed(value: number): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
dispose(): void;
@@ -316,6 +337,9 @@ uniform vec2 uResolution; // canvas size in device pixels
uniform float uPlayheadSeconds; // current playback position (per-frame)
uniform float uTimeSeconds; // monotonic clock (per-frame) — reserved for Wave 3 motion
uniform float uVisibleSeconds; // zoom: window time-span (per change)
+uniform float uBubblyness; // bulge amount [0,1] (per change) — reserved for Wave 3, inert now
+uniform float uDetach; // lava-lamp detach [0,1] (per change) — reserved for Wave 3, inert now
+uniform float uColorShiftSpeed; // gradient-morph rate [0,1] (per change) — reserved for Wave 3, inert now
uniform float uDurationSeconds; // mix length (per datum)
uniform vec3 uColorAccent; // brightest stop, at the now line (per theme)
uniform vec3 uColorEdge; // dim stop, at the window edges (per theme)
@@ -456,6 +480,9 @@ function noopHandle(): MixVisualizerHandle {
setDatum() {},
setPlayback() {},
setZoom() {},
+ setBubblyness() {},
+ setDetach() {},
+ setColorShiftSpeed() {},
refreshTheme() {},
dispose() {},
};
@@ -504,14 +531,20 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// 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. `uTimeSeconds` is reserved for Wave 3 and currently
- // unused by the fragment shader, so the compiler is free to strip it; we exempt
- // it from the warning to avoid a false alarm.
+ // effect, so surface them. The Wave-3-reserved uniforms (`uTimeSeconds`,
+ // `uBubblyness`, `uDetach`, `uColorShiftSpeed`) are declared and uploaded but not
+ // yet consumed by the parity shader, so the compiler is free to dead-strip them;
+ // we exempt them from the warning to avoid a false alarm. Their values still reach
+ // the GPU when a location survives (verifiable in Wave 3).
+ const RESERVED_UNUSED = new Set(['timeSeconds', 'bubblyness', 'detach', 'colorShiftSpeed']);
const u = {
resolution: gl.getUniformLocation(program, 'uResolution'),
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
+ bubblyness: gl.getUniformLocation(program, 'uBubblyness'),
+ detach: gl.getUniformLocation(program, 'uDetach'),
+ colorShiftSpeed: gl.getUniformLocation(program, 'uColorShiftSpeed'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorAccent: gl.getUniformLocation(program, 'uColorAccent'),
colorEdge: gl.getUniformLocation(program, 'uColorEdge'),
@@ -521,7 +554,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
datumSampleCount: gl.getUniformLocation(program, 'uDatumSampleCount'),
};
for (const [name, loc] of Object.entries(u)) {
- if (loc === null && name !== 'timeSeconds') {
+ if (loc === null && !RESERVED_UNUSED.has(name)) {
console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`);
}
}
@@ -530,6 +563,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let datum: Datum | null = null;
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
+ // Wave 2 control values, fed through the handle. Uploaded as uniforms in draw() but inert in the
+ // parity shader (Wave 3 consumes them). Seeded to the defaults that mirror MixVisualizerControlState.
+ let bubblyness = DEFAULT_BUBBLYNESS;
+ let detach = DEFAULT_DETACH;
+ let colorShiftSpeed = DEFAULT_COLOR_SHIFT_SPEED;
/**
* The *authoritative* playhead for this instant: the last pushed position advanced
@@ -695,6 +733,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
// separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds);
+ // Wave 2 control uniforms. Uploaded every frame (cheap scalars); inert in the parity shader.
+ // gl.uniform1f with a null location (dead-stripped uniform) is a documented silent no-op, so
+ // these are safe to set unconditionally even before the Wave 3 shader references them.
+ gl.uniform1f(u.bubblyness, bubblyness);
+ gl.uniform1f(u.detach, detach);
+ gl.uniform1f(u.colorShiftSpeed, colorShiftSpeed);
gl.uniform3fv(u.colorAccent, theme.accent);
gl.uniform3fv(u.colorEdge, theme.edge);
@@ -977,6 +1021,25 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
if (idleRedraw) redrawOnce();
},
+ // The three Wave 2 controls. Each clamps to [0,1], stores the value (uploaded as a uniform in
+ // draw()), and forces one still frame while idle — mirroring setZoom — so the new value reaches
+ // the GPU even when paused. INERT in Wave 2: the parity shader does not read these uniforms, so
+ // a change does not visibly alter the render; the value is verifiable in Wave 3.
+ setBubblyness(value: number): void {
+ bubblyness = Math.min(1, Math.max(0, value));
+ if (!playback.isPlaying) redrawOnce();
+ },
+
+ setDetach(value: number): void {
+ detach = Math.min(1, Math.max(0, value));
+ if (!playback.isPlaying) redrawOnce();
+ },
+
+ setColorShiftSpeed(value: number): void {
+ colorShiftSpeed = Math.min(1, Math.max(0, value));
+ if (!playback.isPlaying) redrawOnce();
+ },
+
refreshTheme(): void {
theme = readTheme();
if (!playback.isPlaying) redrawOnce();