From 1de3e9eced818f3357a1fc03ece1c8efadfed368 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 7 May 2026 15:52:47 -0400 Subject: [PATCH] Fix NaN drift in VU --- src/renderer/visualizers/VUMeter.ts | 5 +-- src/renderer/visualizers/vuMeterBallistics.ts | 16 ++++++--- test/renderer-helpers.test.ts | 35 ++++++++++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index 9940d00..ae43912 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -606,7 +606,7 @@ export class VUMeter { x: number, y: number, _w: number, h: number, db: number ): void { - const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) + const displayDb = Number.isFinite(db) ? Math.max(VU_METER_MIN_DB, Math.min(0, db)) : VU_METER_MIN_DB const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace` @@ -620,7 +620,7 @@ export class VUMeter { x: number, y: number, w: number, h: number, db: number ): void { - const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) + const displayDb = Number.isFinite(db) ? Math.max(VU_METER_MIN_DB, Math.min(0, db)) : VU_METER_MIN_DB const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace` @@ -1088,6 +1088,7 @@ export class VUMeter { } private formatNeedleDb(db: number): string { + if (!Number.isFinite(db)) return '-∞' const displayDb = clamp(db, VU_METER_MIN_DB, VU_METER_MAX_DB) return displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : displayDb.toFixed(1) } diff --git a/src/renderer/visualizers/vuMeterBallistics.ts b/src/renderer/visualizers/vuMeterBallistics.ts index ceaa4df..4baec0c 100644 --- a/src/renderer/visualizers/vuMeterBallistics.ts +++ b/src/renderer/visualizers/vuMeterBallistics.ts @@ -36,6 +36,9 @@ function clampSampleRate(sampleRate: number): number { } function amplitudeToDb(amplitude: number): number { + if (!Number.isFinite(amplitude) || amplitude <= 0) { + return VU_METER_MIN_DB + } const db = 20 * Math.log10(Math.max(amplitude, 1e-10)) return Math.max(VU_METER_MIN_DB, Math.min(VU_METER_MAX_DB, db)) } @@ -119,8 +122,11 @@ export class VUMeterBallistics { const cross = left * right if (this.sampleCount === this.integrationWindowSamples) { - this.sumSqL -= this.sqL[this.writeIndex] - this.sumSqR -= this.sqR[this.writeIndex] + // Clamp running sums to non-negative: floating-point cancellation in + // the slide-out subtraction can drift them by ~1e-15 below zero on + // near-silent content, which would propagate as NaN through sqrt. + this.sumSqL = Math.max(0, this.sumSqL - this.sqL[this.writeIndex]) + this.sumSqR = Math.max(0, this.sumSqR - this.sqR[this.writeIndex]) this.sumCross -= this.cross[this.writeIndex] } else { this.sampleCount += 1 @@ -163,9 +169,9 @@ export class VUMeterBallistics { return } - const meanSqL = this.sumSqL / this.sampleCount - const meanSqR = this.sumSqR / this.sampleCount - const denominator = Math.sqrt(this.sumSqL * this.sumSqR) + const meanSqL = Math.max(0, this.sumSqL) / this.sampleCount + const meanSqR = Math.max(0, this.sumSqR) / this.sampleCount + const denominator = Math.sqrt(Math.max(0, this.sumSqL) * Math.max(0, this.sumSqR)) this.snapshot.vuLDb = amplitudeToDb(Math.sqrt(meanSqL)) this.snapshot.vuRDb = amplitudeToDb(Math.sqrt(meanSqR)) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 265a19f..31f6d23 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -3512,6 +3512,31 @@ test('VUMeterBallistics produces the same VU, bar, and correlation for contiguou assertAlmostEqual(irregularSnapshot.correlation, contiguousSnapshot.correlation, 1e-6, 'correlation should be chunking-invariant') }) +test('VUMeterBallistics keeps RMS finite across loud-then-silent sequences (NaN-drift regression)', () => { + const sampleRate = 48000 + const meter = new VUMeterBallistics(sampleRate) + const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) + + // Run loud audio long enough to fully populate the sliding-window buffer. + meter.process([createFilledStereoChunk(0.7, 0.7, windowSamples * 2)], 100) + + // Then feed many windows of digital silence. Floating-point cancellation in + // the running-sum slide-out used to drift sumSqL/sumSqR slightly below zero, + // producing NaN through Math.sqrt. After the fix, the RMS must remain finite + // and decay all the way to VU_METER_MIN_DB. + let nowMs = 100 + for (let i = 0; i < 20; i += 1) { + nowMs += 50 + meter.process([createFilledStereoChunk(0, 0, windowSamples)], nowMs) + } + + const snapshot = meter.getSnapshot() + assert.ok(Number.isFinite(snapshot.vuLDb), 'left VU must stay finite (no NaN)') + assert.ok(Number.isFinite(snapshot.vuRDb), 'right VU must stay finite (no NaN)') + assert.equal(snapshot.vuLDb, VU_METER_MIN_DB) + assert.equal(snapshot.vuRDb, VU_METER_MIN_DB) +}) + test('VUMeterBallistics lets the bar outrun the VU needle while peak hold remains highest', () => { const sampleRate = 48000 const meter = new VUMeterBallistics(sampleRate) @@ -3696,8 +3721,16 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 12 && rect.width < 80), true, ) + // Readout tag pill: drawn with line color, fits to text width with padding, + // and uses the compact tag height. Sized smaller than the LUFS level bar. assert.equal( - recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 120 && rect.height <= 34), + recorder.fillRects.some((rect) => + rect.fillStyle === 'rgb(255, 0, 96)' + && rect.width >= 40 + && rect.width <= 100 + && rect.height >= 14 + && rect.height <= 24, + ), true, ) assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(1, 2, 3)'), true)