feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData

This commit is contained in:
daniel-c-harvey
2026-06-08 14:40:11 -04:00
parent 9cbc09edf7
commit 58725c4646
5 changed files with 150 additions and 29 deletions
@@ -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;
}
}
+26
View File
@@ -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) {