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
@@ -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;
}
}