From 58725c464666379f7c39ceb658bdea389e5ee265 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 8 Jun 2026 14:40:11 -0400 Subject: [PATCH] feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData --- .../AudioPlayerBar/LevelMeterFab.razor.cs | 37 +++++------ .../Services/AudioInteropService.cs | 41 ++++++++++++ DeepDrftPublic/Interop/audio/AudioPlayer.ts | 12 ++++ .../Interop/audio/SpectrumAnalyzer.ts | 63 ++++++++++++++++--- DeepDrftPublic/Interop/audio/index.ts | 26 ++++++++ 5 files changed, 150 insertions(+), 29 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs index 0d0cb49..cf7b8d4 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs @@ -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); diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index 91b6922..f27fe3f 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -243,6 +243,35 @@ public class AudioInteropService : IAsyncDisposable return await InvokeJsAsync("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId); } + public async Task StartLevelAnimationAsync(string playerId, string callbackId, Func callback) + { + try + { + var callbackWrapper = new LevelCallback { OnData = callback }; + var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); + _callbacks[playerId + "_level_" + callbackId] = dotNetObjectRef; + + return await _jsRuntime.InvokeAsync( + "DeepDrftAudio.startLevelAnimation", + playerId, callbackId, dotNetObjectRef, "OnLevelDataCallback"); + } + catch (Exception ex) + { + return new AudioOperationResult { Success = false, Error = ex.Message }; + } + } + + public async Task 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("DeepDrftAudio.stopLevelAnimation", playerId, callbackId); + } + public async Task DisposePlayerAsync(string playerId) { CleanupPlayerCallbacks(playerId); @@ -341,6 +370,18 @@ public class SpectrumCallback } } +public class LevelCallback +{ + public Func? OnData { get; set; } + + [JSInvokable] + public async Task OnLevelDataCallback(double db) + { + if (OnData != null) + await OnData(db); + } +} + public class AudioOperationResult { public bool Success { get; set; } diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 49cf414..ee0c50a 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -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 { diff --git a/DeepDrftPublic/Interop/audio/SpectrumAnalyzer.ts b/DeepDrftPublic/Interop/audio/SpectrumAnalyzer.ts index 9dfe9a6..e7bcc0d 100644 --- a/DeepDrftPublic/Interop/audio/SpectrumAnalyzer.ts +++ b/DeepDrftPublic/Interop/audio/SpectrumAnalyzer.ts @@ -16,6 +16,7 @@ export class SpectrumAnalyzer { private audioContext: AudioContext | null = null; private fftSize: number = 2048; private dataArray: Float32Array | null = null; + private timeDomainArray: Float32Array | 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 void>(); + private levelCallbacks = new Map 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; } } diff --git a/DeepDrftPublic/Interop/audio/index.ts b/DeepDrftPublic/Interop/audio/index.ts index f399dfa..b6dbb7b 100644 --- a/DeepDrftPublic/Interop/audio/index.ts +++ b/DeepDrftPublic/Interop/audio/index.ts @@ -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) {