From b9969640e55346d8d359b9d1bfddc1d1e3a6c601 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 8 Jun 2026 08:55:45 -0400 Subject: [PATCH] feat: continuous vertical VU fill for LevelMeterFab, replacing 3-band tint --- .../AudioPlayerBar/LevelMeterFab.razor | 34 ++++++++++- .../AudioPlayerBar/LevelMeterFab.razor.cs | 56 +++++++++---------- .../AudioPlayerBar/LevelMeterFab.razor.css | 3 +- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor index 1e88095..85eabce 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor @@ -1,8 +1,36 @@ @namespace DeepDrftPublic.Client.Controls.AudioPlayerBar diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs index b667eb4..8e413df 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs @@ -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) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css index c25497f..d9b4201 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css @@ -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; }