Files
deepdrft/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs
T

154 lines
5.5 KiB
C#

using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
// 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 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;
private string? _playerId;
private IStreamingPlayerService? _subscribedService;
private double _smoothedDb = SilenceFloorDb;
private double _fillPercent; // 0..100, the sole render state
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()
{
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
// OnParametersSet here. Subscribe to the multicast StateChanged side-channel
// so animation state stays correct independent of parent re-renders —
// notably when the bar minimizes while a track is already playing.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
});
private async Task UpdateAnimationState()
{
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
var shouldAnimate = PlayerService.IsPlaying;
if (shouldAnimate && !_isAnimating)
{
await StartAnimation();
}
else if (!shouldAnimate && _isAnimating)
{
await StopAnimation();
}
}
private async Task StartAnimation()
{
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = true;
_smoothedDb = SilenceFloorDb;
_fillPercent = 0;
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnLevelData);
}
private async Task StopAnimation()
{
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = false;
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
// Drop the column to empty so only the dim silhouette remains.
if (_fillPercent != 0)
{
_fillPercent = 0;
await InvokeAsync(StateHasChanged);
}
_smoothedDb = SilenceFloorDb;
}
private Task OnLevelData(double[] buckets)
{
if (buckets.Length == 0) return Task.CompletedTask;
// Peak reads more responsively than mean for a "hot signal" indicator.
var peak = 0.0;
foreach (var bucket in buckets)
{
if (bucket > peak) peak = bucket;
}
// Inverse of SpectrumAnalyzer's normalization: value = (clampedDb + 80) / 80.
var instantDb = peak * 80.0 - 80.0;
// Attack-fast / release-slow envelope so the column doesn't strobe at 30fps.
var coefficient = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
_smoothedDb += (instantDb - _smoothedDb) * coefficient;
// 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)
{
_fillPercent = next;
InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation();
}
}