feat: continuous vertical VU fill for LevelMeterFab, replacing 3-band tint
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user