diff --git a/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs b/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs index 7fd9696..8f2a2f3 100644 --- a/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs +++ b/DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs @@ -3,11 +3,20 @@ namespace DeepDrftContent.Processors; /// /// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned, /// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into -/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1. -/// No external audio dependency — operates directly on the WAV data-chunk bytes. +/// equal time slices, takes the RMS of each slice, applies a ~50 ms envelope-follower smoothing +/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so +/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes. /// public class RmsLoudnessAlgorithm : ILoudnessAlgorithm { + /// + /// Envelope-follower time constant, seconds. ~50 ms is the spec's smoothing target (Phase 10 + /// tuning): long enough to round off the per-bucket RMS spikes into a smooth ribbon contour, + /// short enough that real loudness transients (kicks, drops) still read. Applied as a symmetric + /// (forward+backward) one-pole filter so the smoothing introduces no time lag. + /// + public const double SmoothingTimeConstantSeconds = 0.05; + public double[] Compute(ReadOnlySpan pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount) { if (bucketCount <= 0) @@ -64,16 +73,28 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm counts[bucket]++; } - var peak = 0.0; for (var i = 0; i < bucketCount; i++) { if (counts[i] > 0) { result[i] = Math.Sqrt(sumSquares[i] / counts[i]); - if (result[i] > peak) - { - peak = result[i]; - } + } + } + + // Envelope smoothing (~50 ms): round the spikey per-bucket RMS into a smooth contour before + // peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons. + // Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived + // from that against the time constant so the smoothing is duration-aware, not a fixed window. + var totalSeconds = (double)frameCount / sampleRate; + var bucketSeconds = totalSeconds / bucketCount; + SmoothEnvelope(result, bucketSeconds); + + var peak = 0.0; + for (var i = 0; i < bucketCount; i++) + { + if (result[i] > peak) + { + peak = result[i]; } } @@ -92,6 +113,42 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm return result; } + /// + /// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass + /// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned + /// with the audio (no rightward time shift). The coefficient a = exp(−bucketSeconds / τ) + /// gives a ~-relative response targeting the ~50 ms time constant: + /// each bucket blends (1 − a) of itself with a of the running envelope. A near-zero + /// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully). + /// + private static void SmoothEnvelope(double[] data, double bucketSeconds) + { + if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds)) + { + return; + } + + var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds); + // a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already + // spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below. + + // Forward pass. + var env = data[0]; + for (var i = 0; i < data.Length; i++) + { + env = a * env + (1 - a) * data[i]; + data[i] = env; + } + + // Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero. + env = data[^1]; + for (var i = data.Length - 1; i >= 0; i--) + { + env = a * env + (1 - a) * data[i]; + data[i] = env; + } + } + /// /// Decodes one PCM sample at to a normalized amplitude in [-1, 1]. /// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian. diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor index 5d48a19..b42e75f 100644 --- a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor +++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor @@ -2,12 +2,17 @@ @using DeepDrftPublic.Client.Services @inject MixVisualizerControlState ControlState -@* The Mix visualizer controls. SEVEN continuous RadialKnobs — scroll speed, gradient rotation speed, - lava gravity, lava heat, blob density, collision strength, waveform width — each its own dedicated - control with a Material-icon caption. Visibility is controlled by Blazor, not CSS: the host page - renders this component only while the lava-lamp toggle is on (@if-guarded), so when off it is not in - the DOM at all. There is no collapse/expand animation and no glass surface — the knobs simply appear - in their own transparent band and disappear when un-rendered. +@* The Mix visualizer controls. EIGHT continuous RadialKnobs — scroll speed, gradient rotation speed, + lava gravity, lava heat, fluid amount, fluid viscosity, collision strength, waveform width — each its + own dedicated control with a Material-icon caption. The single "bubbles" knob is split into + fluid-amount + fluid-viscosity (Phase 10 §5). + + Visibility (Phase 10 §4): the host ALWAYS renders this component now and feeds the lava-lamp toggle + into the @Visible parameter. THIS component decides knob visibility — it @if-gates the knobs but keeps + the container's reserved size, so the content below the controls bar never pops when the lamp toggles. + The gating is Blazor @if (matching the established "@if-gated knob band, no CSS hide/glass/animation" + convention) — the knobs are simply not rendered when hidden, while a min-height container holds the + layout. No collapse animation, no glass surface, no CSS visibility-hiding of populated knobs. It 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 @@ -21,72 +26,92 @@
-
- - -
+ @if (Visible) + { +
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
+ +
+ + +
+ }
@code { + /// + /// Whether the knob band is shown. The host wires its lava-lamp toggle straight into this — the host + /// always renders this component, and THIS component decides knob visibility (Phase 10 §4). When + /// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so + /// content below the bar never pops as the lamp toggles. + /// + [Parameter] public bool Visible { get; set; } + // Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and // pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed // to a visible time-span and routes the rest straight to the lava/colour dials. @@ -115,9 +140,15 @@ ControlState.NotifyChanged(); } - private void OnBlobDensityChanged(double value) + private void OnFluidAmountChanged(double value) { - ControlState.BlobDensity = value; + ControlState.FluidAmount = value; + ControlState.NotifyChanged(); + } + + private void OnFluidViscosityChanged(double value) + { + ControlState.FluidViscosity = value; ControlState.NotifyChanged(); } diff --git a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css index 1439d89..9fe3da1 100644 --- a/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css +++ b/DeepDrftPublic.Client/Controls/MixVisualizerControls.razor.css @@ -1,7 +1,13 @@ -/* The seven-knob band. Blazor gates its presence (the host renders this component only while the - lava-lamp is on), so this is purely a layout rule for the visible state — no collapse machinery, no - transitions, no glass surface. A plain transparent horizontal flex row of the seven knobs that wraps - to a second line only if the band is genuinely too narrow. */ +/* The eight-knob band. Phase 10 §4: the host ALWAYS renders this component and the component @if-gates + the knobs on its Visible parameter. So the container is permanent and reserves its height whether or + not the knobs are present — content below the bar never pops on toggle. No collapse machinery, no + transitions, no glass surface. A plain transparent horizontal flex row of the eight knobs that wraps + to a second line only if the band is genuinely too narrow. + + min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so + the empty (hidden) state occupies the same vertical box the populated single-row state does. On very + narrow viewports a populated band may wrap to a second row and exceed this floor — the no-pop + guarantee is exact for the common single-row (desktop) layout. */ .mix-visualizer-controls-bar { display: flex; flex-wrap: wrap; @@ -9,6 +15,7 @@ justify-content: center; gap: 0.85rem 1rem; margin: 0.5rem 0; + min-height: 6rem; } /* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs index ee0ca29..78b2db6 100644 --- a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs @@ -202,28 +202,32 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable // ── Bridge pushes. Each is a no-op until the module handle exists. ─────────────────────────── /// - /// Push the seven control values to the module from the shared state. Used to seed on first render - /// and to re-push when the controls bar signals a change (lava reframe Wave R4). Each value is its - /// own dedicated dial now — no more R2 temp-remapping: + /// Push the eight control values to the module from the shared state. Used to seed on first render + /// and to re-push when the controls bar signals a change. Each value is its own dedicated dial: /// - /// scroll speed [0,1] is mapped to a visible time-span via and - /// pushed through setScrollSpeed (higher speed → tighter window → faster scroll); - /// gradient rotation speed → setGradientRotationSpeed (inert until Wave R3); - /// gravity / heat / blob density / collision strength → their dedicated lava-physics dials; + /// scroll speed [0,1] is mapped onto the useful zoom band via + /// and pushed through setScrollSpeed + /// (higher speed → tighter window → faster scroll); + /// gradient rotation speed → setGradientRotationSpeed (live OKLab anchor rotation); + /// gravity / heat / collision strength → their dedicated lava-physics dials; + /// fluid amount → setFluidAmount (blob count + volume); fluid viscosity → + /// setFluidViscosity (cohesion / sphere-restoration) — the Phase 10 split of the + /// former single density knob; /// waveform width → the ribbon-extent uniform. /// /// private async Task PushControlsAsync() { if (_handle is null) return; - // Scroll speed is a normalized [0,1] axis; map it to the visible time-span the renderer scrolls - // through. The log map keeps the even-to-the-hand feel the old zoom slider had. - var visibleSeconds = MixZoomMapping.FractionToSeconds(ControlState.ScrollSpeed); + // Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune — + // the knob's full travel now covers the 60%–100% zoom range, dropping the dead slow/wide end). + var visibleSeconds = MixZoomMapping.ScrollKnobToSeconds(ControlState.ScrollSpeed); await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds); await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed); await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity); await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat); - await _handle.InvokeVoidAsync("setBlobDensity", ControlState.BlobDensity); + await _handle.InvokeVoidAsync("setFluidAmount", ControlState.FluidAmount); + await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity); await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength); await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth); } diff --git a/DeepDrftPublic.Client/Controls/MixZoomMapping.cs b/DeepDrftPublic.Client/Controls/MixZoomMapping.cs index c23d7f4..1ff0f5e 100644 --- a/DeepDrftPublic.Client/Controls/MixZoomMapping.cs +++ b/DeepDrftPublic.Client/Controls/MixZoomMapping.cs @@ -15,6 +15,27 @@ public static class MixZoomMapping /// Longest span (min zoom). Tunable. public const double MaxVisibleSeconds = 30.0; + /// + /// Lower edge of the useful zoom band on the underlying fraction axis. Phase 10 retune: the bottom + /// 60% of the old knob travel (fraction 0…0.6) was a useless slow/wide window, so the scroll knob's + /// full [0,1] travel now maps onto the upper 0.6…1.0 band where every position reads as a useful + /// zoom (Daniel: "range below 60% is useless; optimize for the current 60%–110% zoom values" — 110% + /// caps at the hard 0.333 s max-zoom anchor, fraction 1.0). + /// + public const double ScrollKnobZoomFloor = 0.60; + + /// + /// Maps the scroll-speed knob [0,1] onto the useful zoom band [, 1.0] + /// of the underlying fraction axis, then to visible seconds. So knob 0 sits at the slow edge of the + /// *useful* range (not the dead slow end), and knob 1 reaches max zoom. Phase 10 scroll retune. + /// + public static double ScrollKnobToSeconds(double knob) + { + knob = Math.Clamp(knob, 0, 1); + var fraction = ScrollKnobZoomFloor + (1.0 - ScrollKnobZoomFloor) * knob; + return FractionToSeconds(fraction); + } + /// Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in. public static double FractionToSeconds(double fraction) { diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index 5041869..12a2e14 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -53,15 +53,13 @@ else ShowMeta="false" ShowShareRow="false"> - @* The seven-knob band lives in its own full-width area below the back/lamp top row. - Blazor — not CSS — controls its visibility: it is rendered only while the lava-lamp is - on, so when off it is not in the DOM at all. No background, no animation, no reflow of - the row above. The band mutates the shared MixVisualizerControlState; the backdrop - bridge pushes the dials. A knob drag does not toggle it — the lamp's click does. *@ - @if (_controlsExpanded) - { - - } + @* The eight-knob band lives in its own full-width area below the back/lamp top row. + Phase 10 §4: the control is ALWAYS rendered; the lava-lamp toggle feeds its Visible + parameter, and the control itself @if-gates the knobs while holding the container's + reserved height — so content below never pops on toggle. The band mutates the shared + MixVisualizerControlState; the backdrop bridge pushes the dials. A knob drag does not + toggle it — the lamp's click does. *@ + @* Lava-lamp button top-right, across from the back link. Toggles the knob band below the diff --git a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs index c26e04c..c9891c3 100644 --- a/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs +++ b/DeepDrftPublic.Client/Services/MixVisualizerControlState.cs @@ -1,17 +1,18 @@ namespace DeepDrftPublic.Client.Services; /// -/// Holds the Mix visualizer's seven continuous-control positions for the lifetime of the WASM app +/// Holds the Mix visualizer's eight 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 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 (lava reframe §7c). /// -/// 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. +/// One state object, eight properties — not eight sibling holders, and (deliberately) NO constructor +/// parameters: this is a plain scoped value holder, so widening it (the Phase 10 split of the single +/// density knob into fluid-amount + fluid-viscosity) 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 DefaultVisibleSeconds / +/// DEFAULT_VISIBLE_SECONDS pair does. /// /// /// is the decoupling seam between the controls bar and the visualizer bridge. @@ -23,8 +24,8 @@ namespace DeepDrftPublic.Client.Services; /// public sealed class MixVisualizerControlState { - // ── 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. + // ── The eight control defaults (Phase 10). Each mirrors a DEFAULT_* anchor in + // MixVisualizer.ts; keep the two in sync, as the default-sync discipline requires. // Feel-anchors only — Daniel tunes on screen; the ~20% gravity / ~100% heat pair is his stated // sweet spot (§4c). @@ -37,10 +38,10 @@ public sealed class MixVisualizerControlState /// /// 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. + /// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation; drives the live OKLab + /// three-colour gradient. 0.45 opens with a clearly-visible ~7 s colour cycle (Phase 10 §3.2). /// - public const double DefaultGradientRotationSpeed = 0.3; + public const double DefaultGradientRotationSpeed = 0.45; /// /// Default lava-gravity dial. Mirrors DEFAULT_LAVA_GRAVITY in MixVisualizer.ts. Normalized @@ -56,10 +57,18 @@ public sealed class MixVisualizerControlState public const double DefaultLavaHeat = 1.0; /// - /// 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. + /// Default fluid-amount dial. Mirrors DEFAULT_FLUID_AMOUNT in MixVisualizer.ts. The first + /// half of the Phase 10 "bubbles" split. Normalized [0,1]; 0 = few small blobs, 1 = many larger + /// blobs (more wax in the container — blob count + per-blob volume). /// - public const double DefaultBlobDensity = 0.4; + public const double DefaultFluidAmount = 0.4; + + /// + /// Default fluid-viscosity / cohesion dial. Mirrors DEFAULT_FLUID_VISCOSITY in + /// MixVisualizer.ts. The second half of the Phase 10 "bubbles" split. Normalized [0,1]; 1 = high + /// cohesion (crisp spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged). + /// + public const double DefaultFluidViscosity = 0.6; /// /// Default collision-strength dial. Mirrors DEFAULT_COLLISION_STRENGTH in MixVisualizer.ts. @@ -69,15 +78,16 @@ public sealed class MixVisualizerControlState /// /// 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. + /// Normalized [0,1], mapped onto the useful 10%–95% ribbon-extent band (Phase 10 §3.7); 0.5 opens + /// mid-band. Narrowing clears room for the lava. /// - public const double DefaultWaveformWidth = 0.6; + public const double DefaultWaveformWidth = 0.5; /// 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. + /// Gradient anchor-rotation rate, normalized [0,1]. Drives the live OKLab gradient. public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed; /// Downward force on the wax, normalized [0,1]. @@ -86,8 +96,12 @@ public sealed class MixVisualizerControlState /// 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; + /// Amount of wax (blob count + per-blob volume), normalized [0,1]. Phase 10 split, part 1. + public double FluidAmount { get; set; } = DefaultFluidAmount; + + /// Fluid viscosity / cohesion, normalized [0,1]. 1 = crisp spheres, 0 = gooey/deformed. + /// Phase 10 split, part 2. + public double FluidViscosity { get; set; } = DefaultFluidViscosity; /// Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw. public double CollisionStrength { get; set; } = DefaultCollisionStrength; diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index 68cc326..12b9851 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -32,9 +32,10 @@ * The Blazor component owns the canvas element and the inputs (datum, playback, * 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`. As of Wave R4 the handle exposes SEVEN dedicated control setters - * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity / - * setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. As of Wave R3 the + * returned by `create`. As of Phase 10 the handle exposes EIGHT dedicated control setters + * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount / + * setFluidViscosity / setCollisionStrength / setWaveformWidth) — the single density knob is split into + * fluid-amount + fluid-viscosity. As of Wave R3 the * gradient-rotation setter is LIVE: it drives the OKLab three-colour gradient's anchor rotation. * * PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and @@ -64,14 +65,15 @@ export const DEFAULT_VISIBLE_SECONDS = 10; // 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). // -// 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: +// Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The +// single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping: // • Scroll speed → visible time-span / scroll rate (setScrollSpeed) // • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE // as of Wave R3; drives the OKLab gradient's anchor rotation // • Lava gravity → gravity dial (setLavaGravity) // • Lava heat → heat dial (setLavaHeat) -// • Blob density/size → density dial (setBlobDensity) +// • Fluid amount → blob count + per-blob volume (setFluidAmount) +// • Fluid viscosity/cohesion → sphere-restoration: smin blend + wobble (setFluidViscosity) // • 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 @@ -89,15 +91,23 @@ export const DEFAULT_LAVA_HEAT = 1.0; * Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */ export const DEFAULT_COLLISION_STRENGTH = 0.5; -/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */ -export const DEFAULT_BLOB_DENSITY = 0.4; +/** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10 + * split): how much wax is in the container — blob count + per-blob volume. 0 = few small blobs, + * 1 = many larger blobs (more fluid). */ +export const DEFAULT_FLUID_AMOUNT = 0.4; + +/** Default FLUID VISCOSITY / COHESION. Mirrors C# DefaultFluidViscosity. The "bubbles" knob's second + * half (Phase 10 split): how strongly the wax holds a spherical shape. 1 = high cohesion (crisp + * spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged under inertia). + * Default leans cohesive so the at-rest look is rounded wax. */ +export const DEFAULT_FLUID_VISCOSITY = 0.6; /** * Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized * [0,1] → slow→fast anchor rotation. LIVE as of Wave R3: it drives Motion 1 (the rate at * which the gradient's two anchors A and B rotate among the three theme colours X/Y/Z). */ -export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3; +export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.45; /** * Anchor-rotation rate at dial = 1, in ring-units per second (one ring-unit = one anchor @@ -105,14 +115,18 @@ export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3; * ~16.7 s at full speed — slow and meditative at the high end, near-static at the low end. * Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead. */ -const GRADIENT_ROTATION_RATE_MAX = 0.18; -const GRADIENT_ROTATION_RATE_MIN = 0.01; +// Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full +// three-colour cycle took ~17 s at full dial and ~49 s at the 0.3 default — below the threshold of +// "this is moving". Raised so the dial has obvious effect: 0.6 → a full cycle in ~5 s at full speed, +// and the default (now 0.45) cycles in ~7 s — clearly rotating, still meditative not strobing. +const GRADIENT_ROTATION_RATE_MAX = 0.6; +const GRADIENT_ROTATION_RATE_MIN = 0.03; /** - * 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. + * Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful + * 10%–95% ribbon-extent band (Phase 10 §3.7 — see effectiveWaveformWidth); 0.5 opens mid-band. */ -export const DEFAULT_WAVEFORM_WIDTH = 0.6; +export const DEFAULT_WAVEFORM_WIDTH = 0.5; /** * Where the "now" line sits within the window, as a fraction from the top. @@ -249,19 +263,29 @@ const BLOB_RESTITUTION_SOFT = 0.05; // residual restitution at strength = 0 (al * the soft end → elastic reflection of the inward velocity at the hard end. The waveform * is read-only authority: it pushes the fluid, the fluid never moves it. */ -const WAVE_COLLIDE_SPRING = 12.0; // soft penalty stiffness pushing wax off the ribbon (softened, Daniel #3) -const WAVE_RESTITUTION_HARD = 1.1; // elastic reflection at full hardness — over-unity for the "throw" (Daniel #4/#6) +// Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax). The +// smoothed waveform (item 1) gives a gently-moving boundary, so the response can be springier without +// buzzing. Restitution is now SUB-unity: a real bounce conserves-or-loses energy, never adds it — +// over-unity (the old 1.1) injected energy each contact and read as "explosive". 0.85 at the hard end +// is lively/springy; the soft end stays near-zero (mush). +const WAVE_COLLIDE_SPRING = 10.0; // soft penalty stiffness pushing wax off the ribbon (slightly softer) +const WAVE_RESTITUTION_HARD = 0.85; // springy but energy-bounded reflection at full hardness (no explosion) const WAVE_RESTITUTION_SOFT = 0.05; // near-pure mush at the soft end (Daniel #3) /** - * Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax - * penetrates the ribbon, in addition to the outward (horizontal) surface-normal push we add - * an UPWARD (−y) impulse proportional to the penetration depth and the collision-strength - * dial. At low strength this is ~0 (the ribbon just mushes the wax around horizontally); at - * high strength a loud transient launches bubbles up and out — the lively "thrown" look. The - * coefficient is in height-units/s² per unit penetration, scaled by the strength dial. + * Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates + * the ribbon we add a small UPWARD (−y) nudge so loud transients lift bubbles toward the surface + * rather than only shoving them sideways. + * + * Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration × + * hardness × dt, accumulated on a sustained loud passage and launched bubbles off-screen — the + * "explosive" feel. Cut to a gentle lift and CAPPED per contact (see the clamp in stepPhysics) so a + * deep/sustained overlap can't pump unbounded upward speed. Reads as a bouncy bob, not a rocket. */ -const WAVE_THROW_UP = 26.0; +const WAVE_THROW_UP = 9.0; +/** Hard cap on the per-contact upward throw velocity (height-units/s) so a sustained loud transient + * can never accumulate into an off-screen launch. Well above a natural bob, far below escape speed. */ +const WAVE_THROW_UP_MAX = 0.6; /** * Max physics timestep, seconds. rAF can stall (tab blur, GC); a huge dt would let a @@ -497,8 +521,11 @@ export interface MixVisualizerHandle { 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]. Amount of wax — blob count + per-blob volume. */ + setFluidAmount(value: number): void; + /** [0,1]. Fluid viscosity / cohesion — how strongly wax restores to a sphere (1) vs stays + * deformed/gooey (0). Drives the metaball smin blend + wobble; no per-fragment cost change. */ + setFluidViscosity(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). */ @@ -523,6 +550,43 @@ function decodeSamples(base64: string): Uint8Array { return out; } +/** + * Envelope-follower smoothing time constant, seconds — mirrors C# + * RmsLoudnessAlgorithm.SmoothingTimeConstantSeconds. The ~50 ms target rounds the spikey + * per-sample loudness into a smooth ribbon contour (Phase 10 tuning). + */ +const SMOOTHING_TIME_CONSTANT_SECONDS = 0.05; + +/** + * Smooth the [0,255] loudness datum in place with a symmetric (zero-phase) one-pole envelope + * follower targeting SMOOTHING_TIME_CONSTANT_SECONDS. This runs at DECODE time so EXISTING stored + * mixes — whose vault profiles predate the C#-side preprocessing smoothing — read as a smooth + * curve with no data regeneration. New mixes are already smoothed at preprocessing; a second light + * pass over an already-smooth curve is near-idempotent, so applying it unconditionally here is safe. + * + * The coefficient a = exp(−secondsPerSample / τ): forward then backward pass cancels the single-pole + * lag (no time shift). Bytes stay [0,255]; we smooth in float and round back. A degenerate sample + * rate (≤0 or non-finite) leaves the data untouched. + */ +function smoothDatum(samples: Uint8Array, sampleCount: number, durationSeconds: number): void { + if (sampleCount < 2 || durationSeconds <= 0 || !Number.isFinite(durationSeconds)) return; + const secondsPerSample = durationSeconds / sampleCount; + const a = Math.exp(-secondsPerSample / SMOOTHING_TIME_CONSTANT_SECONDS); + + // Float working buffer over the real samples (tail padding, if any, is untouched). + const env = new Float32Array(sampleCount); + let acc = samples[0]; + for (let i = 0; i < sampleCount; i++) { + acc = a * acc + (1 - a) * samples[i]; + env[i] = acc; + } + acc = env[sampleCount - 1]; + for (let i = sampleCount - 1; i >= 0; i--) { + acc = a * acc + (1 - a) * env[i]; + samples[i] = Math.round(Math.min(255, Math.max(0, acc))); + } +} + // ── Shaders. ───────────────────────────────────────────────────────────────────── // // Vertex: trivial pass-through. We draw a single triangle that more than covers the @@ -589,6 +653,8 @@ uniform float uPlayheadSeconds; // current playback position (per-frame) uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph uniform float uVisibleSeconds; // zoom: window time-span (per change) uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room) +uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres, + // low = gooey/deformed (drives the smin blend width + wobble below) // NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms // in R2 — they drive the CPU physics step, which uploads the resulting uBlobs[]. The old // uBubblyness/uDetach/uColorShiftSpeed uniforms are gone from the shader for that reason; @@ -713,7 +779,12 @@ float sampleAt(float timeSeconds) { int i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1); int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1); float f = clamp(p - floor(p), 0.0, 1.0); - return mix(fetchSample(i0), fetchSample(i1), f); + // Smootherstep (C1-continuous Hermite) blend between the two bracketing samples instead of a + // straight linear lerp. Linear reconstruction connects samples with straight segments, so the + // ribbon edge reads as faceted polygons; the Hermite ease gives a smooth sinusoid-shaped contour + // between samples with zero slope at each sample point (Phase 10 tuning — smooth, not polygonal). + float fs = f * f * (3.0 - 2.0 * f); + return mix(fetchSample(i0), fetchSample(i1), fs); } // ════════════════════════════════════════════════════════════════════════════════════ @@ -807,10 +878,20 @@ vec3 oklabToLinear(vec3 lab) { -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s ); } -// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → lerp → back to gamma sRGB. +// Chroma (vibrancy) boost in OKLab (Phase 10 — Daniel: "colours too muted, more punch"). OKLab's L +// is lightness; (a,b) is the chroma vector. Scaling (a,b) about the neutral axis raises saturation +// while preserving hue (the a:b ratio) and lightness (L untouched), so the palette-sourced navy/moss/ +// off-white stay themselves — just more vivid. No hardcoded hexes: the anchors remain the live palette +// vars (spec §6a), this only amplifies their existing chroma. >1 = more punch. +const float CHROMA_BOOST = 1.45; +vec3 vivifyOklab(vec3 lab) { + return vec3(lab.x, lab.y * CHROMA_BOOST, lab.z * CHROMA_BOOST); +} +// Mix two GAMMA-sRGB colours perceptually: linearise → OKLab → boost chroma → lerp → back to gamma +// sRGB. The chroma boost gives the gradient punch (Phase 10) while OKLab keeps the blend faithful. vec3 mixOklab(vec3 a, vec3 b, float t) { - vec3 la = linearToOklab(srgbToLinear3(a)); - vec3 lb = linearToOklab(srgbToLinear3(b)); + vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a))); + vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b))); vec3 m = mix(la, lb, t); return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0); } @@ -863,6 +944,14 @@ float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight, float hotAccum = 0.0; float hotWeight = 0.0; + // Phase 10 cohesion (viscosity knob): low cohesion → a wider smin neck (blobs fuse and stay + // gooey/deformed) and more wobble (less sphere-like); high cohesion → a tight neck and minimal + // wobble (crisp spheres that read as "snapped back to round"). Pure uniform scaling of the two + // existing constants — no extra per-fragment loop iterations, so weaker hardware is unaffected. + // Range chosen so cohesion 1 still keeps a small organic neck/wobble (never a hard-edged circle). + float blobK = BLOB_SMOOTHMIN_K * (1.0 + (1.0 - uCohesion) * 1.4); // ×1.0 (crisp) → ×2.4 (gooey) + float wobbleAmt = BLOB_WOBBLE_AMOUNT * (0.35 + (1.0 - uCohesion) * 1.4); // less wobble when cohesive + // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set. for (int i = 0; i < MAX_BLOBS; i++) { if (i >= uBlobCount) break; @@ -873,12 +962,13 @@ float liquidSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight, // Organic radius wobble: a slow per-blob breathing (blob-tied + wall clock), so // the silhouette is never a clean circle. Fluid-tied, not screen-space (§3 ok). + // Amount scaled by cohesion (low cohesion deforms more — Phase 10 viscosity split). float wob = (valueNoise(vec2(float(i) * 1.37, uTimeSeconds * BLOB_WOBBLE_RATE)) - 0.5) - * 2.0 * BLOB_WOBBLE_AMOUNT; + * 2.0 * wobbleAmt; float rr = r * (1.0 + wob); float blob = sdCircle(p - c, rr); - field = smin(field, blob, BLOB_SMOOTHMIN_K); + field = smin(field, blob, blobK); // Weight this blob's temperature by proximity so the tint follows the nearest wax. float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0); @@ -1018,7 +1108,8 @@ function noopHandle(): MixVisualizerHandle { setGradientRotationSpeed() {}, setLavaGravity() {}, setLavaHeat() {}, - setBlobDensity() {}, + setFluidAmount() {}, + setFluidViscosity() {}, setCollisionStrength() {}, setWaveformWidth() {}, refreshTheme() {}, @@ -1078,6 +1169,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'), + cohesion: gl.getUniformLocation(program, 'uCohesion'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), colorNavy: gl.getUniformLocation(program, 'uColorNavy'), colorMoss: gl.getUniformLocation(program, 'uColorMoss'), @@ -1108,11 +1200,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { let lavaHeat = DEFAULT_LAVA_HEAT; let lavaGravity = DEFAULT_LAVA_GRAVITY; let collisionStrength = DEFAULT_COLLISION_STRENGTH; - let blobDensity = DEFAULT_BLOB_DENSITY; + // Phase 10 — the split "bubbles" knob: fluidAmount drives count + per-blob volume; fluidViscosity + // (cohesion) drives the shader's sphere-restoration (smin blend + wobble) via uCohesion. + let fluidAmount = DEFAULT_FLUID_AMOUNT; + let fluidViscosity = DEFAULT_FLUID_VISCOSITY; let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1). let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED; + /** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1] + * travel maps onto the useful 10%–95% band (full-width 100% read too wide; sub-10% vanished). + * Both the shader uniform and the CPU collision boundary read this so they stay aligned. */ + function effectiveWaveformWidth(): number { + return 0.10 + waveformWidth * 0.85; + } + // ── R3 gradient-rotation phase (Motion 1). Integrated from the SAME uTimeSeconds clock the // shader uses (NOT a new wall-clock — spec R3 guidance): each frame we advance the phase by // rate·dt, where dt is the delta of (performance.now()−startTimeMs)/1000 (== uTimeSeconds). @@ -1257,19 +1359,19 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } const rng = makeRng(0x1a2b3c4d); - /** The density dial's effect on blob SIZE (Daniel #1): density 0 → big lazy wax, density 1 → - * smaller wax. Applied LIVE each frame to the blob's unbiased base radius (r0 → r), so turning - * the dial resizes already-live blobs, not just how many spawn. One source so seed + per-frame - * agree. */ - function densitySizeBias(): number { - return 1 - blobDensity * 0.6; // density 0 → ×1.0 (big), density 1 → ×0.4 (smaller) + /** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE + * each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live + * blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean), + * amount 1 → ×1.15 (fat, lots of wax). */ + function fluidSizeBias(): number { + return 0.6 + fluidAmount * 0.55; } /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */ function seedBlob(b: Blob, aspect: number): void { // Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame. const r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN); - const r = r0 * densitySizeBias(); + const r = r0 * fluidSizeBias(); b.r0 = r0; b.r = r; b.er = r; // starts at full size (cool); shrinks as it heats @@ -1292,9 +1394,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } let blobsInitialized = false; - /** Live blob count for the current density dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */ + /** Live blob count for the current fluid-amount dial, within [MIN_BLOB_COUNT, MAX_BLOBS]. */ function liveBlobCount(): number { - return Math.round(MIN_BLOB_COUNT + blobDensity * (MAX_BLOBS - MIN_BLOB_COUNT)); + return Math.round(MIN_BLOB_COUNT + fluidAmount * (MAX_BLOBS - MIN_BLOB_COUNT)); } /** @@ -1312,7 +1414,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { const f = Math.min(Math.max(p - Math.floor(p), 0), 1); const s0 = d.samples[i0] / 255; const s1 = d.samples[i1] / 255; - return s0 + (s1 - s0) * f; + // Smootherstep (Hermite) blend — mirrors the shader's sampleAt so the CPU collision boundary + // follows the same smooth sinusoid contour the ribbon is drawn with (no faceted mismatch). + const fs = f * f * (3 - 2 * f); + return s0 + (s1 - s0) * fs; } /** The heat dial's transfer function: dial 0..1 → how hard the floor pumps heat in. @@ -1321,7 +1426,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { * toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */ function heatScaleFromDial(dial: number): number { const d = Math.min(Math.max(dial, 0), 1); - return d * d * (3 - 2 * d); // smoothstep: flat at 0, steep in the middle, flat at 1 + // Smoothstep toe (gentle at 0) scaled to reach 1.2 at dial 1 — Phase 10 §3.4: ~20% stronger + // at the high end so full heat roils harder. The low/mid feel is unchanged (the toe dominates + // there); only the top end gains the extra 20% drive into the floor-heating + buoyancy + turbulence. + return d * d * (3 - 2 * d) * 1.2; } /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive), @@ -1360,9 +1468,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } const count = liveBlobCount(); - const sizeBias = densitySizeBias(); // density dial → live size scale (Daniel #1, recomputed each step) + const sizeBias = fluidSizeBias(); // fluid-amount dial → live size scale (Phase 10, recomputed each step) const heatScale = heatScaleFromDial(lavaHeat); - const gravity = GRAVITY_ACCEL_MIN + lavaGravity * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN); + // Gravity range remap (Phase 10 §3.3): the knob's full [0,1] travel now covers only the useful + // 0%–75% of the old gravity span — the top quarter was too heavy (wax slammed down). So the dial + // is scaled to 0.75 before mapping onto [MIN, MAX], keeping the low/mid feel and dropping the slam. + const gravityDial = lavaGravity * 0.75; + const gravity = GRAVITY_ACCEL_MIN + gravityDial * (GRAVITY_ACCEL_MAX - GRAVITY_ACCEL_MIN); const collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD); const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD); const collideHardness = Math.min(Math.max(collisionStrength, 0), 1); @@ -1372,8 +1484,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { const secondsPerHeight = visibleSeconds; const centreX = aspect * 0.5; // Match the shader's width-dialled ribbon so the collision boundary lines up with what - // is drawn (R2 #8): a narrower waveform must also collide narrower. - const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * waveformWidth; + // is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped + // effective width as the uniform (Phase 10 §3.7) so the boundary never drifts from the ribbon. + const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * effectiveWaveformWidth(); const playhead = effectivePlayhead(); const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS; @@ -1399,9 +1512,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { b.temp += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt; b.temp = Math.min(Math.max(b.temp, 0), 1); - // Density → SIZE (Daniel #1): scale the blob's identity radius by the live density - // bias EACH STEP, so turning the density dial visibly resizes already-live wax (the - // "size" half is no longer baked at seed). r feeds the heat-shrink below and the + // Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid- + // amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the + // "size" half is not baked at seed). r feeds the heat-shrink below and the // collisions/upload via er, so the dial moves the actual drawn + simulated size. b.r = b.r0 * sizeBias; @@ -1493,15 +1606,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness; } - // UPWARD throw (Daniel #4): on top of the outward push, launch the bubble UP. The - // ribbon only ever drives wax up+out (−y), never down, so loud transients toss - // bubbles toward the surface. Scaled by penetration × hardness, so at low collision - // strength it's ~0 (just mushed around) and at high strength it "throws" them up. - b.vy -= WAVE_THROW_UP * penetration * dt * collideHardness; + // UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob + // bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the + // accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a + // sustained/deep overlap lifts firmly but never launches the bubble off-screen. + const throwUp = Math.min(WAVE_THROW_UP * penetration * dt * collideHardness, WAVE_THROW_UP_MAX); + b.vy -= throwUp; - // Positional push-out: partial at the soft end (wax squishes in then eases out via - // the spring — Daniel #3 mushy), firm at the hard end (no deep penetration allowed). - b.x += sideSign * penetration * (0.15 + 0.6 * collideHardness); + // Positional push-out: always eject the wax fully out of the ribbon along the normal so + // it can never lodge inside (Daniel "gets stuck"). The soft end eases it out gently + // (mushy), the hard end snaps it clean — but both clear the boundary, so no stuck wax. + b.x += sideSign * penetration * (0.5 + 0.5 * collideHardness); } // ── Blob ↔ blob (elastic 2D, soft↔hard via the strength dial — §5a). ── @@ -1679,7 +1794,8 @@ 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); - gl.uniform1f(u.waveformWidth, waveformWidth); + gl.uniform1f(u.waveformWidth, effectiveWaveformWidth()); + gl.uniform1f(u.cohesion, fluidViscosity); gl.uniform1f(u.gradientPhase, gradientPhase); gl.uniform3fv(u.colorNavy, theme.navy); gl.uniform3fv(u.colorMoss, theme.moss); @@ -1828,7 +1944,8 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } debugLog( `lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` + - `collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} density=${blobDensity.toFixed(2)} | ` + + `collision=${collisionStrength.toFixed(2)} width=${waveformWidth.toFixed(2)} ` + + `fluidAmount=${fluidAmount.toFixed(2)} viscosity=${fluidViscosity.toFixed(2)} | ` + `blobs=${live} buoyant=${buoyant} pooled=${pooled} ` + `avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`, ); @@ -1908,6 +2025,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { return null; } + // Smooth the loudness contour at decode time so EXISTING mixes (stored before the C#-side + // preprocessing smoothing) read as a smooth curve with no regeneration. Mutates `samples` in + // place — both the GPU texture (below) and the CPU collision mirror (datum.samples) read it. + smoothDatum(samples, sampleCount, durationSeconds); + // Width = min(N, a safe power-of-two cap). The power-of-two cap (4096) is well // under every real GL_MAX_TEXTURE_SIZE and keeps row arithmetic clean; we // still clamp it to the actual max in case a driver reports something smaller. @@ -2048,12 +2170,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (rafId === null) redrawOnce(); }, - // Blob density/size: drives BOTH halves live — count (liveBlobCount) AND size (densitySizeBias - // applied to every blob's radius each physics step, Daniel #1). Turning it visibly resizes the - // already-live wax, not just how many blobs there are. - setBlobDensity(value: number): void { - blobDensity = Math.min(1, Math.max(0, value)); - debugLog(`setBlobDensity → ${blobDensity.toFixed(3)}.`); + // Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount) + // AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it + // visibly adds/removes wax and resizes the already-live blobs. + setFluidAmount(value: number): void { + fluidAmount = Math.min(1, Math.max(0, value)); + debugLog(`setFluidAmount → ${fluidAmount.toFixed(3)}.`); + if (rafId === null) redrawOnce(); + }, + + // Fluid viscosity / cohesion (Phase 10 — second half of the split knob): drives the shader's + // uCohesion, which scales the metaball smin blend + wobble. High = crisp spheres that snap back; + // low = gooey/deformed wax. Uniform-only — no per-fragment cost change, weaker hardware unaffected. + setFluidViscosity(value: number): void { + fluidViscosity = Math.min(1, Math.max(0, value)); + debugLog(`setFluidViscosity → ${fluidViscosity.toFixed(3)}.`); if (rafId === null) redrawOnce(); }, diff --git a/DeepDrftTests/RmsLoudnessAlgorithmTests.cs b/DeepDrftTests/RmsLoudnessAlgorithmTests.cs index e88e87b..e761c44 100644 --- a/DeepDrftTests/RmsLoudnessAlgorithmTests.cs +++ b/DeepDrftTests/RmsLoudnessAlgorithmTests.cs @@ -36,9 +36,13 @@ public class RmsLoudnessAlgorithmTests var silentAverage = profile.Take(8).Average(); var loudAverage = profile.Skip(8).Average(); - Assert.That(silentAverage, Is.LessThan(0.01), "silent region should read near zero"); + // The ~50 ms envelope smoothing intentionally bleeds a little loud energy across the + // silence/loud boundary, so the silent-half average is no longer ~0 — it sits low but + // non-zero (the boundary bucket lifts). The contract that matters is preserved: the silent + // region reads LOW, the loud region reads near peak, and loud dwarfs silent by a wide margin. + Assert.That(silentAverage, Is.LessThan(0.1), "silent region should still read low (smoothing lifts only the boundary)"); Assert.That(loudAverage, Is.GreaterThan(0.9), "loud region should read near peak after normalization"); - Assert.That(loudAverage, Is.GreaterThan(silentAverage * 10), + Assert.That(loudAverage, Is.GreaterThan(silentAverage * 5), "loud region must be significantly higher than the silent region"); } @@ -90,6 +94,56 @@ public class RmsLoudnessAlgorithmTests Assert.That(profile.Max(), Is.GreaterThan(0.0), "mixed-channel signal must not read as silence"); } + [Test] + public void Compute_AlternatingLoudSilentFrames_SmoothsTheSpikeyContour() + { + // A signal that alternates full-scale and silent across many short buckets would, without + // smoothing, produce a sawtooth (high, ~0, high, ~0). The ~50 ms envelope smoothing must round + // that into a contour whose neighbouring buckets differ far less than the raw alternation would. + const int frames = 44100; // 1 second + var pcm = new byte[frames * 2]; + for (var i = 0; i < frames; i++) + { + // 100 Hz square: ~441 frames per half-cycle — alternating loud/silent blocks well above + // the per-bucket duration so an unsmoothed profile would alternate sharply bucket-to-bucket. + var loud = (i / 441) % 2 == 0; + WriteInt16(pcm, i * 2, loud ? short.MaxValue : (short)0); + } + + // 256 buckets over 1 s = ~3.9 ms/bucket, far finer than the 50 ms time constant → heavy smoothing. + var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 256); + + // Max bucket-to-bucket step in the interior should be small relative to the full [0,1] range — + // an unsmoothed alternation would show steps near 1.0 between adjacent buckets. + var maxStep = 0.0; + for (var i = 1; i < profile.Length; i++) + { + maxStep = Math.Max(maxStep, Math.Abs(profile[i] - profile[i - 1])); + } + + Assert.That(maxStep, Is.LessThan(0.5), + "the ~50 ms envelope smoothing must round the loud/silent alternation into a smooth contour"); + } + + [Test] + public void Compute_Smoothing_PreservesPeakNormalization() + { + // Smoothing runs before peak-normalization, so the loudest bucket must still land at exactly 1. + const int frames = 8192; + var pcm = new byte[frames * 2]; + for (var i = 0; i < frames; i++) + { + var amplitude = (short)(short.MaxValue * ((double)i / frames)); + WriteInt16(pcm, i * 2, amplitude); + } + + var profile = _algorithm.Compute(pcm, Channels, SampleRate, BitsPerSample, bucketCount: 64); + + Assert.That(profile, Is.All.InRange(0.0, 1.0)); + Assert.That(profile.Max(), Is.EqualTo(1.0).Within(1e-9), + "peak normalization must still put the loudest smoothed bucket at 1"); + } + private static void WriteInt16(byte[] buffer, int offset, short value) { buffer[offset] = (byte)(value & 0xFF);