147 lines
5.4 KiB
C#
147 lines
5.4 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 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();
|
|
}
|
|
}
|