feat: continuous vertical VU fill for LevelMeterFab, replacing 3-band tint

This commit is contained in:
daniel-c-harvey
2026-06-08 08:55:45 -04:00
parent 5b50879476
commit b9969640e5
3 changed files with 59 additions and 34 deletions
@@ -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)