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; }
|
||||
|
||||
@@ -463,6 +463,18 @@ export class AudioPlayer {
|
||||
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
|
||||
}
|
||||
|
||||
getLevelDb(): number {
|
||||
return this.contextManager.getSpectrumAnalyzer().getLevelDb();
|
||||
}
|
||||
|
||||
startLevelAnimation(callbackId: string, callback: (db: number) => void): void {
|
||||
this.contextManager.getSpectrumAnalyzer().addLevelCallback(callbackId, callback);
|
||||
}
|
||||
|
||||
stopLevelAnimation(callbackId: string): void {
|
||||
this.contextManager.getSpectrumAnalyzer().removeLevelCallback(callbackId);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
private resetState(): void {
|
||||
|
||||
@@ -16,6 +16,7 @@ export class SpectrumAnalyzer {
|
||||
private audioContext: AudioContext | null = null;
|
||||
private fftSize: number = 2048;
|
||||
private dataArray: Float32Array<ArrayBuffer> | null = null;
|
||||
private timeDomainArray: Float32Array<ArrayBuffer> | null = null;
|
||||
|
||||
// Configuration
|
||||
private bucketCount: number = 32;
|
||||
@@ -26,6 +27,7 @@ export class SpectrumAnalyzer {
|
||||
// Animation state - supports multiple callbacks per player
|
||||
private animationId: number | null = null;
|
||||
private callbacks = new Map<string, (data: number[]) => void>();
|
||||
private levelCallbacks = new Map<string, (db: number) => void>();
|
||||
private lastFrameTime: number = 0;
|
||||
private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop
|
||||
|
||||
@@ -35,6 +37,7 @@ export class SpectrumAnalyzer {
|
||||
this.analyser.fftSize = this.fftSize;
|
||||
this.analyser.smoothingTimeConstant = 0.8;
|
||||
this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
|
||||
this.timeDomainArray = new Float32Array(this.analyser.fftSize);
|
||||
|
||||
return this.analyser;
|
||||
}
|
||||
@@ -121,6 +124,22 @@ export class SpectrumAnalyzer {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the true RMS signal level in dBFS from the time-domain waveform.
|
||||
* Unlike getFrequencyData (FFT peaks), this reflects the actual signal level
|
||||
* and calibrates against commercial loudness targets. Returns -Infinity on silence.
|
||||
*/
|
||||
getLevelDb(): number {
|
||||
if (!this.analyser || !this.timeDomainArray) return -Infinity;
|
||||
this.analyser.getFloatTimeDomainData(this.timeDomainArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.timeDomainArray.length; i++) {
|
||||
sum += this.timeDomainArray[i] * this.timeDomainArray[i];
|
||||
}
|
||||
const rms = Math.sqrt(sum / this.timeDomainArray.length);
|
||||
return rms > 0 ? 20 * Math.log10(rms) : -Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply high-pass, low-pass, and slope correction filters
|
||||
*/
|
||||
@@ -157,7 +176,7 @@ export class SpectrumAnalyzer {
|
||||
* Add a callback for spectrum data. Starts animation loop on first subscriber.
|
||||
*/
|
||||
addCallback(id: string, callback: (data: number[]) => void): void {
|
||||
const wasEmpty = this.callbacks.size === 0;
|
||||
const wasEmpty = this.callbacks.size === 0 && this.levelCallbacks.size === 0;
|
||||
this.callbacks.set(id, callback);
|
||||
if (wasEmpty) {
|
||||
this.lastFrameTime = 0;
|
||||
@@ -170,7 +189,30 @@ export class SpectrumAnalyzer {
|
||||
*/
|
||||
removeCallback(id: string): void {
|
||||
this.callbacks.delete(id);
|
||||
if (this.callbacks.size === 0) {
|
||||
if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback for true RMS level data (dBFS). Shares the spectrum animation
|
||||
* loop; starts it only if both callback maps were previously empty.
|
||||
*/
|
||||
addLevelCallback(id: string, callback: (db: number) => void): void {
|
||||
const wasEmpty = this.callbacks.size === 0 && this.levelCallbacks.size === 0;
|
||||
this.levelCallbacks.set(id, callback);
|
||||
if (wasEmpty) {
|
||||
this.lastFrameTime = 0;
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a level callback by ID. Stops the shared loop only when both maps are empty.
|
||||
*/
|
||||
removeLevelCallback(id: string): void {
|
||||
this.levelCallbacks.delete(id);
|
||||
if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
@@ -186,16 +228,21 @@ export class SpectrumAnalyzer {
|
||||
}
|
||||
|
||||
private animate = (timestamp: number): void => {
|
||||
if (this.callbacks.size === 0) return;
|
||||
if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) return;
|
||||
|
||||
// Throttle to target frame rate
|
||||
const elapsed = timestamp - this.lastFrameTime;
|
||||
if (elapsed >= this.targetFrameInterval) {
|
||||
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
|
||||
const data = this.getFrequencyData();
|
||||
// Broadcast to all callbacks
|
||||
for (const cb of this.callbacks.values()) {
|
||||
cb(data);
|
||||
|
||||
if (this.callbacks.size > 0) {
|
||||
const data = this.getFrequencyData();
|
||||
for (const cb of this.callbacks.values()) cb(data);
|
||||
}
|
||||
|
||||
if (this.levelCallbacks.size > 0) {
|
||||
const db = this.getLevelDb();
|
||||
for (const cb of this.levelCallbacks.values()) cb(db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +252,10 @@ export class SpectrumAnalyzer {
|
||||
dispose(): void {
|
||||
this.stopAnimation();
|
||||
this.callbacks.clear();
|
||||
this.levelCallbacks.clear();
|
||||
this.analyser = null;
|
||||
this.audioContext = null;
|
||||
this.dataArray = null;
|
||||
this.timeDomainArray = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,32 @@ const DeepDrftAudio = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
getLevelDb: (playerId: string): number => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
return player?.getLevelDb() ?? -Infinity;
|
||||
},
|
||||
|
||||
startLevelAnimation: (
|
||||
playerId: string,
|
||||
callbackId: string,
|
||||
dotNetRef: DotNetObjectReference,
|
||||
methodName: string
|
||||
): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
player.startLevelAnimation(callbackId, (db: number) => {
|
||||
dotNetRef.invokeMethodAsync(methodName, db);
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
stopLevelAnimation: (playerId: string, callbackId: string): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
player.stopLevelAnimation(callbackId);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
disposePlayer: (playerId: string): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (player) {
|
||||
|
||||
Reference in New Issue
Block a user