import { audioRouter } from '../audio/AudioRouter' import { vumeter as nativeVUMeter, type VUMeterNativeAnalyzer, type VUMeterNativeSnapshot, } from '../audio/native' import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' import { VUMeterBallistics, VU_METER_MAX_DB, VU_METER_MIN_DB, type VUMeterSnapshot, } from './vuMeterBallistics' import { DEFAULT_VU_METER_NEEDLE_CHANNELS, DEFAULT_VU_METER_ORIENTATION, DEFAULT_VU_REFERENCE_DBFS, type VUMeterNeedleChannels, type VUMeterMode, type VUMeterOrientation, } from '../../types/vumeter' export interface VUMeterDataSource extends VisualizerSessionSource { getPendingVUMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }> } export interface VUMeterOptions { mode?: VUMeterMode orientation?: VUMeterOrientation backgroundColor?: string lineColor?: string trackColor?: string peakColor?: string clipColor?: string scaleColor?: string labelColor?: string needleLeftColor?: string needleRightColor?: string needleCombinedColor?: string needleChannels?: VUMeterNeedleChannels referenceDb?: number dataSource?: VUMeterDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: VUMeterNativeAnalyzer | null } type ResolvedVUMeterOptions = Required> const defaultOptions: ResolvedVUMeterOptions = { mode: 'bar', orientation: DEFAULT_VU_METER_ORIENTATION, backgroundColor: 'transparent', lineColor: '#38bdf8', trackColor: 'rgba(56, 189, 248, 0.08)', peakColor: 'rgb(255, 127, 0)', clipColor: 'rgba(255, 120, 80, 0.9)', scaleColor: 'rgba(255, 255, 255, 0.12)', labelColor: 'rgba(255, 255, 255, 0.5)', needleLeftColor: '#c7dfff', needleRightColor: '#ff477e', needleCombinedColor: '#f4f8ff', needleChannels: DEFAULT_VU_METER_NEEDLE_CHANNELS, referenceDb: DEFAULT_VU_REFERENCE_DBFS, } const defaultVUMeterDataSource: VUMeterDataSource = { getPendingVUMeterSamples: () => audioRouter.flushPendingVUMeterSamples(), ...defaultVisualizerSessionSource, } const INITIAL_NATIVE_SNAPSHOT: VUMeterNativeSnapshot = { 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, } const NEEDLE_VISUAL_SMOOTHING_SECONDS = 0.065 const NEEDLE_PEAK_DECAY_DB_PER_SECOND = 12 const NEEDLE_PIVOT_FRACTION = 0.91 const NEEDLE_STEREO_GAP_FRACTION = 0.14 export const VU_NEEDLE_FACE_WIDTH_CSS_PX = 560 export const VU_NEEDLE_FACE_HEIGHT_CSS_PX = 360 export interface VUNeedleFaceLayout { x: number y: number width: number height: number scale: number } function colorWithAlpha(r: number, g: number, b: number, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})` } function alphaColor(color: string, alpha: number): string { const { r, g, b } = resolveColorToRgb(color) return colorWithAlpha(r, g, b, alpha) } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } function finiteNumber(value: number, fallback: number): number { return Number.isFinite(value) ? value : fallback } function normalizeNativeSnapshot(snapshot: VUMeterNativeSnapshot | null): VUMeterNativeSnapshot { if (!snapshot) { return { ...INITIAL_NATIVE_SNAPSHOT } } return { vuLDb: finiteNumber(snapshot.vuLDb, VU_METER_MIN_DB), vuRDb: finiteNumber(snapshot.vuRDb, VU_METER_MIN_DB), barLDb: finiteNumber(snapshot.barLDb, VU_METER_MIN_DB), barRDb: finiteNumber(snapshot.barRDb, VU_METER_MIN_DB), peakLDb: finiteNumber(snapshot.peakLDb, VU_METER_MIN_DB), peakRDb: finiteNumber(snapshot.peakRDb, VU_METER_MIN_DB), correlation: finiteNumber(snapshot.correlation, 0), } } function normalizeDevicePixelRatio(devicePixelRatio: number): number { return Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1 } function getCanvasDevicePixelRatio(): number { return normalizeDevicePixelRatio( typeof window === 'undefined' ? 1 : window.devicePixelRatio, ) } export function resolveVUNeedleFaceLayout( canvasWidth: number, canvasHeight: number, devicePixelRatio = 1, ): VUNeedleFaceLayout { const dpr = normalizeDevicePixelRatio(devicePixelRatio) const targetWidth = VU_NEEDLE_FACE_WIDTH_CSS_PX * dpr const targetHeight = VU_NEEDLE_FACE_HEIGHT_CSS_PX * dpr const safeWidth = Math.max(0, Number.isFinite(canvasWidth) ? canvasWidth : 0) const safeHeight = Math.max(0, Number.isFinite(canvasHeight) ? canvasHeight : 0) const widthScale = safeWidth / targetWidth const heightScale = safeHeight / targetHeight const scale = Math.min(1, widthScale, heightScale) const width = targetWidth * scale const height = targetHeight * scale const widthLimited = widthScale < Math.min(1, heightScale) return { x: Math.max(0, (safeWidth - width) / 2), y: Math.max(0, widthLimited ? (safeHeight - height) / 2 : safeHeight - height), width, height, scale, } } export const CLASSIC_VU_MIN = -20 export const CLASSIC_VU_MAX = 3 export const CLASSIC_VU_LABELS: ReadonlyArray<{ vu: number; label: string }> = [ { vu: -20, label: '20' }, { vu: -10, label: '10' }, { vu: -7, label: '7' }, { vu: -5, label: '5' }, { vu: -4, label: '4' }, { vu: -3, label: '3' }, { vu: -2, label: '2' }, { vu: -1, label: '1' }, { vu: 0, label: '0' }, { vu: 1, label: '+1' }, { vu: 2, label: '+2' }, { vu: 3, label: '+3' }, ] const CLASSIC_VU_ANCHORS: ReadonlyArray<{ vu: number; position: number }> = [ { vu: -20, position: 0 }, { vu: -10, position: 0.24 }, { vu: -7, position: 0.36 }, { vu: -5, position: 0.46 }, { vu: -4, position: 0.53 }, { vu: -3, position: 0.60 }, { vu: -2, position: 0.67 }, { vu: -1, position: 0.74 }, { vu: 0, position: 0.81 }, { vu: 1, position: 0.88 }, { vu: 2, position: 0.94 }, { vu: 3, position: 1 }, ] export interface VUNeedleReading { id: 'left' | 'right' | 'combined' db: number peakDb: number vu: number peakVu: number } export function dbfsToClassicVu(db: number, referenceDb: number = DEFAULT_VU_REFERENCE_DBFS): number { if (!Number.isFinite(db)) { return CLASSIC_VU_MIN } return clamp(db - referenceDb, CLASSIC_VU_MIN, CLASSIC_VU_MAX) } export function classicVuToNormalized(vu: number): number { const clampedVu = clamp( Number.isFinite(vu) ? vu : CLASSIC_VU_MIN, CLASSIC_VU_MIN, CLASSIC_VU_MAX, ) for (let index = 1; index < CLASSIC_VU_ANCHORS.length; index += 1) { const previous = CLASSIC_VU_ANCHORS[index - 1] const next = CLASSIC_VU_ANCHORS[index] if (!previous || !next || clampedVu > next.vu) continue const span = next.vu - previous.vu const progress = span > 0 ? (clampedVu - previous.vu) / span : 0 return previous.position + progress * (next.position - previous.position) } return 1 } export function stereoRmsDbAverage(leftDb: number, rightDb: number): number { const leftPower = 10 ** (clamp(leftDb, VU_METER_MIN_DB, VU_METER_MAX_DB) / 10) const rightPower = 10 ** (clamp(rightDb, VU_METER_MIN_DB, VU_METER_MAX_DB) / 10) const averagePower = (leftPower + rightPower) / 2 const db = 10 * Math.log10(Math.max(averagePower, 1e-12)) return clamp(db, VU_METER_MIN_DB, VU_METER_MAX_DB) } export function resolveVUNeedleReadings({ leftDb, rightDb, leftPeakDb, rightPeakDb, needleChannels, referenceDb = DEFAULT_VU_REFERENCE_DBFS, }: { leftDb: number rightDb: number leftPeakDb: number rightPeakDb: number needleChannels: VUMeterNeedleChannels referenceDb?: number }): VUNeedleReading[] { if (needleChannels === 'combined') { const db = stereoRmsDbAverage(leftDb, rightDb) const peakDb = Math.max(leftPeakDb, rightPeakDb) return [{ id: 'combined', db, peakDb, vu: dbfsToClassicVu(db, referenceDb), peakVu: dbfsToClassicVu(peakDb, referenceDb), }] } return [ { id: 'left', db: leftDb, peakDb: leftPeakDb, vu: dbfsToClassicVu(leftDb, referenceDb), peakVu: dbfsToClassicVu(leftPeakDb, referenceDb), }, { id: 'right', db: rightDb, peakDb: rightPeakDb, vu: dbfsToClassicVu(rightDb, referenceDb), peakVu: dbfsToClassicVu(rightPeakDb, referenceDb), }, ] } // ---- VU Meter class ---- export class VUMeter { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private options: ResolvedVUMeterOptions private dataSource: VUMeterDataSource private nativeAnalyzer: VUMeterNativeAnalyzer | null private frameLoop: VisualizerFrameLoop private meterBallistics: VUMeterBallistics private currentSampleRate = 0 private pushScratchL = new Float32Array(0) private pushScratchR = new Float32Array(0) private unsubscribeSessionChange: (() => void) | null = null // Meter state private vuL = VU_METER_MIN_DB private vuR = VU_METER_MIN_DB private peakL = VU_METER_MIN_DB private peakR = VU_METER_MIN_DB private correlation = 0 private needleDisplayL = VU_METER_MIN_DB private needleDisplayR = VU_METER_MIN_DB private needlePeakL = VU_METER_MIN_DB private needlePeakR = VU_METER_MIN_DB private lastNeedleVisualUpdateMs: number | null = null constructor(canvas: HTMLCanvasElement, options: VUMeterOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultVUMeterDataSource this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVUMeter : nativeAnalyzer this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate()) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), onFrame: this.drawFrame, }) this.resetMeters() this.subscribeToSessionChanges() } private subscribeToSessionChanges(): void { if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetMeters() }) } private resetMeters(): void { this.currentSampleRate = Math.max(1, this.dataSource.getSampleRate()) this.meterBallistics.reinitialize(this.currentSampleRate) if (this.isNativeAnalyzerReady()) { this.nativeAnalyzer?.setSampleRate(this.currentSampleRate) this.nativeAnalyzer?.reset() } this.applySnapshot(this.meterBallistics.getSnapshot()) this.resetNeedleVisuals() this.invalidate() } setOptions(options: Partial): void { const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } let didReset = false if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { this.nativeAnalyzer = nativeAnalyzer this.resetMeters() didReset = true } if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() this.resetMeters() didReset = true } if (!didReset) { this.invalidate() } } start(): void { this.frameLoop.start() } stop(): void { this.frameLoop.stop() } invalidate(): void { this.frameLoop.invalidate() } resize(): void { // Canvas resize handled externally this.invalidate() } private applySnapshot(snapshot: VUMeterSnapshot): void { this.vuL = snapshot.vuLDb this.vuR = snapshot.vuRDb this.peakL = snapshot.peakLDb this.peakR = snapshot.peakRDb this.correlation = snapshot.correlation } private resetNeedleVisuals(): void { this.needleDisplayL = this.vuL this.needleDisplayR = this.vuR this.needlePeakL = this.vuL this.needlePeakR = this.vuR this.lastNeedleVisualUpdateMs = null } private updateNeedleVisuals(nowMs: number): void { if (!Number.isFinite(nowMs)) { this.needleDisplayL = this.vuL this.needleDisplayR = this.vuR this.needlePeakL = this.vuL this.needlePeakR = this.vuR this.lastNeedleVisualUpdateMs = null return } if (this.lastNeedleVisualUpdateMs === null) { this.needleDisplayL = this.vuL this.needleDisplayR = this.vuR this.needlePeakL = this.vuL this.needlePeakR = this.vuR this.lastNeedleVisualUpdateMs = nowMs return } const elapsedSeconds = clamp((nowMs - this.lastNeedleVisualUpdateMs) / 1000, 0, 0.1) this.lastNeedleVisualUpdateMs = nowMs const amount = 1 - Math.exp(-elapsedSeconds / NEEDLE_VISUAL_SMOOTHING_SECONDS) this.needleDisplayL += (this.vuL - this.needleDisplayL) * amount this.needleDisplayR += (this.vuR - this.needleDisplayR) * amount this.needlePeakL = this.updateNeedlePeakVisual(this.needlePeakL, this.needleDisplayL, elapsedSeconds) this.needlePeakR = this.updateNeedlePeakVisual(this.needlePeakR, this.needleDisplayR, elapsedSeconds) } private updateNeedlePeakVisual(currentPeakDb: number, currentNeedleDb: number, elapsedSeconds: number): number { if (currentNeedleDb >= currentPeakDb) { return currentNeedleDb } return Math.max( currentNeedleDb, currentPeakDb - elapsedSeconds * NEEDLE_PEAK_DECAY_DB_PER_SECOND, ) } private processAudio(nowMs = performance.now()): void { const sampleRate = Math.max(1, this.dataSource.getSampleRate()) if ( Math.abs(sampleRate - this.currentSampleRate) > 100 || Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100 ) { this.resetMeters() } const chunks = this.dataSource.getPendingVUMeterSamples() if (!this.dataSource.isPlaying()) { const snapshot = this.isNativeAnalyzerReady() ? normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null) : this.meterBallistics.getSnapshot() this.applySnapshot(snapshot) return } if (this.isNativeAnalyzerReady()) { if (chunks.length > 0) { const batch = this.concatStereoChunks(chunks) if (batch.left.length > 0 && batch.right.length > 0) { this.nativeAnalyzer?.pushSamples(batch.left, batch.right) } } this.applySnapshot(normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null)) return } this.applySnapshot(this.meterBallistics.process(chunks, nowMs)) } private isNativeAnalyzerReady(): boolean { if (!this.nativeAnalyzer) { return false } return this.nativeAnalyzer.isAvailable?.() ?? true } private concatStereoChunks(chunks: Array<{ left: Float32Array; right: Float32Array }>): { left: Float32Array; right: Float32Array } { if (chunks.length === 1) { const chunk = chunks[0] const length = Math.min(chunk.left.length, chunk.right.length) return { left: chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length), right: chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length), } } let totalLength = 0 for (const chunk of chunks) { totalLength += Math.min(chunk.left.length, chunk.right.length) } if (totalLength === 0) { return { left: new Float32Array(0), right: new Float32Array(0) } } if (this.pushScratchL.length < totalLength) { this.pushScratchL = new Float32Array(totalLength) this.pushScratchR = new Float32Array(totalLength) } const left = this.pushScratchL.subarray(0, totalLength) const right = this.pushScratchR.subarray(0, totalLength) let offset = 0 for (const chunk of chunks) { const length = Math.min(chunk.left.length, chunk.right.length) if (length <= 0) { continue } left.set(chunk.left.subarray(0, length), offset) right.set(chunk.right.subarray(0, length), offset) offset += length } return { left, right } } private dbToNormalized(db: number): number { // Map dBFS to bar position via the VU calibration so bars and the needle // share the same scale (0 VU sits at the hot threshold, +3 VU at full scale). return classicVuToNormalized(dbfsToClassicVu(db, this.options.referenceDb)) } private drawBarMode(width: number, height: number): void { if (this.options.orientation === 'vertical') { this.drawVerticalBarMode(width, height) return } this.drawHorizontalBarMode(width, height) } private drawHorizontalBarMode(width: number, height: number): void { const ctx = this.ctx const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) const meterHeight = Math.max(1, Math.floor(height * 0.28)) const corrHeight = Math.max(1, Math.floor(height * 0.16)) const gap = Math.max(2, Math.floor(height * 0.04)) const labelWidth = Math.max(24, Math.floor(width * 0.07)) const dbLabelWidth = Math.max(52, Math.floor(width * 0.1)) const barLeft = labelWidth + 4 const barRight = width - dbLabelWidth - 4 const barWidth = Math.max(1, barRight - barLeft) // Total content height const totalHeight = meterHeight * 2 + corrHeight + gap * 2 const topOffset = Math.max(0, Math.floor((height - totalHeight) / 2)) // ---- L meter ---- const lY = topOffset this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.vuL, this.peakL, cr, cg, cb) this.drawMeterLabel(ctx, 0, lY, labelWidth, meterHeight, 'L') this.drawDbLabel(ctx, barRight + 4, lY, dbLabelWidth, meterHeight, this.vuL) // ---- R meter ---- const rY = lY + meterHeight + gap this.drawHorizontalMeterBar(ctx, barLeft, rY, barWidth, meterHeight, this.vuR, this.peakR, cr, cg, cb) this.drawMeterLabel(ctx, 0, rY, labelWidth, meterHeight, 'R') this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.vuR) // ---- Correlation meter ---- const corrY = rY + meterHeight + gap this.drawCorrelationBar(ctx, barLeft, corrY, barWidth, corrHeight, cr, cg, cb) } private drawVerticalBarMode(width: number, height: number): void { const ctx = this.ctx const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) const sidePadding = Math.max(4, Math.floor(width * 0.08)) const channelGap = Math.max(4, Math.floor(width * 0.08)) const labelHeight = Math.max(14, Math.floor(height * 0.08)) const dbHeight = Math.max(14, Math.floor(height * 0.1)) const corrHeight = Math.max(10, Math.floor(height * 0.11)) const gapY = Math.max(4, Math.floor(height * 0.03)) const maxMeterWidth = Math.max(4, Math.floor((width - channelGap) / 2)) const availableMeterWidth = Math.max(8, width - sidePadding * 2 - channelGap) const meterWidth = Math.min(Math.max(6, Math.floor(availableMeterWidth / 2)), maxMeterWidth) const totalMeterWidth = meterWidth * 2 + channelGap const meterLeft = Math.max(0, Math.floor((width - totalMeterWidth) / 2)) const meterTop = gapY + labelHeight const meterHeight = Math.max(1, height - labelHeight - dbHeight - corrHeight - gapY * 4) const dbY = meterTop + meterHeight + gapY const corrY = dbY + dbHeight + gapY const corrX = Math.max(4, Math.floor(width * 0.06)) const corrWidth = Math.max(1, width - corrX * 2) const lX = meterLeft const rX = meterLeft + meterWidth + channelGap this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L') this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.vuL, this.peakL, cr, cg, cb) this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.vuL) this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R') this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.vuR, this.peakR, cr, cg, cb) this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.vuR) this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb) } private drawHorizontalMeterBar( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, levelDb: number, peakDb: number, cr: number, cg: number, cb: number ): void { const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) const levelWidth = levelNorm * w const hotThreshold = classicVuToNormalized(0) * w // Background track ctx.fillStyle = this.options.trackColor ctx.fillRect(x, y, w, h) // 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 (levelWidth > hotThreshold) { // Hot zone: transition to warm/red 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)) const hotB = Math.round(cb * (1 - hotProgress * 0.7)) ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82) ctx.fillRect(x + hotThreshold, y, hotWidth, h) } } // Peak indicator line if (peakNorm > 0.001) { const peakX = x + peakNorm * w const peakInHot = dbfsToClassicVu(peakDb, this.options.referenceDb) > 0 ctx.fillStyle = peakInHot ? this.options.clipColor : this.options.peakColor ctx.fillRect(peakX - 1, y, 2, h) } // Scale ticks (VU units) ctx.fillStyle = this.options.scaleColor const tickVus = [-20, -10, -5, -3, -1, 0, 1, 2, 3] for (const vu of tickVus) { const tickX = x + classicVuToNormalized(vu) * w ctx.fillRect(tickX, y + h - 3, 1, 3) } } private drawVerticalMeterBar( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, levelDb: number, peakDb: number, cr: number, cg: number, cb: number ): void { const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) const levelHeight = levelNorm * h const hotThreshold = classicVuToNormalized(0) * h ctx.fillStyle = this.options.trackColor ctx.fillRect(x, y, w, h) 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 (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 - levelHeight, w, hotHeight) } } if (peakNorm > 0.001) { const peakY = y + h - peakNorm * h const peakInHot = dbfsToClassicVu(peakDb, this.options.referenceDb) > 0 ctx.fillStyle = peakInHot ? this.options.clipColor : this.options.peakColor ctx.fillRect(x, peakY - 1, w, 2) } ctx.fillStyle = alphaColor(this.options.scaleColor, 0.84) const tickVus = [-20, -10, -5, -3, -1, 0, 1, 2, 3] for (const vu of tickVus) { const tickY = y + h - classicVuToNormalized(vu) * h ctx.fillRect(x, tickY, w, 1) } } private drawMeterLabel( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, label: string ): void { ctx.fillStyle = this.options.labelColor ctx.font = `${Math.min(22, Math.max(10, h * 0.65))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(label, x + w / 2, y + h / 2) } private drawDbLabel( ctx: CanvasRenderingContext2D, x: number, y: number, _w: number, h: number, db: number ): void { 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` ctx.textAlign = 'left' ctx.textBaseline = 'middle' ctx.fillText(text, x, y + h / 2) } private drawCenteredDbLabel( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, db: number ): void { 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` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(text, x + w / 2, y + h / 2) } private drawCorrelationBar( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, cr: number, cg: number, cb: number, labelScale = 0.55, labelAlpha = 0.6, minLabelSize = 8, maxLabelSize = 20, ): void { const centerX = x + w / 2 const corr = Math.max(-1, Math.min(1, this.correlation)) // Background track ctx.fillStyle = this.options.trackColor ctx.fillRect(x, y, w, h) // Center line ctx.fillStyle = this.options.scaleColor ctx.fillRect(centerX - 0.5, y, 1, h) // Correlation indicator const indicatorWidth = Math.abs(corr) * (w / 2) if (indicatorWidth > 0.5) { if (corr >= 0) { // Positive correlation: draw rightward from center (good) ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.6) ctx.fillRect(centerX, y, indicatorWidth, h) } else { // Negative correlation: draw leftward from center (out of phase) ctx.fillStyle = alphaColor(this.options.clipColor, 0.6) ctx.fillRect(centerX - indicatorWidth, y, indicatorWidth, h) } } // Labels const fontSize = Math.min(maxLabelSize, Math.max(minLabelSize, h * labelScale)) ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textBaseline = 'middle' ctx.fillStyle = alphaColor(this.options.labelColor, labelAlpha) ctx.textAlign = 'left' ctx.fillText('-1', x + 2, y + h / 2) ctx.textAlign = 'center' ctx.fillText('Ø', centerX, y + h / 2) ctx.textAlign = 'right' ctx.fillText('+1', x + w - 2, y + h / 2) } private drawNeedleMode(width: number, height: number): void { const ctx = this.ctx const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) const devicePixelRatio = getCanvasDevicePixelRatio() const faceLayout = resolveVUNeedleFaceLayout(width, height, devicePixelRatio) const faceScale = faceLayout.scale * devicePixelRatio const faceWidth = faceLayout.width const faceHeight = faceLayout.height ctx.save() ctx.translate(faceLayout.x, faceLayout.y) const startAngle = Math.PI * 1.08 const endAngle = Math.PI * 1.92 const spanCos = Math.abs(Math.cos(startAngle)) const corrHeight = 19 * faceScale const gap = 4 * faceScale const meterAreaHeight = Math.max(1, faceHeight - corrHeight - gap) const sidePadding = 11 * faceScale const labelFontSize = 23.5 * faceScale const outerArcWidth = 22 * faceScale const innerArcWidth = 17 * faceScale const needleWidth = 3.25 * faceScale const pivotY = meterAreaHeight * NEEDLE_PIVOT_FRACTION const tickOutward = 5 * faceScale const topPadding = 10 * faceScale const horizontalLabelAllowance = labelFontSize * 0.32 const verticalLabelAllowance = labelFontSize * 0.62 const radiusXByWidth = (faceWidth / 2 - sidePadding) / spanCos - outerArcWidth / 2 - tickOutward - horizontalLabelAllowance const radiusYByHeight = pivotY - outerArcWidth / 2 - tickOutward - verticalLabelAllowance - topPadding const outerRadiusX = Math.max(34 * faceScale, radiusXByWidth) const outerRadiusY = Math.max(34 * faceScale, Math.min(radiusYByHeight, outerRadiusX * 0.92)) const innerRadiusX = this.options.needleChannels === 'stereo' ? outerRadiusX * (1 - NEEDLE_STEREO_GAP_FRACTION) : outerRadiusX const innerRadiusY = this.options.needleChannels === 'stereo' ? outerRadiusY * (1 - NEEDLE_STEREO_GAP_FRACTION) : outerRadiusY const centerX = faceWidth / 2 const centerY = Math.max(topPadding + outerRadiusY, pivotY) const visualReadings = resolveVUNeedleReadings({ leftDb: this.needleDisplayL, rightDb: this.needleDisplayR, leftPeakDb: this.needlePeakL, rightPeakDb: this.needlePeakR, needleChannels: this.options.needleChannels, referenceDb: this.options.referenceDb, }) const readoutReadings = visualReadings this.drawNeedleArcs( ctx, centerX, centerY, outerRadiusX, outerRadiusY, innerRadiusX, innerRadiusY, startAngle, endAngle, visualReadings, outerArcWidth, innerArcWidth, ) this.drawSharedNeedleScale(ctx, centerX, centerY, outerRadiusX, outerRadiusY, outerArcWidth, startAngle, endAngle, labelFontSize, faceWidth, sidePadding, tickOutward) this.drawNeedlePeakMarkers(ctx, centerX, centerY, outerRadiusX, outerRadiusY, innerRadiusX, innerRadiusY, startAngle, endAngle, readoutReadings, outerArcWidth, innerArcWidth) this.drawSharedNeedles(ctx, centerX, centerY, outerRadiusX, outerRadiusY, startAngle, endAngle, visualReadings, needleWidth) this.drawNeedleReadouts(ctx, readoutReadings, meterAreaHeight, faceWidth, sidePadding, labelFontSize) const barLeft = Math.max(10 * faceScale, sidePadding) const barWidth = Math.max(1, faceWidth - barLeft * 2) this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb, 0.72, 0.76, 8 * faceScale, 18 * faceScale) ctx.restore() } private vuToNeedleAngle(vu: number, startAngle: number, endAngle: number): number { return startAngle + classicVuToNormalized(vu) * (endAngle - startAngle) } private needleEllipsePoint( centerX: number, centerY: number, radiusX: number, radiusY: number, angle: number, offset = 0, ): { x: number; y: number } { return { x: centerX + Math.cos(angle) * (radiusX + offset), y: centerY + Math.sin(angle) * (radiusY + offset), } } private drawNeedleArcs( ctx: CanvasRenderingContext2D, centerX: number, centerY: number, outerRadiusX: number, outerRadiusY: number, innerRadiusX: number, innerRadiusY: number, startAngle: number, endAngle: number, readings: VUNeedleReading[], outerWidth: number, innerWidth: number, ): void { const hotAngle = this.vuToNeedleAngle(0, startAngle, endAngle) const mainReading = readings[readings.length - 1] const leftReading = readings.length > 1 ? readings[0] : null if (!mainReading) return ctx.lineCap = 'butt' ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.052) ctx.lineWidth = outerWidth ctx.beginPath() ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, startAngle, hotAngle) ctx.stroke() ctx.strokeStyle = alphaColor(this.options.clipColor, 0.085) ctx.beginPath() ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, hotAngle, endAngle) ctx.stroke() if (leftReading) { ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.04) ctx.lineWidth = innerWidth ctx.beginPath() ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, startAngle, hotAngle) ctx.stroke() ctx.strokeStyle = alphaColor(this.options.clipColor, 0.065) ctx.beginPath() ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, hotAngle, endAngle) ctx.stroke() this.drawNeedleLevelArc(ctx, leftReading, centerX, centerY, innerRadiusX, innerRadiusY, innerWidth, startAngle, hotAngle, endAngle, 0.72) } this.drawNeedleLevelArc(ctx, mainReading, centerX, centerY, outerRadiusX, outerRadiusY, outerWidth, startAngle, hotAngle, endAngle, 0.95) } private drawNeedleLevelArc( ctx: CanvasRenderingContext2D, reading: VUNeedleReading, centerX: number, centerY: number, radiusX: number, radiusY: number, lineWidth: number, startAngle: number, hotAngle: number, endAngle: number, alpha: number, ): void { const levelAngle = Math.min(endAngle, Math.max(startAngle, this.vuToNeedleAngle(reading.vu, startAngle, endAngle))) if (levelAngle <= startAngle) return const safeEnd = Math.min(levelAngle, hotAngle) if (safeEnd > startAngle) { ctx.strokeStyle = alphaColor(this.getNeedleColor(reading), alpha) ctx.lineWidth = lineWidth ctx.lineCap = 'butt' ctx.beginPath() ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, startAngle, safeEnd) ctx.stroke() } if (levelAngle > hotAngle) { ctx.strokeStyle = alphaColor(this.options.clipColor, Math.min(1, alpha + 0.04)) ctx.lineWidth = lineWidth ctx.lineCap = 'butt' ctx.beginPath() ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, hotAngle, levelAngle) ctx.stroke() } } private drawSharedNeedleScale( ctx: CanvasRenderingContext2D, centerX: number, centerY: number, radiusX: number, radiusY: number, outerWidth: number, startAngle: number, endAngle: number, labelFontSize: number, width: number, sidePadding: number, tickOutward: number, ): void { const averageRadius = (radiusX + radiusY) / 2 const majorInward = outerWidth + Math.max(5, averageRadius * 0.028) const minorInward = outerWidth * 0.65 ctx.lineCap = 'square' for (const { vu, label } of CLASSIC_VU_LABELS) { const angle = this.vuToNeedleAngle(vu, startAngle, endAngle) const isHot = vu >= 0 const isMajor = vu <= -10 || vu === 0 || vu > 0 const shouldShowLabel = isMajor || vu === -5 || vu === -3 || vu === -2 || vu === -1 const inward = isMajor ? majorInward : minorInward ctx.strokeStyle = isHot ? alphaColor(this.options.clipColor, 0.92) : alphaColor(this.options.scaleColor, 0.72) ctx.lineWidth = isMajor ? Math.max(2.25, averageRadius * 0.014) : Math.max(1.15, averageRadius * 0.008) const tickStart = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward) const tickEnd = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 - inward) ctx.beginPath() ctx.moveTo(tickStart.x, tickStart.y) ctx.lineTo(tickEnd.x, tickEnd.y) ctx.stroke() if (shouldShowLabel) { const labelPoint = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward + labelFontSize * 0.64) const x = clamp(labelPoint.x, sidePadding, width - sidePadding) const y = labelPoint.y ctx.fillStyle = isHot ? alphaColor(this.options.clipColor, 0.98) : alphaColor(this.options.labelColor, 0.9) ctx.font = `${isMajor ? '700 ' : ''}${isMajor ? labelFontSize : labelFontSize * 0.9}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(label, x, y) } } const percentFontSize = labelFontSize * 0.62 ctx.font = `${percentFontSize}px "JetBrains Mono", monospace` ctx.fillStyle = alphaColor(this.options.labelColor, 0.48) ctx.textAlign = 'center' ctx.textBaseline = 'middle' const percentMarks = [ { vu: -20, label: '0' }, { vu: -10, label: '20' }, { vu: -5, label: '40' }, { vu: -3, label: '60' }, { vu: -1, label: '80' }, { vu: 0, label: '100' }, ] for (const { vu, label } of percentMarks) { const angle = this.vuToNeedleAngle(vu, startAngle, endAngle) const x = centerX + Math.cos(angle) * (radiusX * 0.55) const y = centerY + Math.sin(angle) * (radiusY * 0.55) if (y < centerY - 4) { ctx.fillText(label, x, y) } } const titleFontSize = Math.min(labelFontSize * 0.88, averageRadius * 0.095) const titleY = centerY - radiusY * 0.14 if (titleY > 4) { ctx.fillStyle = alphaColor(this.options.labelColor, 0.48) ctx.font = `${titleFontSize}px "JetBrains Mono", monospace` ctx.fillText('VU', centerX, titleY) } } private drawNeedlePeakMarkers( ctx: CanvasRenderingContext2D, centerX: number, centerY: number, outerRadiusX: number, outerRadiusY: number, innerRadiusX: number, innerRadiusY: number, startAngle: number, endAngle: number, readings: VUNeedleReading[], outerWidth: number, innerWidth: number, ): void { const mainReading = readings[readings.length - 1] const leftReading = readings.length > 1 ? readings[0] : null if (leftReading) { this.drawNeedlePeakMarker(ctx, leftReading, centerX, centerY, innerRadiusX, innerRadiusY, innerWidth, startAngle, endAngle, 0.68) } if (mainReading) { this.drawNeedlePeakMarker(ctx, mainReading, centerX, centerY, outerRadiusX, outerRadiusY, outerWidth, startAngle, endAngle, 0.96) } } private drawNeedlePeakMarker( ctx: CanvasRenderingContext2D, reading: VUNeedleReading, centerX: number, centerY: number, radiusX: number, radiusY: number, arcWidth: number, startAngle: number, endAngle: number, alpha: number, ): void { if (reading.peakDb <= VU_METER_MIN_DB + 1) return const angle = this.vuToNeedleAngle(reading.peakVu, startAngle, endAngle) const isHot = reading.peakVu >= 0 const color = isHot ? this.options.clipColor : this.getNeedleColor(reading) const averageRadius = (radiusX + radiusY) / 2 const markerInset = Math.max(3, averageRadius * 0.016) const inner = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, -arcWidth / 2 - markerInset) const outer = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, arcWidth / 2 + markerInset) ctx.strokeStyle = alphaColor(color, alpha) ctx.lineWidth = Math.max(2.2, averageRadius * 0.011) ctx.lineCap = 'round' ctx.beginPath() ctx.moveTo(inner.x, inner.y) ctx.lineTo(outer.x, outer.y) ctx.stroke() } private drawSharedNeedles( ctx: CanvasRenderingContext2D, centerX: number, centerY: number, radiusX: number, radiusY: number, startAngle: number, endAngle: number, readings: VUNeedleReading[], needleWidth: number, ): void { const mainReading = readings[readings.length - 1] const leftReading = readings.length > 1 ? readings[0] : null const averageRadius = (radiusX + radiusY) / 2 if (leftReading) { this.drawSharedNeedle(ctx, leftReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.5, needleWidth * 0.72) } if (mainReading) { this.drawSharedNeedle(ctx, mainReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.96, needleWidth) const pivotSize = Math.max(2.3, averageRadius * 0.015) ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.65) ctx.fillRect(centerX - pivotSize, centerY - pivotSize, pivotSize * 2, pivotSize * 2) } } private drawSharedNeedle( ctx: CanvasRenderingContext2D, reading: VUNeedleReading, centerX: number, centerY: number, radiusX: number, radiusY: number, startAngle: number, endAngle: number, alpha: number, lineWidth: number, ): void { const angle = this.vuToNeedleAngle(reading.vu, startAngle, endAngle) const color = this.getNeedleColor(reading) const averageRadius = (radiusX + radiusY) / 2 const tip = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, -Math.max(3, averageRadius * 0.012)) const counterWeight = Math.max(5, averageRadius * 0.034) const baseX = centerX - Math.cos(angle) * counterWeight const baseY = centerY - Math.sin(angle) * counterWeight ctx.strokeStyle = alphaColor(color, alpha) ctx.lineWidth = lineWidth ctx.lineCap = 'round' ctx.beginPath() ctx.moveTo(baseX, baseY) ctx.lineTo(tip.x, tip.y) ctx.stroke() ctx.fillStyle = alphaColor(color, Math.min(1, alpha + 0.07)) ctx.beginPath() ctx.arc(tip.x, tip.y, Math.max(1.5, lineWidth * 0.75), 0, Math.PI * 2) ctx.fill() } private drawNeedleReadouts( ctx: CanvasRenderingContext2D, readings: VUNeedleReading[], meterAreaHeight: number, width: number, sidePadding: number, labelFontSize: number, ): void { const mainReading = readings[readings.length - 1] if (!mainReading) return const leftReading = readings.length > 1 ? readings[0] : null const fontSize = labelFontSize * 0.88 const y = meterAreaHeight - 3 ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textBaseline = 'bottom' if (!leftReading) { ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.94) ctx.textAlign = 'center' ctx.fillText(`${this.formatNeedleDb(mainReading.db)} dB`, width / 2, y) return } ctx.fillStyle = alphaColor(this.getNeedleColor(leftReading), 0.88) ctx.textAlign = 'left' ctx.fillText(`L ${this.formatNeedleDb(leftReading.db)} dB`, sidePadding, y) ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.96) ctx.textAlign = 'right' ctx.fillText(`${this.formatNeedleDb(mainReading.db)} dB R`, width - sidePadding, y) } 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) } private getNeedleColor(reading: VUNeedleReading): string { switch (reading.id) { case 'left': return this.options.needleLeftColor case 'right': return this.options.needleRightColor case 'combined': return this.options.needleCombinedColor } } private drawFrame = (): void => { const { canvas, ctx, options } = this const width = canvas.width const height = canvas.height if (width <= 0 || height <= 0) { return } const nowMs = performance.now() this.processAudio(nowMs) this.updateNeedleVisuals(nowMs) ctx.clearRect(0, 0, width, height) if (options.backgroundColor !== 'transparent') { ctx.fillStyle = options.backgroundColor ctx.fillRect(0, 0, width, height) } if (options.mode === 'needle') { this.drawNeedleMode(width, height) } else { this.drawBarMode(width, height) } } dispose(): void { this.stop() this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } if (this.isNativeAnalyzerReady()) { this.nativeAnalyzer?.reset() } } }