Merge branch 'level-meter-fill' into dev

This commit is contained in:
daniel-c-harvey
2026-06-08 13:31:58 -04:00
3 changed files with 59 additions and 34 deletions
@@ -1,8 +1,36 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<button class="lmf-fab-btn" type="button" @onclick="OnClick">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true"
style="@_svgStyle">
<path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
<defs>
<!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
<linearGradient id="lmf-grad-@(IdSuffix)"
gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
<stop offset="0%" stop-color="#2ECC71" />
<stop offset="55%" stop-color="#2ECC71" />
<stop offset="62%" stop-color="#F4C430" />
<stop offset="82%" stop-color="#F4C430" />
<stop offset="88%" stop-color="#FF6B35" />
<stop offset="100%" stop-color="#FF6B35" />
</linearGradient>
<!-- The note silhouette, used to clip the fill rect. -->
<clipPath id="lmf-clip-@(IdSuffix)">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</clipPath>
</defs>
<!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
fill="rgba(255,255,255,0.25)" />
<!-- Clipped fill: a full-width rect revealed through the note shape. -->
<g clip-path="url(#lmf-clip-@(IdSuffix))">
<rect class="lmf-fill-rect"
x="0" width="24"
y="@FillY" height="@FillH"
fill="url(#lmf-grad-@(IdSuffix))" />
</g>
</svg>
</button>
@@ -11,14 +11,16 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
[Parameter] public EventCallback OnClick { get; set; }
// Level-data reduction tuning (see PLAN-level-meter-fab.md §2/§4). The three
// band boundaries below are the spec contract; the attack/release coefficients
// and silence floor are by-ear tuning values.
private const double AttackCoefficient = 0.6; // fast rise toward a louder reading
private const double ReleaseCoefficient = 0.15; // slow decay so the tint doesn't strobe
// Calibration window (PLAN-level-meter-fill.md §2). These four constants are the
// spec contract: floor/ceiling define the dB window, and the linear map places
// -12 dB at 60% fill (green/yellow) and -4.5 dB at 85% (yellow/orange) to match
// the SVG gradient stops. The attack/release coefficients are by-ear tuning values.
private const double FloorDb = -30.0; // fill = 0%
private const double CeilingDb = 0.0; // fill = 100%
private const double SilenceFloorDb = -80.0; // matches the analyzer's normalization window
private const double GreenCeilingDb = -18.0; // ≤ this → green
private const double YellowCeilingDb = -6.0; // ≤ this → yellow; above → orange
private const double AttackCoefficient = 0.5; // fast rise toward a louder reading
private const double ReleaseCoefficient = 0.12; // slow decay so the column doesn't strobe
private readonly string _instanceId = Guid.NewGuid().ToString();
private bool _isAnimating;
@@ -26,15 +28,13 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
private IStreamingPlayerService? _subscribedService;
private double _smoothedDb = SilenceFloorDb;
private string _bandClass = string.Empty;
private double _fillPercent; // 0..100, the sole render state
private string _svgStyle => _bandClass switch
{
"lmf-green" => "color: #2ECC71; filter: drop-shadow(0 0 6px rgba(46, 204, 113, 0.45));",
"lmf-yellow" => "color: #F4C430; filter: drop-shadow(0 0 6px rgba(244, 196, 48, 0.45));",
"lmf-orange" => "color: #FF6B35; filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.45));",
_ => ""
};
private string IdSuffix => _instanceId.Replace("-", "");
private double FillHeight => 24.0 * (_fillPercent / 100.0);
private string FillY => (24.0 - FillHeight).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
private string FillH => FillHeight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
protected override async Task OnParametersSetAsync()
{
@@ -90,6 +90,7 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
_isAnimating = true;
_smoothedDb = SilenceFloorDb;
_fillPercent = 0;
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnLevelData);
}
@@ -100,12 +101,13 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
_isAnimating = false;
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
// Revert to idle untinted; CSS eases the color back over 120ms.
if (_bandClass.Length > 0)
// Drop the column to empty so only the dim silhouette remains.
if (_fillPercent != 0)
{
_bandClass = string.Empty;
_fillPercent = 0;
await InvokeAsync(StateHasChanged);
}
_smoothedDb = SilenceFloorDb;
}
private Task OnLevelData(double[] buckets)
@@ -122,27 +124,23 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
// Inverse of SpectrumAnalyzer's normalization: value = (clampedDb + 80) / 80.
var instantDb = peak * 80.0 - 80.0;
// Attack-fast / release-slow envelope so the tint doesn't strobe at 30fps.
// Attack-fast / release-slow envelope so the column doesn't strobe at 30fps.
var coefficient = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
_smoothedDb += (instantDb - _smoothedDb) * coefficient;
var band = BandFor(_smoothedDb);
if (band != _bandClass)
// Linear map of smoothed dB onto a 0-100 fill across the [floor, ceiling] window.
var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);
// Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
if (Math.Abs(next - _fillPercent) >= 0.5)
{
_bandClass = band;
_fillPercent = next;
InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
private static string BandFor(double db) => db switch
{
<= GreenCeilingDb => "lmf-green",
<= YellowCeilingDb => "lmf-yellow",
_ => "lmf-orange"
};
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
@@ -28,9 +28,8 @@
outline-offset: 3px;
}
/* Transition fires when _svgStyle changes between band states */
/* Fill motion is driven by C#-computed SVG geometry, not CSS — no transition here. */
.lmf-icon {
width: 24px;
height: 24px;
transition: color 120ms ease-out, filter 120ms ease-out;
}