From bf00b7f22fb8d700c1261b64c3b16294fab8cc7a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:15:44 -0400 Subject: [PATCH] feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2) --- .../Controls/MixVisualizerControls.razor | 96 +++++++++++++++++++ .../Controls/MixVisualizerControls.razor.css | 34 +++++++ .../Controls/MixWaveformVisualizer.razor | 23 +---- .../Controls/MixWaveformVisualizer.razor.cs | 69 ++++++++----- .../Controls/MixWaveformVisualizer.razor.css | 24 ----- .../Controls/ReleaseDetailScaffold.razor | 2 + .../Controls/ReleaseDetailScaffold.razor.cs | 7 ++ DeepDrftPublic.Client/Pages/MixDetail.razor | 6 ++ .../Services/MixVisualizerControlState.cs | 68 +++++++++++++ .../Services/MixVisualizerZoomState.cs | 20 ---- DeepDrftPublic.Client/Startup.cs | 6 +- .../Interop/visualizer/MixVisualizer.ts | 71 +++++++++++++- 12 files changed, 332 insertions(+), 94 deletions(-) create mode 100644 DeepDrftPublic.Client/Controls/MixVisualizerControls.razor create mode 100644 DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css create mode 100644 DeepDrftPublic.Client/Services/MixVisualizerControlState.cs delete mode 100644 DeepDrftPublic.Client/Services/MixVisualizerZoomState.cs diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor new file mode 100644 index 0000000..53ed529 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor @@ -0,0 +1,96 @@ +@namespace DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Services +@inject MixVisualizerControlState ControlState + +@* The Mix visualizer controls row (Phase 10, Wave 2). Four continuous sliders — resolution, + bubblyness, detach, color-shift speed — placed above the mix details and below the back button. + This component owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState + and raises its Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event + and pushes the affected uniform to the WebGL module. That keeps the JS module handle single-owned + by the bridge and this component purely presentational. None of these is a seek surface (spec §D). *@ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +@code { + // Resolution rides the log mapping (slider fraction [0,1] ↔ visible seconds); the other three are + // already normalized [0,1] and bind to their state properties directly. + private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds); + + private void OnResolutionChanged(double fraction) + { + ControlState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction); + ControlState.NotifyChanged(); + } + + private void OnBubblynessChanged(double value) + { + ControlState.Bubblyness = value; + ControlState.NotifyChanged(); + } + + private void OnDetachChanged(double value) + { + ControlState.Detach = value; + ControlState.NotifyChanged(); + } + + private void OnColorShiftSpeedChanged(double value) + { + ControlState.ColorShiftSpeed = value; + ControlState.NotifyChanged(); + } +} diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css new file mode 100644 index 0000000..0653f51 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css @@ -0,0 +1,34 @@ +/* The controls row sits in the mix-detail foreground, below the back button and above the masthead. + A horizontal row of four icon+slider controls. On narrow viewports it wraps to keep all four + present (spec §3b: wrap is the chosen mobile behaviour — none may drop). */ +.mix-visualizer-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1.5rem; + margin: 0.5rem 0 1.5rem; +} + +/* One control: a compact label icon followed by the slider. The slider gets a fixed-ish track width + so the four read as a tidy row rather than stretching unevenly. */ +.mix-visualizer-control { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1 1 180px; + min-width: 160px; + max-width: 260px; +} + +.mix-visualizer-control-icon { + flex: 0 0 auto; + opacity: 0.7; +} + +/* MudSlider renders a Razor component, so its root is reached with ::deep (a bare scoped selector + would not be stamped onto the child component's element). Let the slider fill the remaining width + of its control so the icon+slider pair lays out cleanly. */ +.mix-visualizer-control ::deep .mud-slider { + flex: 1 1 auto; + margin: 0; +} diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor index 15d4b35..ea9c23c 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor @@ -11,23 +11,6 @@ -@* Viewing control only — never a seek surface. Hidden until a datum is present. - Deliberately a SIBLING of .mix-waveform-bg, not a child: the backdrop is position:fixed and so - forms its own stacking context, which would trap any descendant below the page's z-index:1 - foreground (.mix-detail-foreground) and let that foreground swallow the slider's pointer events. - As a top-level sibling with its own z-index, the slider stacks above the foreground and stays - draggable. *@ -@if (_hasDatum) -{ -
- -
-} +@* The viewing controls (resolution + the three Wave 2 controls) live in MixVisualizerControls, + rendered in the mix-detail foreground row below the back button — NOT here. This component is now a + pure backdrop bridge; it pushes uniforms in response to the shared MixVisualizerControlState. *@ diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index 55ef5e6..cfaedf9 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -24,7 +24,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable { [Inject] public required IReleaseDataService ReleaseData { get; set; } [Inject] public required IJSRuntime JS { get; set; } - [Inject] public required MixVisualizerZoomState ZoomState { get; set; } + [Inject] public required MixVisualizerControlState ControlState { get; set; } [Inject] public required ILogger Logger { get; set; } // Live playback + the mix duration come from the cascaded streaming player when present. The @@ -73,7 +73,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable private IStreamingPlayerService? _subscribedService; private WaveformProfileDto? _profile; private long? _loadedReleaseId; - private bool _hasDatum; + + // Whether we are subscribed to the shared control state's Changed event. The controls row (a + // sibling component) mutates ControlState and raises Changed; we push the affected uniforms. + private bool _subscribedToControls; // The profile reference last sent to the module, plus whether it went with a real duration. // Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only @@ -84,11 +87,18 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable // Theme last pushed to the module, so we only re-push on an actual change. private bool? _lastIsDark; - /// - /// Slider position in [0, 1]. 0 = most zoomed-out (MaxVisibleSeconds), 1 = most zoomed-in - /// (MinVisibleSeconds). Derived from the session-persisted seconds via the log mapping below. - /// - private double ZoomFraction => MixZoomMapping.SecondsToFraction(ZoomState.VisibleSeconds); + protected override void OnInitialized() + { + // Subscribe once to the shared control state. The controls row mutates it and raises Changed; + // we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps + // the handle single-owned (no handle sharing, no service-locator) — the scoped state object is + // the decoupling seam between the foreground controls and this backdrop bridge. + if (!_subscribedToControls) + { + ControlState.Changed += OnControlStateChanged; + _subscribedToControls = true; + } + } protected override async Task OnParametersSetAsync() { @@ -119,7 +129,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0) { _profile = profile; - _hasDatum = true; DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}."); } else @@ -127,7 +136,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable // No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still // renders its content over a plain background. _profile = null; - _hasDatum = false; DebugLog(result.Success ? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseId={ReleaseId}) — backdrop stays blank." : $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank."); @@ -165,8 +173,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable return; } - // Seed the module with the current state now that it exists. - await PushZoomAsync(); + // Seed the module with the current state now that it exists. All four control values + // come from the shared (session-persisted) state, so a mix opened mid-session seeds the + // module with the slider positions the listener left them at. + await PushControlsAsync(); await PushDatumAsync(); await PushPlaybackAsync(); await PushThemeIfChangedAsync(); @@ -177,16 +187,29 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable await PushThemeIfChangedAsync(); } - private async Task OnZoomFractionChanged(double fraction) + // The controls row mutated a slider on the shared state and raised Changed. Push all four control + // uniforms (cheap scalar interop; the inert three are no-ops in the parity shader until Wave 3). + private void OnControlStateChanged() => InvokeAsync(async () => { - ZoomState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction); - DebugLog($"zoom slider changed — raw fraction={fraction:F3} → visibleSeconds={ZoomState.VisibleSeconds:F3}s; pushing setZoom (handle={(_handle is null ? "null" : "ready")})."); - await PushZoomAsync(); - StateHasChanged(); - } + await PushControlsAsync(); + }); // ── Bridge pushes. Each is a no-op until the module handle exists. ─────────────────────────── + /// + /// Push all four control values to the module from the shared state. Used to seed on first render + /// and to re-push when the controls row signals a change. Resolution drives the live render; the + /// other three are inert in the parity shader (Wave 3 consumes them). + /// + private async Task PushControlsAsync() + { + if (_handle is null) return; + await _handle.InvokeVoidAsync("setZoom", ControlState.VisibleSeconds); + await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness); + await _handle.InvokeVoidAsync("setDetach", ControlState.Detach); + await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed); + } + /// /// Push the datum to the module, but only when it actually changed — a different profile, or the /// mix duration becoming available for the first time. Idempotent so the per-tick playback path @@ -242,12 +265,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying); } - private async Task PushZoomAsync() - { - if (_handle is null) return; - await _handle.InvokeVoidAsync("setZoom", ZoomState.VisibleSeconds); - } - private async Task PushThemeIfChangedAsync() { if (_handle is null) return; @@ -297,6 +314,12 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable _subscribedService = null; } + if (_subscribedToControls) + { + ControlState.Changed -= OnControlStateChanged; + _subscribedToControls = false; + } + if (_handle is not null) { try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { } diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css index 97529cc..78a7d01 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css @@ -19,27 +19,3 @@ height: 100%; display: block; } - -/* Zoom slider — a small viewing control pinned to the top-right, clear of the player bar at - the bottom and the nav bar at the top. It is never a seek surface. top: 5rem sits just below the - fixed nav bar (~4.5rem tall) so neither the expanded player bar nor the nav occludes it. - - position: fixed (not absolute) because the slider is now a top-level sibling of the backdrop, not - a child of it — see the comment in the .razor. z-index: 10 lifts it above the page foreground - (.mix-detail-foreground, z-index: 1) so the foreground can't intercept its pointer events; that - occlusion was the resolution-slider regression. */ -.mix-waveform-zoom { - position: fixed; - right: 1.5rem; - top: 5rem; - z-index: 10; - width: 180px; - max-width: 40vw; - pointer-events: auto; - opacity: 0.7; - transition: opacity 0.2s ease; -} - -.mix-waveform-zoom:hover { - opacity: 1; -} diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor index 6b0f7a7..947945d 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor @@ -11,6 +11,8 @@ ← @BackLabel + @TopContent +
@Title diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs index cbabae0..9f90815 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs @@ -24,6 +24,13 @@ public partial class ReleaseDetailScaffold : ComponentBase [Parameter] public string BackHref { get; set; } = "/archive"; [Parameter] public string BackLabel { get; set; } = "Archive"; + /// + /// Optional medium-specific content rendered between the back link and the masthead — the "below + /// the back button, above the details" band. The Mix detail page uses it for the visualizer + /// controls row; other media leave it null. + /// + [Parameter] public RenderFragment? TopContent { get; set; } + /// Medium-specific hero visual (cover art, hero image, or waveform background). [Parameter] public RenderFragment? Hero { get; set; } diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index e827cba..e206344 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -46,6 +46,12 @@ else BackHref="/mixes" BackLabel="All mixes" ShowMeta="@(hasGenre || hasDate)"> + + @* The four visualizer controls — resolution, bubblyness, detach, color-shift speed — + in a row below the back button and above the masthead (spec §3). They mutate the + shared MixVisualizerControlState; the backdrop bridge above pushes the uniforms. *@ + +
@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();