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 for true RMS dBFS (AnalyserNode.getFloatTimeDomainData). // Floor/ceiling define the dB window; the linear map across this 57 dB range places // a steady dance track (~-14 dBFS) at ~81% fill (upper yellow) and a hot drop // (~-6 dBFS) at ~95% (deep orange). Attack/release coefficients are by-ear tuning values. private const double FloorDb = -60.0; // fill = 0%; below this is near-silence (true RMS dBFS) private const double CeilingDb = -3.0; // fill = 100%; hot peaks on commercial masters (true RMS dBFS) 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.StartLevelAnimationAsync(_playerId, _instanceId, OnLevelData); } private async Task StopAnimation() { if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return; _isAnimating = false; await AudioInterop.StopLevelAnimationAsync(_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 db) { // db is true RMS dBFS from getFloatTimeDomainData; -Infinity on silence. var instantDb = double.IsNegativeInfinity(db) || double.IsNaN(db) ? SilenceFloorDb : Math.Max(db, SilenceFloorDb); // Attack-fast / release-slow envelope so the column doesn't strobe at 30fps. var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient; _smoothedDb += (instantDb - _smoothedDb) * coeff; // 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(); } }