update VU bar

This commit is contained in:
Boof2015
2026-03-28 13:55:54 -04:00
parent 0d5e487385
commit a972d4646b
3 changed files with 91 additions and 61 deletions
+34 -30
View File
@@ -56,8 +56,10 @@ export class VUMeter {
private unsubscribeSessionChange: (() => void) | null = null private unsubscribeSessionChange: (() => void) | null = null
// Meter state // Meter state
private rmsL = VU_METER_MIN_DB private vuL = VU_METER_MIN_DB
private rmsR = 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 peakL = VU_METER_MIN_DB
private peakR = VU_METER_MIN_DB private peakR = VU_METER_MIN_DB
private correlation = 0 private correlation = 0
@@ -124,8 +126,10 @@ export class VUMeter {
} }
private applySnapshot(snapshot: VUMeterSnapshot): void { private applySnapshot(snapshot: VUMeterSnapshot): void {
this.rmsL = snapshot.rmsLDb this.vuL = snapshot.vuLDb
this.rmsR = snapshot.rmsRDb this.vuR = snapshot.vuRDb
this.barL = snapshot.barLDb
this.barR = snapshot.barRDb
this.peakL = snapshot.peakLDb this.peakL = snapshot.peakLDb
this.peakR = snapshot.peakRDb this.peakR = snapshot.peakRDb
this.correlation = snapshot.correlation this.correlation = snapshot.correlation
@@ -178,15 +182,15 @@ export class VUMeter {
// ---- L meter ---- // ---- L meter ----
const lY = topOffset 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.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 ---- // ---- R meter ----
const rY = lY + meterHeight + gap 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.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 ---- // ---- Correlation meter ----
const corrY = rY + meterHeight + gap const corrY = rY + meterHeight + gap
@@ -219,12 +223,12 @@ export class VUMeter {
const rX = meterLeft + meterWidth + channelGap const rX = meterLeft + meterWidth + channelGap
this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L') this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L')
this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.rmsL, this.peakL, cr, cg, cb) this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.barL, this.peakL, cr, cg, cb)
this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.rmsL) this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.barL)
this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R') this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R')
this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.rmsR, this.peakR, cr, cg, cb) this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.barR, this.peakR, cr, cg, cb)
this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.rmsR) this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.barR)
this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb) this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb)
} }
@@ -232,28 +236,28 @@ export class VUMeter {
private drawHorizontalMeterBar( private drawHorizontalMeterBar(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, x: number, y: number, w: number, h: number,
rmsDb: number, peakDb: number, levelDb: number, peakDb: number,
cr: number, cg: number, cb: number cr: number, cg: number, cb: number
): void { ): void {
const rmsNorm = this.dbToNormalized(rmsDb) const levelNorm = this.dbToNormalized(levelDb)
const peakNorm = this.dbToNormalized(peakDb) const peakNorm = this.dbToNormalized(peakDb)
const rmsWidth = rmsNorm * w const levelWidth = levelNorm * w
const hotThreshold = this.dbToNormalized(-6) * w const hotThreshold = this.dbToNormalized(-6) * w
// Background track // Background track
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'
ctx.fillRect(x, y, w, h) ctx.fillRect(x, y, w, h)
// RMS bar // Main level bar
if (rmsWidth > 0) { if (levelWidth > 0) {
const safeWidth = Math.min(rmsWidth, hotThreshold) const safeWidth = Math.min(levelWidth, hotThreshold)
if (safeWidth > 0) { if (safeWidth > 0) {
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82) ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
ctx.fillRect(x, y, safeWidth, h) ctx.fillRect(x, y, safeWidth, h)
} }
if (rmsWidth > hotThreshold) { if (levelWidth > hotThreshold) {
// Hot zone: transition to warm/red // 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 hotProgress = Math.min(1, hotWidth / Math.max(1, w - hotThreshold))
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7) const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
const hotG = Math.round(cg * (1 - hotProgress * 0.6)) const hotG = Math.round(cg * (1 - hotProgress * 0.6))
@@ -285,31 +289,31 @@ export class VUMeter {
private drawVerticalMeterBar( private drawVerticalMeterBar(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, x: number, y: number, w: number, h: number,
rmsDb: number, peakDb: number, levelDb: number, peakDb: number,
cr: number, cg: number, cb: number cr: number, cg: number, cb: number
): void { ): void {
const rmsNorm = this.dbToNormalized(rmsDb) const levelNorm = this.dbToNormalized(levelDb)
const peakNorm = this.dbToNormalized(peakDb) const peakNorm = this.dbToNormalized(peakDb)
const rmsHeight = rmsNorm * h const levelHeight = levelNorm * h
const hotThreshold = this.dbToNormalized(-6) * h const hotThreshold = this.dbToNormalized(-6) * h
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'
ctx.fillRect(x, y, w, h) ctx.fillRect(x, y, w, h)
if (rmsHeight > 0) { if (levelHeight > 0) {
const safeHeight = Math.min(rmsHeight, hotThreshold) const safeHeight = Math.min(levelHeight, hotThreshold)
if (safeHeight > 0) { if (safeHeight > 0) {
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82) ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
ctx.fillRect(x, y + h - safeHeight, w, safeHeight) ctx.fillRect(x, y + h - safeHeight, w, safeHeight)
} }
if (rmsHeight > hotThreshold) { if (levelHeight > hotThreshold) {
const hotHeight = rmsHeight - hotThreshold const hotHeight = levelHeight - hotThreshold
const hotProgress = Math.min(1, hotHeight / Math.max(1, h - hotThreshold)) const hotProgress = Math.min(1, hotHeight / Math.max(1, h - hotThreshold))
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7) const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
const hotG = Math.round(cg * (1 - hotProgress * 0.6)) const hotG = Math.round(cg * (1 - hotProgress * 0.6))
const hotB = Math.round(cb * (1 - hotProgress * 0.7)) const hotB = Math.round(cb * (1 - hotProgress * 0.7))
ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82) 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) const barWidth = Math.max(1, barRight - barLeft)
// L needle // 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 // 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 // Correlation bar at bottom
this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb) this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb)
+33 -8
View File
@@ -4,8 +4,10 @@ export interface VUMeterStereoChunk {
} }
export interface VUMeterSnapshot { export interface VUMeterSnapshot {
rmsLDb: number vuLDb: number
rmsRDb: number vuRDb: number
barLDb: number
barRDb: number
peakLDb: number peakLDb: number
peakRDb: number peakRDb: number
correlation: number correlation: number
@@ -16,10 +18,14 @@ export const VU_METER_MAX_DB = 0
export const VU_INTEGRATION_WINDOW_MS = 300 export const VU_INTEGRATION_WINDOW_MS = 300
export const VU_PEAK_HOLD_MS = 750 export const VU_PEAK_HOLD_MS = 750
export const VU_PEAK_DECAY_DB_PER_SECOND = 18 export const VU_PEAK_DECAY_DB_PER_SECOND = 18
const BAR_ATTACK_MS = 5
const BAR_RELEASE_MS = 180
const INITIAL_SNAPSHOT: VUMeterSnapshot = { const INITIAL_SNAPSHOT: VUMeterSnapshot = {
rmsLDb: VU_METER_MIN_DB, vuLDb: VU_METER_MIN_DB,
rmsRDb: VU_METER_MIN_DB, vuRDb: VU_METER_MIN_DB,
barLDb: VU_METER_MIN_DB,
barRDb: VU_METER_MIN_DB,
peakLDb: VU_METER_MIN_DB, peakLDb: VU_METER_MIN_DB,
peakRDb: VU_METER_MIN_DB, peakRDb: VU_METER_MIN_DB,
correlation: 0, correlation: 0,
@@ -48,6 +54,10 @@ export class VUMeterBallistics {
private peakHoldUntilL = 0 private peakHoldUntilL = 0
private peakHoldUntilR = 0 private peakHoldUntilR = 0
private lastPeakUpdateMs: number | null = null private lastPeakUpdateMs: number | null = null
private barEnvelopeL = 0
private barEnvelopeR = 0
private barAttackCoeff = 0
private barReleaseCoeff = 0
private snapshot: VUMeterSnapshot = { ...INITIAL_SNAPSHOT } private snapshot: VUMeterSnapshot = { ...INITIAL_SNAPSHOT }
constructor(sampleRate: number) { constructor(sampleRate: number) {
@@ -67,6 +77,8 @@ export class VUMeterBallistics {
this.sqL = new Float64Array(this.integrationWindowSamples) this.sqL = new Float64Array(this.integrationWindowSamples)
this.sqR = new Float64Array(this.integrationWindowSamples) this.sqR = new Float64Array(this.integrationWindowSamples)
this.cross = 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() this.reset()
} }
@@ -82,6 +94,8 @@ export class VUMeterBallistics {
this.peakHoldUntilL = 0 this.peakHoldUntilL = 0
this.peakHoldUntilR = 0 this.peakHoldUntilR = 0
this.lastPeakUpdateMs = null this.lastPeakUpdateMs = null
this.barEnvelopeL = 0
this.barEnvelopeR = 0
this.snapshot = { ...INITIAL_SNAPSHOT } this.snapshot = { ...INITIAL_SNAPSHOT }
} }
@@ -122,6 +136,8 @@ export class VUMeterBallistics {
const absL = Math.abs(left) const absL = Math.abs(left)
const absR = Math.abs(right) 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 (absL > maxPeakL) maxPeakL = absL
if (absR > maxPeakR) maxPeakR = absR if (absR > maxPeakR) maxPeakR = absR
} }
@@ -139,8 +155,10 @@ export class VUMeterBallistics {
private recomputeSnapshot(): void { private recomputeSnapshot(): void {
if (this.sampleCount <= 0) { if (this.sampleCount <= 0) {
this.snapshot.rmsLDb = VU_METER_MIN_DB this.snapshot.vuLDb = VU_METER_MIN_DB
this.snapshot.rmsRDb = 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 this.snapshot.correlation = 0
return return
} }
@@ -149,13 +167,20 @@ export class VUMeterBallistics {
const meanSqR = this.sumSqR / this.sampleCount const meanSqR = this.sumSqR / this.sampleCount
const denominator = Math.sqrt(this.sumSqL * this.sumSqR) const denominator = Math.sqrt(this.sumSqL * this.sumSqR)
this.snapshot.rmsLDb = amplitudeToDb(Math.sqrt(meanSqL)) this.snapshot.vuLDb = amplitudeToDb(Math.sqrt(meanSqL))
this.snapshot.rmsRDb = amplitudeToDb(Math.sqrt(meanSqR)) 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 this.snapshot.correlation = denominator > 1e-10
? Math.max(-1, Math.min(1, this.sumCross / denominator)) ? Math.max(-1, Math.min(1, this.sumCross / denominator))
: 0 : 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 { private advancePeaks(nowMs: number): void {
if (!Number.isFinite(nowMs)) { if (!Number.isFinite(nowMs)) {
return return
+24 -23
View File
@@ -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 sampleRate = 48000
const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000)
const meter = new VUMeterBallistics(sampleRate) const meter = new VUMeterBallistics(sampleRate)
const initial = meter.process([createFilledStereoChunk(0.5, 0.5, windowSamples)], 300) const initial = meter.process([createFilledStereoChunk(0.5, 0.5, windowSamples)], 300)
const held = meter.process([], 316) const held = meter.process([], 316)
assert.ok(initial.rmsLDb > -7 && initial.rmsLDb < -5) assert.ok(initial.vuLDb > -7 && initial.vuLDb < -5)
assertAlmostEqual(held.rmsLDb, initial.rmsLDb, 1e-12, 'left RMS should hold across empty frames') assertAlmostEqual(held.vuLDb, initial.vuLDb, 1e-12, 'left VU should hold across empty frames')
assertAlmostEqual(held.rmsRDb, initial.rmsRDb, 1e-12, 'right RMS 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') 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 sampleRate = 48000
const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000)
const program = createProgramSamples(windowSamples) const program = createProgramSamples(windowSamples)
@@ -471,29 +473,28 @@ test('VUMeterBallistics produces the same RMS and correlation for contiguous and
} }
const irregularSnapshot = irregular.getSnapshot() const irregularSnapshot = irregular.getSnapshot()
assertAlmostEqual(irregularSnapshot.rmsLDb, contiguousSnapshot.rmsLDb, 1e-6, 'left RMS should be chunking-invariant') assertAlmostEqual(irregularSnapshot.vuLDb, contiguousSnapshot.vuLDb, 1e-6, 'left VU should be chunking-invariant')
assertAlmostEqual(irregularSnapshot.rmsRDb, contiguousSnapshot.rmsRDb, 1e-6, 'right RMS 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') 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 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 meter = new VUMeterBallistics(sampleRate)
const initial = meter.process([{ left, right }], 300) const baselineSamples = Math.floor(sampleRate * 0.2)
const beforeDecay = meter.process([], 300 + VU_PEAK_HOLD_MS - 10) const burstSamples = Math.floor(sampleRate * 0.016)
const afterDecay = meter.process([], 300 + VU_PEAK_HOLD_MS + 100)
assert.ok(initial.rmsLDb < -19) meter.process([createFilledStereoChunk(0.1, 0.1, baselineSamples)], 200)
assert.equal(initial.peakLDb, 0) const burstSnapshot = meter.process([createFilledStereoChunk(1.0, 1.0, burstSamples)], 216)
assert.ok(initial.peakLDb > initial.rmsLDb + 10) 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.equal(beforeDecay.peakLDb, 0)
assert.ok(afterDecay.peakLDb < beforeDecay.peakLDb) assert.ok(afterDecay.peakLDb < beforeDecay.peakLDb)
assert.ok(afterDecay.peakLDb > -3) assert.ok(afterDecay.peakLDb > -3)