feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData
This commit is contained in:
@@ -11,12 +11,12 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
|
||||
|
||||
[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 = -70.0; // fill = 0%; lowered to match FFT peak values (AnalyserNode.getFloatFrequencyData returns peaks, not RMS — typical loud dance tracks cluster ~-40 to -60 dB)
|
||||
private const double CeilingDb = -10.0; // fill = 100%; lowered to match FFT peak ceiling (loud dance FFT peaks land around -10 dB, far below 0 dB RMS full-scale)
|
||||
// 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
|
||||
@@ -91,7 +91,7 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
|
||||
_isAnimating = true;
|
||||
_smoothedDb = SilenceFloorDb;
|
||||
_fillPercent = 0;
|
||||
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnLevelData);
|
||||
await AudioInterop.StartLevelAnimationAsync(_playerId, _instanceId, OnLevelData);
|
||||
}
|
||||
|
||||
private async Task StopAnimation()
|
||||
@@ -99,7 +99,7 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
|
||||
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = false;
|
||||
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
|
||||
await AudioInterop.StopLevelAnimationAsync(_playerId, _instanceId);
|
||||
|
||||
// Drop the column to empty so only the dim silhouette remains.
|
||||
if (_fillPercent != 0)
|
||||
@@ -110,23 +110,16 @@ public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
|
||||
_smoothedDb = SilenceFloorDb;
|
||||
}
|
||||
|
||||
private Task OnLevelData(double[] buckets)
|
||||
private Task OnLevelData(double db)
|
||||
{
|
||||
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;
|
||||
// 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 coefficient = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
|
||||
_smoothedDb += (instantDb - _smoothedDb) * coefficient;
|
||||
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);
|
||||
|
||||
@@ -243,6 +243,35 @@ public class AudioInteropService : IAsyncDisposable
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartLevelAnimationAsync(string playerId, string callbackId, Func<double, Task> callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new LevelCallback { OnData = callback };
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + "_level_" + callbackId] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
|
||||
"DeepDrftAudio.startLevelAnimation",
|
||||
playerId, callbackId, dotNetObjectRef, "OnLevelDataCallback");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopLevelAnimationAsync(string playerId, string callbackId)
|
||||
{
|
||||
var key = playerId + "_level_" + callbackId;
|
||||
if (_callbacks.TryGetValue(key, out var callback))
|
||||
{
|
||||
callback?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopLevelAnimation", playerId, callbackId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
|
||||
{
|
||||
CleanupPlayerCallbacks(playerId);
|
||||
@@ -341,6 +370,18 @@ public class SpectrumCallback
|
||||
}
|
||||
}
|
||||
|
||||
public class LevelCallback
|
||||
{
|
||||
public Func<double, Task>? OnData { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnLevelDataCallback(double db)
|
||||
{
|
||||
if (OnData != null)
|
||||
await OnData(db);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user