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
// 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)
+33 -8
View File
@@ -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
+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 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)