From a972d4646b9eac6daa2b651788666bd286396634 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:55:54 -0400 Subject: [PATCH] update VU bar --- src/renderer/visualizers/VUMeter.ts | 64 ++++++++++--------- src/renderer/visualizers/vuMeterBallistics.ts | 41 +++++++++--- test/renderer-helpers.test.ts | 47 +++++++------- 3 files changed, 91 insertions(+), 61 deletions(-) diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index 607d0b2..ec237db 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -56,8 +56,10 @@ export class VUMeter { private unsubscribeSessionChange: (() => void) | null = null // Meter state - private rmsL = VU_METER_MIN_DB - private rmsR = VU_METER_MIN_DB + private vuL = VU_METER_MIN_DB + private vuR = VU_METER_MIN_DB + private barL = VU_METER_MIN_DB + private barR = VU_METER_MIN_DB private peakL = VU_METER_MIN_DB private peakR = VU_METER_MIN_DB private correlation = 0 @@ -124,8 +126,10 @@ export class VUMeter { } private applySnapshot(snapshot: VUMeterSnapshot): void { - this.rmsL = snapshot.rmsLDb - this.rmsR = snapshot.rmsRDb + this.vuL = snapshot.vuLDb + this.vuR = snapshot.vuRDb + this.barL = snapshot.barLDb + this.barR = snapshot.barRDb this.peakL = snapshot.peakLDb this.peakR = snapshot.peakRDb this.correlation = snapshot.correlation @@ -178,15 +182,15 @@ export class VUMeter { // ---- L meter ---- const lY = topOffset - this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.rmsL, this.peakL, cr, cg, cb) + this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.barL, this.peakL, cr, cg, cb) this.drawMeterLabel(ctx, 0, lY, labelWidth, meterHeight, 'L') - this.drawDbLabel(ctx, barRight + 4, lY, dbLabelWidth, meterHeight, this.rmsL) + this.drawDbLabel(ctx, barRight + 4, lY, dbLabelWidth, meterHeight, this.barL) // ---- R meter ---- const rY = lY + meterHeight + gap - this.drawHorizontalMeterBar(ctx, barLeft, rY, barWidth, meterHeight, this.rmsR, this.peakR, cr, cg, cb) + this.drawHorizontalMeterBar(ctx, barLeft, rY, barWidth, meterHeight, this.barR, this.peakR, cr, cg, cb) this.drawMeterLabel(ctx, 0, rY, labelWidth, meterHeight, 'R') - this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.rmsR) + this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.barR) // ---- Correlation meter ---- const corrY = rY + meterHeight + gap @@ -219,12 +223,12 @@ export class VUMeter { const rX = meterLeft + meterWidth + channelGap this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L') - this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.rmsL, this.peakL, cr, cg, cb) - this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.rmsL) + this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.barL, this.peakL, cr, cg, cb) + this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.barL) this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R') - this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.rmsR, this.peakR, cr, cg, cb) - this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.rmsR) + this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.barR, this.peakR, cr, cg, cb) + this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.barR) this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb) } @@ -232,28 +236,28 @@ export class VUMeter { private drawHorizontalMeterBar( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, - rmsDb: number, peakDb: number, + levelDb: number, peakDb: number, cr: number, cg: number, cb: number ): void { - const rmsNorm = this.dbToNormalized(rmsDb) + const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) - const rmsWidth = rmsNorm * w + const levelWidth = levelNorm * w const hotThreshold = this.dbToNormalized(-6) * w // Background track ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' ctx.fillRect(x, y, w, h) - // RMS bar - if (rmsWidth > 0) { - const safeWidth = Math.min(rmsWidth, hotThreshold) + // Main level bar + if (levelWidth > 0) { + const safeWidth = Math.min(levelWidth, hotThreshold) if (safeWidth > 0) { ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82) ctx.fillRect(x, y, safeWidth, h) } - if (rmsWidth > hotThreshold) { + if (levelWidth > hotThreshold) { // Hot zone: transition to warm/red - const hotWidth = rmsWidth - hotThreshold + const hotWidth = levelWidth - hotThreshold const hotProgress = Math.min(1, hotWidth / Math.max(1, w - hotThreshold)) const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7) const hotG = Math.round(cg * (1 - hotProgress * 0.6)) @@ -285,31 +289,31 @@ export class VUMeter { private drawVerticalMeterBar( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, - rmsDb: number, peakDb: number, + levelDb: number, peakDb: number, cr: number, cg: number, cb: number ): void { - const rmsNorm = this.dbToNormalized(rmsDb) + const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) - const rmsHeight = rmsNorm * h + const levelHeight = levelNorm * h const hotThreshold = this.dbToNormalized(-6) * h ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' ctx.fillRect(x, y, w, h) - if (rmsHeight > 0) { - const safeHeight = Math.min(rmsHeight, hotThreshold) + if (levelHeight > 0) { + const safeHeight = Math.min(levelHeight, hotThreshold) if (safeHeight > 0) { ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82) ctx.fillRect(x, y + h - safeHeight, w, safeHeight) } - if (rmsHeight > hotThreshold) { - const hotHeight = rmsHeight - hotThreshold + if (levelHeight > hotThreshold) { + const hotHeight = levelHeight - hotThreshold const hotProgress = Math.min(1, hotHeight / Math.max(1, h - hotThreshold)) const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7) const hotG = Math.round(cg * (1 - hotProgress * 0.6)) const hotB = Math.round(cb * (1 - hotProgress * 0.7)) ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82) - ctx.fillRect(x, y + h - rmsHeight, w, hotHeight) + ctx.fillRect(x, y + h - levelHeight, w, hotHeight) } } @@ -427,9 +431,9 @@ export class VUMeter { const barWidth = Math.max(1, barRight - barLeft) // L needle - this.drawNeedleMeter(ctx, 0, 0, meterWidth, meterAreaHeight, this.rmsL, this.peakL, 'L', cr, cg, cb) + this.drawNeedleMeter(ctx, 0, 0, meterWidth, meterAreaHeight, this.vuL, this.peakL, 'L', cr, cg, cb) // R needle - this.drawNeedleMeter(ctx, meterWidth + 4, 0, meterWidth, meterAreaHeight, this.rmsR, this.peakR, 'R', cr, cg, cb) + this.drawNeedleMeter(ctx, meterWidth + 4, 0, meterWidth, meterAreaHeight, this.vuR, this.peakR, 'R', cr, cg, cb) // Correlation bar at bottom this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb) diff --git a/src/renderer/visualizers/vuMeterBallistics.ts b/src/renderer/visualizers/vuMeterBallistics.ts index 35323f5..ceaa4df 100644 --- a/src/renderer/visualizers/vuMeterBallistics.ts +++ b/src/renderer/visualizers/vuMeterBallistics.ts @@ -4,8 +4,10 @@ export interface VUMeterStereoChunk { } export interface VUMeterSnapshot { - rmsLDb: number - rmsRDb: number + vuLDb: number + vuRDb: number + barLDb: number + barRDb: number peakLDb: number peakRDb: number correlation: number @@ -16,10 +18,14 @@ export const VU_METER_MAX_DB = 0 export const VU_INTEGRATION_WINDOW_MS = 300 export const VU_PEAK_HOLD_MS = 750 export const VU_PEAK_DECAY_DB_PER_SECOND = 18 +const BAR_ATTACK_MS = 5 +const BAR_RELEASE_MS = 180 const INITIAL_SNAPSHOT: VUMeterSnapshot = { - rmsLDb: VU_METER_MIN_DB, - rmsRDb: VU_METER_MIN_DB, + vuLDb: VU_METER_MIN_DB, + vuRDb: VU_METER_MIN_DB, + barLDb: VU_METER_MIN_DB, + barRDb: VU_METER_MIN_DB, peakLDb: VU_METER_MIN_DB, peakRDb: VU_METER_MIN_DB, correlation: 0, @@ -48,6 +54,10 @@ export class VUMeterBallistics { private peakHoldUntilL = 0 private peakHoldUntilR = 0 private lastPeakUpdateMs: number | null = null + private barEnvelopeL = 0 + private barEnvelopeR = 0 + private barAttackCoeff = 0 + private barReleaseCoeff = 0 private snapshot: VUMeterSnapshot = { ...INITIAL_SNAPSHOT } constructor(sampleRate: number) { @@ -67,6 +77,8 @@ export class VUMeterBallistics { this.sqL = new Float64Array(this.integrationWindowSamples) this.sqR = new Float64Array(this.integrationWindowSamples) this.cross = new Float64Array(this.integrationWindowSamples) + this.barAttackCoeff = Math.exp(-1 / (this.sampleRate * (BAR_ATTACK_MS / 1000))) + this.barReleaseCoeff = Math.exp(-1 / (this.sampleRate * (BAR_RELEASE_MS / 1000))) this.reset() } @@ -82,6 +94,8 @@ export class VUMeterBallistics { this.peakHoldUntilL = 0 this.peakHoldUntilR = 0 this.lastPeakUpdateMs = null + this.barEnvelopeL = 0 + this.barEnvelopeR = 0 this.snapshot = { ...INITIAL_SNAPSHOT } } @@ -122,6 +136,8 @@ export class VUMeterBallistics { const absL = Math.abs(left) const absR = Math.abs(right) + this.barEnvelopeL = this.updateBarEnvelope(this.barEnvelopeL, absL) + this.barEnvelopeR = this.updateBarEnvelope(this.barEnvelopeR, absR) if (absL > maxPeakL) maxPeakL = absL if (absR > maxPeakR) maxPeakR = absR } @@ -139,8 +155,10 @@ export class VUMeterBallistics { private recomputeSnapshot(): void { if (this.sampleCount <= 0) { - this.snapshot.rmsLDb = VU_METER_MIN_DB - this.snapshot.rmsRDb = VU_METER_MIN_DB + this.snapshot.vuLDb = VU_METER_MIN_DB + this.snapshot.vuRDb = VU_METER_MIN_DB + this.snapshot.barLDb = amplitudeToDb(this.barEnvelopeL) + this.snapshot.barRDb = amplitudeToDb(this.barEnvelopeR) this.snapshot.correlation = 0 return } @@ -149,13 +167,20 @@ export class VUMeterBallistics { const meanSqR = this.sumSqR / this.sampleCount const denominator = Math.sqrt(this.sumSqL * this.sumSqR) - this.snapshot.rmsLDb = amplitudeToDb(Math.sqrt(meanSqL)) - this.snapshot.rmsRDb = amplitudeToDb(Math.sqrt(meanSqR)) + this.snapshot.vuLDb = amplitudeToDb(Math.sqrt(meanSqL)) + this.snapshot.vuRDb = amplitudeToDb(Math.sqrt(meanSqR)) + this.snapshot.barLDb = amplitudeToDb(this.barEnvelopeL) + this.snapshot.barRDb = amplitudeToDb(this.barEnvelopeR) this.snapshot.correlation = denominator > 1e-10 ? Math.max(-1, Math.min(1, this.sumCross / denominator)) : 0 } + private updateBarEnvelope(current: number, input: number): number { + const coeff = input > current ? this.barAttackCoeff : this.barReleaseCoeff + return coeff * current + (1 - coeff) * input + } + private advancePeaks(nowMs: number): void { if (!Number.isFinite(nowMs)) { return diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index e01d813..91e1a4f 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -432,21 +432,23 @@ test('moveDockedScopeOrder preserves popped-out scope positions in the full orde ]) }) -test('VUMeterBallistics holds RMS and correlation steady when an active frame receives no chunks', () => { +test('VUMeterBallistics holds VU, bar, and correlation steady when an active frame receives no chunks', () => { const sampleRate = 48000 const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const meter = new VUMeterBallistics(sampleRate) const initial = meter.process([createFilledStereoChunk(0.5, 0.5, windowSamples)], 300) const held = meter.process([], 316) - assert.ok(initial.rmsLDb > -7 && initial.rmsLDb < -5) - assertAlmostEqual(held.rmsLDb, initial.rmsLDb, 1e-12, 'left RMS should hold across empty frames') - assertAlmostEqual(held.rmsRDb, initial.rmsRDb, 1e-12, 'right RMS should hold across empty frames') + assert.ok(initial.vuLDb > -7 && initial.vuLDb < -5) + assertAlmostEqual(held.vuLDb, initial.vuLDb, 1e-12, 'left VU should hold across empty frames') + assertAlmostEqual(held.vuRDb, initial.vuRDb, 1e-12, 'right VU should hold across empty frames') + assertAlmostEqual(held.barLDb, initial.barLDb, 1e-12, 'left bar should hold across empty frames') + assertAlmostEqual(held.barRDb, initial.barRDb, 1e-12, 'right bar should hold across empty frames') assertAlmostEqual(held.correlation, initial.correlation, 1e-12, 'correlation should hold across empty frames') - assert.notEqual(held.rmsLDb, VU_METER_MIN_DB) + assert.notEqual(held.vuLDb, VU_METER_MIN_DB) }) -test('VUMeterBallistics produces the same RMS and correlation for contiguous and irregular chunk delivery', () => { +test('VUMeterBallistics produces the same VU, bar, and correlation for contiguous and irregular chunk delivery', () => { const sampleRate = 48000 const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const program = createProgramSamples(windowSamples) @@ -471,29 +473,28 @@ test('VUMeterBallistics produces the same RMS and correlation for contiguous and } const irregularSnapshot = irregular.getSnapshot() - assertAlmostEqual(irregularSnapshot.rmsLDb, contiguousSnapshot.rmsLDb, 1e-6, 'left RMS should be chunking-invariant') - assertAlmostEqual(irregularSnapshot.rmsRDb, contiguousSnapshot.rmsRDb, 1e-6, 'right RMS should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.vuLDb, contiguousSnapshot.vuLDb, 1e-6, 'left VU should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.vuRDb, contiguousSnapshot.vuRDb, 1e-6, 'right VU should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.barLDb, contiguousSnapshot.barLDb, 1e-6, 'left bar should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.barRDb, contiguousSnapshot.barRDb, 1e-6, 'right bar should be chunking-invariant') assertAlmostEqual(irregularSnapshot.correlation, contiguousSnapshot.correlation, 1e-6, 'correlation should be chunking-invariant') }) -test('VUMeterBallistics tracks a transient peak independently of the RMS bar and decays it by elapsed time', () => { +test('VUMeterBallistics lets the bar outrun the VU needle while peak hold remains highest', () => { const sampleRate = 48000 - const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) - const left = new Float32Array(windowSamples) - const right = new Float32Array(windowSamples) - left.fill(0.1) - right.fill(0.1) - left[Math.floor(windowSamples / 2)] = 1.0 - right[Math.floor(windowSamples / 2)] = 1.0 - const meter = new VUMeterBallistics(sampleRate) - const initial = meter.process([{ left, right }], 300) - const beforeDecay = meter.process([], 300 + VU_PEAK_HOLD_MS - 10) - const afterDecay = meter.process([], 300 + VU_PEAK_HOLD_MS + 100) + const baselineSamples = Math.floor(sampleRate * 0.2) + const burstSamples = Math.floor(sampleRate * 0.016) - assert.ok(initial.rmsLDb < -19) - assert.equal(initial.peakLDb, 0) - assert.ok(initial.peakLDb > initial.rmsLDb + 10) + meter.process([createFilledStereoChunk(0.1, 0.1, baselineSamples)], 200) + const burstSnapshot = meter.process([createFilledStereoChunk(1.0, 1.0, burstSamples)], 216) + const beforeDecay = meter.process([], 216 + VU_PEAK_HOLD_MS - 10) + const afterDecay = meter.process([], 216 + VU_PEAK_HOLD_MS + 100) + + assert.ok(burstSnapshot.vuLDb < -10) + assert.ok(burstSnapshot.barLDb > burstSnapshot.vuLDb + 8) + assert.ok(burstSnapshot.peakLDb >= burstSnapshot.barLDb) + assert.equal(burstSnapshot.peakLDb, 0) assert.equal(beforeDecay.peakLDb, 0) assert.ok(afterDecay.peakLDb < beforeDecay.peakLDb) assert.ok(afterDecay.peakLDb > -3)