feat(mix-visualizer): Phase 10 tuning — smooth waveform, bouncy collision, 8 knobs

Smooth the loudness contour (~50 ms envelope at preprocessing + decode-time, plus
smootherstep render reconstruction); retune wax↔waveform collision to bouncy/sub-unity
(no explosion/stuck/jitter); split the bubbles knob into fluid-amount + fluid-viscosity
(cohesion via uniform-only smin/wobble); retune scroll/gravity/heat/width ranges; make
the colour rotation visible and boost OKLab chroma; the controls bar now holds its
layout and hides only its knobs via a Visible parameter.
This commit is contained in:
daniel-c-harvey
2026-06-17 05:12:15 -04:00
parent ba1a1cd8ec
commit 4e34696719
9 changed files with 500 additions and 183 deletions
@@ -3,11 +3,20 @@ namespace DeepDrftContent.Processors;
/// <summary> /// <summary>
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned, /// 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 /// 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. /// equal time slices, takes the RMS of each slice, applies a ~50 ms envelope-follower smoothing
/// No external audio dependency — operates directly on the WAV data-chunk bytes. /// 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.
/// </summary> /// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{ {
/// <summary>
/// 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.
/// </summary>
public const double SmoothingTimeConstantSeconds = 0.05;
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount) public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{ {
if (bucketCount <= 0) if (bucketCount <= 0)
@@ -64,16 +73,28 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
counts[bucket]++; counts[bucket]++;
} }
var peak = 0.0;
for (var i = 0; i < bucketCount; i++) for (var i = 0; i < bucketCount; i++)
{ {
if (counts[i] > 0) if (counts[i] > 0)
{ {
result[i] = Math.Sqrt(sumSquares[i] / counts[i]); 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; return result;
} }
/// <summary>
/// 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 <c>a = exp(bucketSeconds / τ)</c>
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~50 ms time constant:
/// each bucket blends <c>(1 a)</c> of itself with <c>a</c> of the running envelope. A near-zero
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1]. /// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian. /// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
@@ -2,12 +2,17 @@
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState @inject MixVisualizerControlState ControlState
@* The Mix visualizer controls. SEVEN continuous RadialKnobs — scroll speed, gradient rotation speed, @* The Mix visualizer controls. EIGHT continuous RadialKnobs — scroll speed, gradient rotation speed,
lava gravity, lava heat, blob density, collision strength, waveform width — each its own dedicated lava gravity, lava heat, fluid amount, fluid viscosity, collision strength, waveform width — each its
control with a Material-icon caption. Visibility is controlled by Blazor, not CSS: the host page own dedicated control with a Material-icon caption. The single "bubbles" knob is split into
renders this component only while the lava-lamp toggle is on (@if-guarded), so when off it is not in fluid-amount + fluid-viscosity (Phase 10 §5).
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. 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 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 Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
@@ -21,72 +26,92 @@
<div class="mix-visualizer-controls-bar"> <div class="mix-visualizer-controls-bar">
<div class="mix-visualizer-control" role="group" aria-label="Waveform scroll speed"> @if (Visible)
<RadialKnob Value="@ControlState.ScrollSpeed" {
ValueChanged="@OnScrollSpeedChanged" <div class="mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
Min="0" Max="1" Step="0.001" <RadialKnob Value="@ControlState.ScrollSpeed"
Size="64" ValueChanged="@OnScrollSpeedChanged"
Color="Color.Primary" /> Min="0" Max="1" Step="0.001"
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" /> Size="64"
</div> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Color gradient rotation speed"> <div class="mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed" <RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged" ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Lava gravity"> <div class="mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity" <RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged" ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Lava heat"> <div class="mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat" <RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged" ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Blob density and size"> <div class="mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.BlobDensity" <RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnBlobDensityChanged" ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Collision strength"> <div class="mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.CollisionStrength" <RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnCollisionStrengthChanged" ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Waveform width"> <div class="mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.WaveformWidth" <RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnWaveformWidthChanged" ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001" Min="0" Max="1" Step="0.001"
Size="64" Size="64"
Color="Color.Primary" /> Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div> </div>
<div class="mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
}
</div> </div>
@code { @code {
/// <summary>
/// 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.
/// </summary>
[Parameter] public bool Visible { get; set; }
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and // 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 // 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. // to a visible time-span and routes the rest straight to the lava/colour dials.
@@ -115,9 +140,15 @@
ControlState.NotifyChanged(); 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(); ControlState.NotifyChanged();
} }
@@ -1,7 +1,13 @@
/* The seven-knob band. Blazor gates its presence (the host renders this component only while the /* The eight-knob band. Phase 10 §4: the host ALWAYS renders this component and the component @if-gates
lava-lamp is on), so this is purely a layout rule for the visible state — no collapse machinery, no the knobs on its Visible parameter. So the container is permanent and reserves its height whether or
transitions, no glass surface. A plain transparent horizontal flex row of the seven knobs that wraps not the knobs are present — content below the bar never pops on toggle. No collapse machinery, no
to a second line only if the band is genuinely too narrow. */ 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 { .mix-visualizer-controls-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -9,6 +15,7 @@
justify-content: center; justify-content: center;
gap: 0.85rem 1rem; gap: 0.85rem 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
min-height: 6rem;
} }
/* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so /* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so
@@ -202,28 +202,32 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ─────────────────────────── // ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary> /// <summary>
/// Push the seven control values to the module from the shared state. Used to seed on first render /// 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 (lava reframe Wave R4). Each value is its /// and to re-push when the controls bar signals a change. Each value is its own dedicated dial:
/// own dedicated dial now — no more R2 temp-remapping:
/// <list type="bullet"> /// <list type="bullet">
/// <item>scroll speed [0,1] is mapped to a visible time-span via <see cref="MixZoomMapping"/> and /// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// pushed through <c>setScrollSpeed</c> (higher speed → tighter window → faster scroll);</item> /// <see cref="MixZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (inert until Wave R3);</item> /// (higher speed → tighter window → faster scroll);</item>
/// <item>gravity / heat / blob density / collision strength → their dedicated lava-physics dials;</item> /// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (live OKLab anchor rotation);</item>
/// <item>gravity / heat / collision strength → their dedicated lava-physics dials;</item>
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform.</item> /// <item>waveform width → the ribbon-extent uniform.</item>
/// </list> /// </list>
/// </summary> /// </summary>
private async Task PushControlsAsync() private async Task PushControlsAsync()
{ {
if (_handle is null) return; if (_handle is null) return;
// Scroll speed is a normalized [0,1] axis; map it to the visible time-span the renderer scrolls // Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune —
// through. The log map keeps the even-to-the-hand feel the old zoom slider had. // the knob's full travel now covers the 60%100% zoom range, dropping the dead slow/wide end).
var visibleSeconds = MixZoomMapping.FractionToSeconds(ControlState.ScrollSpeed); var visibleSeconds = MixZoomMapping.ScrollKnobToSeconds(ControlState.ScrollSpeed);
await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds); await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed); await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity); await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat); 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("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth); await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
} }
@@ -15,6 +15,27 @@ public static class MixZoomMapping
/// <summary>Longest span (min zoom). Tunable.</summary> /// <summary>Longest span (min zoom). Tunable.</summary>
public const double MaxVisibleSeconds = 30.0; public const double MaxVisibleSeconds = 30.0;
/// <summary>
/// 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).
/// </summary>
public const double ScrollKnobZoomFloor = 0.60;
/// <summary>
/// Maps the scroll-speed knob [0,1] onto the useful zoom band [<see cref="ScrollKnobZoomFloor"/>, 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.
/// </summary>
public static double ScrollKnobToSeconds(double knob)
{
knob = Math.Clamp(knob, 0, 1);
var fraction = ScrollKnobZoomFloor + (1.0 - ScrollKnobZoomFloor) * knob;
return FractionToSeconds(fraction);
}
/// <summary>Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.</summary> /// <summary>Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.</summary>
public static double FractionToSeconds(double fraction) public static double FractionToSeconds(double fraction)
{ {
+7 -9
View File
@@ -53,15 +53,13 @@ else
ShowMeta="false" ShowMeta="false"
ShowShareRow="false"> ShowShareRow="false">
<TopContent> <TopContent>
@* The seven-knob band lives in its own full-width area below the back/lamp top row. @* The eight-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 Phase 10 §4: the control is ALWAYS rendered; the lava-lamp toggle feeds its Visible
on, so when off it is not in the DOM at all. No background, no animation, no reflow of parameter, and the control itself @if-gates the knobs while holding the container's
the row above. The band mutates the shared MixVisualizerControlState; the backdrop reserved height — so content below never pops on toggle. The band mutates the shared
bridge pushes the dials. A knob drag does not toggle it — the lamp's click does. *@ MixVisualizerControlState; the backdrop bridge pushes the dials. A knob drag does not
@if (_controlsExpanded) toggle it — the lamp's click does. *@
{ <MixVisualizerControls Visible="@_controlsExpanded" />
<MixVisualizerControls />
}
</TopContent> </TopContent>
<TopRightAction> <TopRightAction>
@* Lava-lamp button top-right, across from the back link. Toggles the knob band below the @* Lava-lamp button top-right, across from the back link. Toggles the knob band below the
@@ -1,17 +1,18 @@
namespace DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.Services;
/// <summary> /// <summary>
/// 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 /// 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 /// 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 /// 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). /// load" without any cookie/localStorage round-trip (lava reframe §7c).
/// ///
/// One state object, seven properties — not seven sibling holders, and (deliberately) NO constructor /// One state object, eight properties — not eight sibling holders, and (deliberately) NO constructor
/// parameters: this is a plain scoped value holder, so widening it from four to seven properties adds /// parameters: this is a plain scoped value holder, so widening it (the Phase 10 split of the single
/// fields + defaults only and never forces a consumer constructor to grow. Each C#-side default mirrors /// density knob into fluid-amount + fluid-viscosity) adds fields + defaults only and never forces a
/// a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as the existing /// consumer constructor to grow. Each C#-side default mirrors a TS-side tuning anchor in
/// <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does. /// MixVisualizer.ts; keep the two in sync, as the <c>DefaultVisibleSeconds</c> /
/// <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
/// ///
/// <para> /// <para>
/// <see cref="Changed"/> is the decoupling seam between the controls bar and the visualizer bridge. /// <see cref="Changed"/> is the decoupling seam between the controls bar and the visualizer bridge.
@@ -23,8 +24,8 @@ namespace DeepDrftPublic.Client.Services;
/// </summary> /// </summary>
public sealed class MixVisualizerControlState public sealed class MixVisualizerControlState
{ {
// ── The seven control defaults (lava reframe §7a). Each mirrors a DEFAULT_* anchor in // ── The eight control defaults (Phase 10). Each mirrors a DEFAULT_* anchor in
// MixVisualizer.ts; keep the two in sync, as the existing default-sync discipline requires. // 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 // Feel-anchors only — Daniel tunes on screen; the ~20% gravity / ~100% heat pair is his stated
// sweet spot (§4c). // sweet spot (§4c).
@@ -37,10 +38,10 @@ public sealed class MixVisualizerControlState
/// <summary> /// <summary>
/// Default gradient-rotation-speed dial. Mirrors <c>DEFAULT_GRADIENT_ROTATION_SPEED</c> in /// Default gradient-rotation-speed dial. Mirrors <c>DEFAULT_GRADIENT_ROTATION_SPEED</c> in
/// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation. INERT until Wave R3 builds the /// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation; drives the live OKLab
/// OKLab three-colour gradient that consumes it. /// three-colour gradient. 0.45 opens with a clearly-visible ~7 s colour cycle (Phase 10 §3.2).
/// </summary> /// </summary>
public const double DefaultGradientRotationSpeed = 0.3; public const double DefaultGradientRotationSpeed = 0.45;
/// <summary> /// <summary>
/// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in MixVisualizer.ts. Normalized /// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in MixVisualizer.ts. Normalized
@@ -56,10 +57,18 @@ public sealed class MixVisualizerControlState
public const double DefaultLavaHeat = 1.0; public const double DefaultLavaHeat = 1.0;
/// <summary> /// <summary>
/// Default blob-density dial. Mirrors <c>DEFAULT_BLOB_DENSITY</c> in MixVisualizer.ts. Normalized /// Default fluid-amount dial. Mirrors <c>DEFAULT_FLUID_AMOUNT</c> in MixVisualizer.ts. The first
/// [0,1]; 0 = a few large lazy blobs, 1 = many smaller active blobs. /// 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).
/// </summary> /// </summary>
public const double DefaultBlobDensity = 0.4; public const double DefaultFluidAmount = 0.4;
/// <summary>
/// Default fluid-viscosity / cohesion dial. Mirrors <c>DEFAULT_FLUID_VISCOSITY</c> 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).
/// </summary>
public const double DefaultFluidViscosity = 0.6;
/// <summary> /// <summary>
/// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in MixVisualizer.ts. /// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in MixVisualizer.ts.
@@ -69,15 +78,16 @@ public sealed class MixVisualizerControlState
/// <summary> /// <summary>
/// Default waveform-width dial. Mirrors <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts. /// Default waveform-width dial. Mirrors <c>DEFAULT_WAVEFORM_WIDTH</c> 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.
/// </summary> /// </summary>
public const double DefaultWaveformWidth = 0.6; public const double DefaultWaveformWidth = 0.5;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible /// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="MixZoomMapping"/>; the standalone resolution/zoom control is gone.</summary> /// time-span via <see cref="MixZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed; public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
/// <summary>Gradient anchor-rotation rate, normalized [0,1]. Inert until Wave R3 consumes it.</summary> /// <summary>Gradient anchor-rotation rate, normalized [0,1]. Drives the live OKLab gradient.</summary>
public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed; public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed;
/// <summary>Downward force on the wax, normalized [0,1].</summary> /// <summary>Downward force on the wax, normalized [0,1].</summary>
@@ -86,8 +96,12 @@ public sealed class MixVisualizerControlState
/// <summary>Energy into the lava system, normalized [0,1]. 0 = rest-at-bottom, 1 = roiling.</summary> /// <summary>Energy into the lava system, normalized [0,1]. 0 = rest-at-bottom, 1 = roiling.</summary>
public double LavaHeat { get; set; } = DefaultLavaHeat; public double LavaHeat { get; set; } = DefaultLavaHeat;
/// <summary>Amount of wax (blob count/size), normalized [0,1].</summary> /// <summary>Amount of wax (blob count + per-blob volume), normalized [0,1]. Phase 10 split, part 1.</summary>
public double BlobDensity { get; set; } = DefaultBlobDensity; public double FluidAmount { get; set; } = DefaultFluidAmount;
/// <summary>Fluid viscosity / cohesion, normalized [0,1]. 1 = crisp spheres, 0 = gooey/deformed.
/// Phase 10 split, part 2.</summary>
public double FluidViscosity { get; set; } = DefaultFluidViscosity;
/// <summary>Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw.</summary> /// <summary>Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw.</summary>
public double CollisionStrength { get; set; } = DefaultCollisionStrength; public double CollisionStrength { get; set; } = DefaultCollisionStrength;
@@ -32,9 +32,10 @@
* The Blazor component owns the canvas element and the inputs (datum, playback, * The Blazor component owns the canvas element and the inputs (datum, playback,
* scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop, * scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop,
* the physics step, and all the GL math. The component drives it through the handle * 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 * returned by `create`. As of Phase 10 the handle exposes EIGHT dedicated control setters
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity / * (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setFluidAmount /
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. As of Wave R3 the * 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. * 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 * 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 // 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). // reaches setScrollSpeed; it arrives here already in seconds).
// //
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the // Phase 10 — the EIGHT dedicated controls. Each knob drives its own physics/colour dial. The
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping: // single "bubbles"/density knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). Mapping:
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed) // • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE // • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — LIVE
// as of Wave R3; drives the OKLab gradient's anchor rotation // as of Wave R3; drives the OKLab gradient's anchor rotation
// • Lava gravity → gravity dial (setLavaGravity) // • Lava gravity → gravity dial (setLavaGravity)
// • Lava heat → heat dial (setLavaHeat) // • 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) // • Collision strength → collision hardness dial (setCollisionStrength)
// • Waveform width → ribbon half-width uniform (setWaveformWidth) // • Waveform width → ribbon half-width uniform (setWaveformWidth)
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he // 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. */ * Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
export const DEFAULT_COLLISION_STRENGTH = 0.5; export const DEFAULT_COLLISION_STRENGTH = 0.5;
/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */ /** Default FLUID AMOUNT. Mirrors C# DefaultFluidAmount. The "bubbles" knob's first half (Phase 10
export const DEFAULT_BLOB_DENSITY = 0.4; * 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 * 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 * [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). * 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 * 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. * ~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. * Daniel tunes the feel here; dial 0 still creeps (RATE_MIN) so the field never freezes dead.
*/ */
const GRADIENT_ROTATION_RATE_MAX = 0.18; // Phase 10 colour retune (Daniel: "the rotation appears to do nothing"). The old 0.18 max → a full
const GRADIENT_ROTATION_RATE_MIN = 0.01; // 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 * Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. The knob maps onto the useful
* values narrow the waveform band so the lava fluid gets more room to move on loud songs. * 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. * 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 * 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. * 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) // Phase 10 collision retune (Daniel: "less explosive, more bouncy", no jitter, no stuck wax). The
const WAVE_RESTITUTION_HARD = 1.1; // elastic reflection at full hardness — over-unity for the "throw" (Daniel #4/#6) // 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) 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 * Waveform UPWARD throw (Daniel #4 — "throw bubbles up AND out, not just out"). When wax penetrates
* penetrates the ribbon, in addition to the outward (horizontal) surface-normal push we add * the ribbon we add a small UPWARD (y) nudge so loud transients lift bubbles toward the surface
* an UPWARD (y) impulse proportional to the penetration depth and the collision-strength * rather than only shoving them sideways.
* 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 * Phase 10 retune (Daniel: "less explosive"): the old 26.0, applied every substep × penetration ×
* coefficient is in height-units/s² per unit penetration, scaled by the strength dial. * 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 * 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; setLavaGravity(value: number): void;
/** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */ /** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
setLavaHeat(value: number): void; setLavaHeat(value: number): void;
/** [0,1]. Amount of wax — blob count/size. */ /** [0,1]. Amount of wax — blob count + per-blob volume. */
setBlobDensity(value: number): void; 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). */ /** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
setCollisionStrength(value: number): void; setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */ /** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
@@ -523,6 +550,43 @@ function decodeSamples(base64: string): Uint8Array {
return out; 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. ───────────────────────────────────────────────────────────────────── // ── Shaders. ─────────────────────────────────────────────────────────────────────
// //
// Vertex: trivial pass-through. We draw a single triangle that more than covers the // 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 uTimeSeconds; // monotonic clock (per-frame) — drives field morph
uniform float uVisibleSeconds; // zoom: window time-span (per change) 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 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 // 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 // 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; // 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 i0 = clamp(int(floor(p)), 0, uDatumSampleCount - 1);
int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1); int i1 = clamp(int(floor(p)) + 1, 0, uDatumSampleCount - 1);
float f = clamp(p - floor(p), 0.0, 1.0); 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 -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 mixOklab(vec3 a, vec3 b, float t) {
vec3 la = linearToOklab(srgbToLinear3(a)); vec3 la = vivifyOklab(linearToOklab(srgbToLinear3(a)));
vec3 lb = linearToOklab(srgbToLinear3(b)); vec3 lb = vivifyOklab(linearToOklab(srgbToLinear3(b)));
vec3 m = mix(la, lb, t); vec3 m = mix(la, lb, t);
return clamp(linearToSrgb3(oklabToLinear(m)), 0.0, 1.0); 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 hotAccum = 0.0;
float hotWeight = 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. // Union every live wax blob. Bounded loop to MAX_BLOBS; uBlobCount gates the live set.
for (int i = 0; i < MAX_BLOBS; i++) { for (int i = 0; i < MAX_BLOBS; i++) {
if (i >= uBlobCount) break; 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 // 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). // 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) 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 rr = r * (1.0 + wob);
float blob = sdCircle(p - c, rr); 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. // 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); float prox = clamp(1.0 - (blob / max(rr, 1e-3)), 0.0, 1.0);
@@ -1018,7 +1108,8 @@ function noopHandle(): MixVisualizerHandle {
setGradientRotationSpeed() {}, setGradientRotationSpeed() {},
setLavaGravity() {}, setLavaGravity() {},
setLavaHeat() {}, setLavaHeat() {},
setBlobDensity() {}, setFluidAmount() {},
setFluidViscosity() {},
setCollisionStrength() {}, setCollisionStrength() {},
setWaveformWidth() {}, setWaveformWidth() {},
refreshTheme() {}, refreshTheme() {},
@@ -1078,6 +1169,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'), timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'), visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'), waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
cohesion: gl.getUniformLocation(program, 'uCohesion'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'), durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorNavy: gl.getUniformLocation(program, 'uColorNavy'), colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
colorMoss: gl.getUniformLocation(program, 'uColorMoss'), colorMoss: gl.getUniformLocation(program, 'uColorMoss'),
@@ -1108,11 +1200,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let lavaHeat = DEFAULT_LAVA_HEAT; let lavaHeat = DEFAULT_LAVA_HEAT;
let lavaGravity = DEFAULT_LAVA_GRAVITY; let lavaGravity = DEFAULT_LAVA_GRAVITY;
let collisionStrength = DEFAULT_COLLISION_STRENGTH; 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; let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1). // LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED; 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 // ── 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 // 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). // 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); const rng = makeRng(0x1a2b3c4d);
/** The density dial's effect on blob SIZE (Daniel #1): density 0 → big lazy wax, density 1 → /** The fluid-amount dial's effect on blob SIZE (Phase 10): more fluid → larger wax. Applied LIVE
* smaller wax. Applied LIVE each frame to the blob's unbiased base radius (r0 → r), so turning * each frame to the blob's unbiased base radius (r0 → r), so turning the dial resizes already-live
* the dial resizes already-live blobs, not just how many spawn. One source so seed + per-frame * blobs, not just how many spawn. One source so seed + per-frame agree. amount 0 → ×0.6 (lean),
* agree. */ * amount 1 → ×1.15 (fat, lots of wax). */
function densitySizeBias(): number { function fluidSizeBias(): number {
return 1 - blobDensity * 0.6; // density 0 → ×1.0 (big), density 1 → ×0.4 (smaller) return 0.6 + fluidAmount * 0.55;
} }
/** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */ /** Construct (or re-seed) one blob at a random spot near the floor, ready to be heated. */
function seedBlob(b: Blob, aspect: number): void { function seedBlob(b: Blob, aspect: number): void {
// Pick the blob's UNBIASED identity radius once; the density dial scales it live each frame. // 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 r0 = BLOB_RADIUS_MIN + rng() * (BLOB_RADIUS_MAX - BLOB_RADIUS_MIN);
const r = r0 * densitySizeBias(); const r = r0 * fluidSizeBias();
b.r0 = r0; b.r0 = r0;
b.r = r; b.r = r;
b.er = r; // starts at full size (cool); shrinks as it heats 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; 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 { 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 f = Math.min(Math.max(p - Math.floor(p), 0), 1);
const s0 = d.samples[i0] / 255; const s0 = d.samples[i0] / 255;
const s1 = d.samples[i1] / 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. /** 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. */ * toe) keeps the low end gentle so small dial moves near 0 don't suddenly erupt. */
function heatScaleFromDial(dial: number): number { function heatScaleFromDial(dial: number): number {
const d = Math.min(Math.max(dial, 0), 1); 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), /** The collision-strength transfer: dial 0 = soft (penalty-spring, absorptive),
@@ -1360,9 +1468,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
} }
const count = liveBlobCount(); 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 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 collideRest = restitution(BLOB_RESTITUTION_SOFT, BLOB_RESTITUTION_HARD);
const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD); const waveRest = restitution(WAVE_RESTITUTION_SOFT, WAVE_RESTITUTION_HARD);
const collideHardness = Math.min(Math.max(collisionStrength, 0), 1); const collideHardness = Math.min(Math.max(collisionStrength, 0), 1);
@@ -1372,8 +1484,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
const secondsPerHeight = visibleSeconds; const secondsPerHeight = visibleSeconds;
const centreX = aspect * 0.5; const centreX = aspect * 0.5;
// Match the shader's width-dialled ribbon so the collision boundary lines up with what // 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. // is drawn (R2 #8): a narrower waveform must also collide narrower. Uses the SAME remapped
const maxHalf = (aspect * 0.5) * RIBBON_HALF_WIDTH_FRAC * waveformWidth; // 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 playhead = effectivePlayhead();
const dt = Math.min(dtTotal, PHYSICS_MAX_DT) / PHYSICS_SUBSTEPS; 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 += (TEMP_AMBIENT - b.temp) * HEAT_AMBIENT_RATE * dt;
b.temp = Math.min(Math.max(b.temp, 0), 1); b.temp = Math.min(Math.max(b.temp, 0), 1);
// Density → SIZE (Daniel #1): scale the blob's identity radius by the live density // Fluid amount → SIZE (Phase 10): scale the blob's identity radius by the live fluid-
// bias EACH STEP, so turning the density dial visibly resizes already-live wax (the // amount bias EACH STEP, so turning the dial visibly resizes already-live wax (the
// "size" half is no longer baked at seed). r feeds the heat-shrink below and 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. // collisions/upload via er, so the dial moves the actual drawn + simulated size.
b.r = b.r0 * sizeBias; b.r = b.r0 * sizeBias;
@@ -1493,15 +1606,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness; b.vx += sideSign * inwardSpeed * (1 + waveRest) * collideHardness;
} }
// UPWARD throw (Daniel #4): on top of the outward push, launch the bubble UP. The // UPWARD throw (Daniel #4): a gentle upward lift on contact so loud transients bob
// ribbon only ever drives wax up+out (y), never down, so loud transients toss // bubbles toward the surface. CAPPED per contact (Phase 10 — "less explosive"): the
// bubbles toward the surface. Scaled by penetration × hardness, so at low collision // accumulated upward velocity from this contact can't exceed WAVE_THROW_UP_MAX, so a
// strength it's ~0 (just mushed around) and at high strength it "throws" them up. // sustained/deep overlap lifts firmly but never launches the bubble off-screen.
b.vy -= WAVE_THROW_UP * penetration * dt * collideHardness; 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 // Positional push-out: always eject the wax fully out of the ribbon along the normal so
// the spring — Daniel #3 mushy), firm at the hard end (no deep penetration allowed). // it can never lodge inside (Daniel "gets stuck"). The soft end eases it out gently
b.x += sideSign * penetration * (0.15 + 0.6 * collideHardness); // (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). ── // ── 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 // Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
// separate dirty-tracking needed for scalars/vec3s). // separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds); 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.uniform1f(u.gradientPhase, gradientPhase);
gl.uniform3fv(u.colorNavy, theme.navy); gl.uniform3fv(u.colorNavy, theme.navy);
gl.uniform3fv(u.colorMoss, theme.moss); gl.uniform3fv(u.colorMoss, theme.moss);
@@ -1828,7 +1944,8 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
} }
debugLog( debugLog(
`lava — heat=${lavaHeat.toFixed(2)} gravity=${lavaGravity.toFixed(2)} ` + `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} ` + `blobs=${live} buoyant=${buoyant} pooled=${pooled} ` +
`avgTemp=${(avgTemp / Math.max(live, 1)).toFixed(2)} avgSize=${(avgShrink / Math.max(live, 1)).toFixed(2)}.`, `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; 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 // 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 // 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. // 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(); if (rafId === null) redrawOnce();
}, },
// Blob density/size: drives BOTH halves live — count (liveBlobCount) AND size (densitySizeBias // Fluid amount (Phase 10 — first half of the split density knob): drives count (liveBlobCount)
// applied to every blob's radius each physics step, Daniel #1). Turning it visibly resizes the // AND per-blob size (fluidSizeBias applied to every blob's radius each physics step). Turning it
// already-live wax, not just how many blobs there are. // visibly adds/removes wax and resizes the already-live blobs.
setBlobDensity(value: number): void { setFluidAmount(value: number): void {
blobDensity = Math.min(1, Math.max(0, value)); fluidAmount = Math.min(1, Math.max(0, value));
debugLog(`setBlobDensity${blobDensity.toFixed(3)}.`); 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(); if (rafId === null) redrawOnce();
}, },
+56 -2
View File
@@ -36,9 +36,13 @@ public class RmsLoudnessAlgorithmTests
var silentAverage = profile.Take(8).Average(); var silentAverage = profile.Take(8).Average();
var loudAverage = profile.Skip(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(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"); "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"); 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) private static void WriteInt16(byte[] buffer, int offset, short value)
{ {
buffer[offset] = (byte)(value & 0xFF); buffer[offset] = (byte)(value & 0xFF);