feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user