From d614952a155da28222394bd14f74a8a7867e00cf Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Tue, 5 May 2026 19:21:02 -0400
Subject: [PATCH] Improved VU needle mode
---
src/renderer/components/ScopeModule.tsx | 4 +
.../components/ScopeSettingsSection.tsx | 31 +-
src/renderer/visualizers/VUMeter.ts | 752 +++++++++++++++---
src/shared/profileState.ts | 12 +-
src/shared/themeState.ts | 14 +
src/types/settings.ts | 5 +-
src/types/theme.ts | 6 +
src/types/vumeter.ts | 7 +
test/profile-library.test.ts | 23 +
test/renderer-helpers.test.ts | 84 ++
test/theme-library.test.ts | 50 ++
11 files changed, 877 insertions(+), 111 deletions(-)
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx
index d773a11..0331280 100644
--- a/src/renderer/components/ScopeModule.tsx
+++ b/src/renderer/components/ScopeModule.tsx
@@ -264,8 +264,12 @@ export function scopeSettingsToOptions(
clipColor: t.clip,
scaleColor: t.scale,
labelColor: t.labels,
+ needleLeftColor: t.needleLeft,
+ needleRightColor: t.needleRight,
+ needleCombinedColor: t.needleCombined,
mode: s.mode,
orientation: s.orientation,
+ needleChannels: s.needleChannels,
}
}
case 'lufsmeter': {
diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx
index 2e4da68..f200a46 100644
--- a/src/renderer/components/ScopeSettingsSection.tsx
+++ b/src/renderer/components/ScopeSettingsSection.tsx
@@ -74,7 +74,9 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
- return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
+ return scopeSettings.mode === 'needle'
+ ? `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.needleChannels.toUpperCase()}`
+ : `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return `${lufsReadoutLabel((settings as ScopeSettings['lufsmeter']).readout)} LUFS`
@@ -481,14 +483,25 @@ export default function ScopeSettingsSection({
- onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
- >
-
-
-
+ {current.mode === 'bar' ? (
+ onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
+ >
+
+
+
+ ) : (
+ onUpdate('vumeter', { needleChannels: value as ScopeSettings['vumeter']['needleChannels'] })}
+ >
+
+
+
+ )}
>
)
})()}
diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts
index 8c4a821..e17bc51 100644
--- a/src/renderer/visualizers/VUMeter.ts
+++ b/src/renderer/visualizers/VUMeter.ts
@@ -10,7 +10,9 @@ import {
type VUMeterSnapshot,
} from './vuMeterBallistics'
import {
+ DEFAULT_VU_METER_NEEDLE_CHANNELS,
DEFAULT_VU_METER_ORIENTATION,
+ type VUMeterNeedleChannels,
type VUMeterMode,
type VUMeterOrientation,
} from '../../types/vumeter'
@@ -29,6 +31,10 @@ export interface VUMeterOptions {
clipColor?: string
scaleColor?: string
labelColor?: string
+ needleLeftColor?: string
+ needleRightColor?: string
+ needleCombinedColor?: string
+ needleChannels?: VUMeterNeedleChannels
dataSource?: VUMeterDataSource
frameScheduler?: FrameScheduler
}
@@ -45,6 +51,10 @@ const defaultOptions: ResolvedVUMeterOptions = {
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,
}
const defaultVUMeterDataSource: VUMeterDataSource = {
@@ -52,6 +62,21 @@ const defaultVUMeterDataSource: VUMeterDataSource = {
...defaultVisualizerSessionSource,
}
+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})`
}
@@ -61,6 +86,163 @@ function alphaColor(color: string, alpha: number): string {
return colorWithAlpha(r, g, b, alpha)
}
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value))
+}
+
+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 scale = Math.min(1, safeWidth / targetWidth, safeHeight / targetHeight)
+ const width = targetWidth * scale
+ const height = targetHeight * scale
+
+ return {
+ x: Math.max(0, (safeWidth - width) / 2),
+ y: Math.max(0, 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): number {
+ if (!Number.isFinite(db)) {
+ return CLASSIC_VU_MIN
+ }
+ return clamp(db + 6, 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,
+}: {
+ leftDb: number
+ rightDb: number
+ leftPeakDb: number
+ rightPeakDb: number
+ needleChannels: VUMeterNeedleChannels
+}): VUNeedleReading[] {
+ if (needleChannels === 'combined') {
+ const db = stereoRmsDbAverage(leftDb, rightDb)
+ const peakDb = Math.max(leftPeakDb, rightPeakDb)
+ return [{
+ id: 'combined',
+ db,
+ peakDb,
+ vu: dbfsToClassicVu(db),
+ peakVu: dbfsToClassicVu(peakDb),
+ }]
+ }
+
+ return [
+ {
+ id: 'left',
+ db: leftDb,
+ peakDb: leftPeakDb,
+ vu: dbfsToClassicVu(leftDb),
+ peakVu: dbfsToClassicVu(leftPeakDb),
+ },
+ {
+ id: 'right',
+ db: rightDb,
+ peakDb: rightPeakDb,
+ vu: dbfsToClassicVu(rightDb),
+ peakVu: dbfsToClassicVu(rightPeakDb),
+ },
+ ]
+}
+
// ---- VU Meter class ----
export class VUMeter {
@@ -80,6 +262,11 @@ export class VUMeter {
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
@@ -111,6 +298,7 @@ export class VUMeter {
private resetMeters(): void {
this.meterBallistics.reinitialize(this.dataSource.getSampleRate())
this.applySnapshot(this.meterBallistics.getSnapshot())
+ this.resetNeedleVisuals()
this.invalidate()
}
@@ -152,7 +340,54 @@ export class VUMeter {
this.correlation = snapshot.correlation
}
- private processAudio(): void {
+ 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 = this.dataSource.getSampleRate()
if (Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100) {
this.meterBallistics.reinitialize(sampleRate)
@@ -164,7 +399,7 @@ export class VUMeter {
}
const chunks = this.dataSource.getPendingVUMeterSamples()
- this.applySnapshot(this.meterBallistics.process(chunks, performance.now()))
+ this.applySnapshot(this.meterBallistics.process(chunks, nowMs))
}
private dbToNormalized(db: number): number {
@@ -394,7 +629,11 @@ export class VUMeter {
private drawCorrelationBar(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number,
- cr: number, cg: number, cb: 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))
@@ -422,10 +661,10 @@ export class VUMeter {
}
// Labels
- const fontSize = Math.min(18, Math.max(8, h * 0.55))
+ 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, 0.6)
+ ctx.fillStyle = alphaColor(this.options.labelColor, labelAlpha)
ctx.textAlign = 'left'
ctx.fillText('-1', x + 2, y + h / 2)
ctx.textAlign = 'center'
@@ -437,120 +676,433 @@ export class VUMeter {
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
- // Layout: two meters side by side, correlation bar below
- const corrHeight = Math.max(1, Math.floor(height * 0.12))
- const gap = Math.max(2, Math.floor(height * 0.03))
- const meterAreaHeight = height - corrHeight - gap
- const meterWidth = Math.floor(width / 2) - 2
- const barLeft = Math.max(16, Math.floor(width * 0.06)) + 4
- const barRight = width - Math.max(36, Math.floor(width * 0.08)) - 4
- const barWidth = Math.max(1, barRight - barLeft)
+ ctx.save()
+ ctx.translate(faceLayout.x, faceLayout.y)
- // L needle
- 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.vuR, this.peakR, 'R', cr, cg, cb)
+ 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,
+ })
+ const readoutReadings = resolveVUNeedleReadings({
+ leftDb: this.vuL,
+ rightDb: this.vuR,
+ leftPeakDb: this.needlePeakL,
+ rightPeakDb: this.needlePeakR,
+ needleChannels: this.options.needleChannels,
+ })
- // Correlation bar at bottom
- this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb)
+ 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 drawNeedleMeter(
+ 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,
- x: number, y: number, w: number, h: number,
- rmsDb: number, peakDb: number,
- label: string,
- cr: number, cg: number, cb: number
+ centerX: number,
+ centerY: number,
+ outerRadiusX: number,
+ outerRadiusY: number,
+ innerRadiusX: number,
+ innerRadiusY: number,
+ startAngle: number,
+ endAngle: number,
+ readings: VUNeedleReading[],
+ outerWidth: number,
+ innerWidth: number,
): void {
- const centerX = x + w / 2
- const arcRadius = Math.min(w * 0.42, h * 0.65)
- const arcCenterY = y + h * 0.78
+ const hotAngle = this.vuToNeedleAngle(0, startAngle, endAngle)
+ const mainReading = readings[readings.length - 1]
+ const leftReading = readings.length > 1 ? readings[0] : null
+ if (!mainReading) return
- // Arc background (sweep from -135° to -45°, top half)
- const startAngle = Math.PI * 1.25 // 225° (bottom-left)
- const endAngle = Math.PI * 1.75 // 315° (bottom-right)
-
- // Scale arc
- ctx.strokeStyle = alphaColor(this.options.scaleColor, 0.66)
- ctx.lineWidth = 2
+ ctx.lineCap = 'butt'
+ ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.052)
+ ctx.lineWidth = outerWidth
ctx.beginPath()
- ctx.arc(centerX, arcCenterY, arcRadius, startAngle, endAngle)
+ 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()
- // Scale ticks
- const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0]
- for (const db of tickDbs) {
- const norm = this.dbToNormalized(db)
- const angle = startAngle + norm * (endAngle - startAngle)
- const innerR = arcRadius - 6
- const outerR = arcRadius + 2
-
- ctx.strokeStyle = db >= -6
- ? alphaColor(this.options.clipColor, 0.3)
- : alphaColor(this.options.scaleColor, 0.9)
- ctx.lineWidth = 1
+ if (leftReading) {
+ ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.04)
+ ctx.lineWidth = innerWidth
ctx.beginPath()
- ctx.moveTo(centerX + Math.cos(angle) * innerR, arcCenterY + Math.sin(angle) * innerR)
- ctx.lineTo(centerX + Math.cos(angle) * outerR, arcCenterY + Math.sin(angle) * outerR)
+ 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()
}
- // Needle
- const rmsNorm = this.dbToNormalized(rmsDb)
- const needleAngle = startAngle + rmsNorm * (endAngle - startAngle)
- const needleLength = arcRadius * 0.88
-
- ctx.strokeStyle = colorWithAlpha(cr, cg, cb, 0.9)
- ctx.lineWidth = 1.5
- ctx.lineCap = 'round'
- ctx.beginPath()
- ctx.moveTo(centerX, arcCenterY)
- ctx.lineTo(
- centerX + Math.cos(needleAngle) * needleLength,
- arcCenterY + Math.sin(needleAngle) * needleLength
- )
- ctx.stroke()
-
- // Needle pivot dot
- ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.7)
- ctx.beginPath()
- ctx.arc(centerX, arcCenterY, 2.5, 0, Math.PI * 2)
- ctx.fill()
-
- // Peak indicator (small dot on the arc)
- const peakNorm = this.dbToNormalized(peakDb)
- if (peakNorm > 0.001) {
- const peakAngle = startAngle + peakNorm * (endAngle - startAngle)
- const peakInHot = peakDb > -6
- ctx.fillStyle = peakInHot
- ? alphaColor(this.options.clipColor, 0.8)
- : alphaColor(this.options.peakColor, 0.8)
+ if (levelAngle > hotAngle) {
+ ctx.strokeStyle = alphaColor(this.options.clipColor, Math.min(1, alpha + 0.04))
+ ctx.lineWidth = lineWidth
+ ctx.lineCap = 'butt'
ctx.beginPath()
- ctx.arc(
- centerX + Math.cos(peakAngle) * arcRadius,
- arcCenterY + Math.sin(peakAngle) * arcRadius,
- 2.5, 0, Math.PI * 2
- )
- ctx.fill()
+ 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)
+ }
}
- // Channel label
- const fontSize = Math.min(22, Math.max(10, h * 0.1))
- ctx.fillStyle = alphaColor(this.options.labelColor, 0.9)
- ctx.font = `${fontSize}px "JetBrains Mono", monospace`
+ 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 = 'top'
- ctx.fillText(label, centerX, y + 4)
+ 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)
+ }
+ }
- // dB readout
- const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, rmsDb))
- const dbText = displayDb <= VU_METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB`
- ctx.fillStyle = alphaColor(this.options.labelColor, 0.7)
- ctx.font = `${Math.max(9, fontSize - 1)}px "JetBrains Mono", monospace`
- ctx.textAlign = 'center'
+ 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'
- ctx.fillText(dbText, centerX, y + h - 2)
+
+ 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 {
+ 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 => {
@@ -562,7 +1114,9 @@ export class VUMeter {
return
}
- this.processAudio()
+ const nowMs = performance.now()
+ this.processAudio(nowMs)
+ this.updateNeedleVisuals(nowMs)
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts
index a27f9f7..9e26839 100644
--- a/src/shared/profileState.ts
+++ b/src/shared/profileState.ts
@@ -16,6 +16,7 @@ import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } fr
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import { isLUFSMeterReadout } from '../types/lufsmeter'
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
+import { isVUMeterNeedleChannels } from '../types/vumeter'
import { clampWaveformScrollSpeed } from '../types/waveform'
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
@@ -142,6 +143,9 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
const rawLUFSMeter: Partial = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null
? parsed.lufsmeter
: {}
+ const rawVUMeter: Partial = typeof parsed.vumeter === 'object' && parsed.vumeter !== null
+ ? parsed.vumeter
+ : {}
const rawNowPlaying: Partial = typeof legacyParsed.nowPlaying === 'object' && legacyParsed.nowPlaying !== null
? legacyParsed.nowPlaying
: (typeof legacyParsed.astra === 'object' && legacyParsed.astra !== null ? legacyParsed.astra : {})
@@ -155,7 +159,13 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
- vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
+ vumeter: {
+ ...DEFAULT_SCOPE_SETTINGS.vumeter,
+ ...rawVUMeter,
+ needleChannels: isVUMeterNeedleChannels(rawVUMeter.needleChannels)
+ ? rawVUMeter.needleChannels
+ : DEFAULT_SCOPE_SETTINGS.vumeter.needleChannels,
+ },
lufsmeter: {
...DEFAULT_SCOPE_SETTINGS.lufsmeter,
...rawLUFSMeter,
diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts
index af2c094..dfa61ee 100644
--- a/src/shared/themeState.ts
+++ b/src/shared/themeState.ts
@@ -47,6 +47,9 @@ const DEFAULT_BAND_HIGH = '#4488ff'
const DEFAULT_HEAT_LOW = 'rgb(15, 7, 33)'
const DEFAULT_HEAT_MID = 'rgb(163, 26, 121)'
const DEFAULT_HEAT_HIGH = 'rgb(255, 241, 209)'
+const DEFAULT_VU_NEEDLE_LEFT = 'rgb(199, 223, 255)'
+const DEFAULT_VU_NEEDLE_RIGHT = 'rgb(255, 71, 126)'
+const DEFAULT_VU_NEEDLE_COMBINED = 'rgb(244, 248, 255)'
type SectionTokenMap = Partial>
type SectionSchema = Record>
@@ -138,6 +141,9 @@ const VUMETER_SCHEMA = {
clip: 'clip',
scale: 'scale',
labels: 'labels',
+ needle_left: 'needleLeft',
+ needle_right: 'needleRight',
+ needle_combined: 'needleCombined',
} as const satisfies SectionSchema
const LUFSMETER_SCHEMA = {
@@ -536,6 +542,9 @@ export function createDefaultTheme(): PrismTheme {
track: 'rgba(56, 189, 248, 0.08)',
peak: 'rgb(255, 127, 0)',
clip: 'rgba(255, 120, 80, 0.9)',
+ needleLeft: DEFAULT_VU_NEEDLE_LEFT,
+ needleRight: DEFAULT_VU_NEEDLE_RIGHT,
+ needleCombined: DEFAULT_VU_NEEDLE_COMBINED,
},
lufsmeter: {
level: DEFAULT_ACCENT,
@@ -1715,6 +1724,9 @@ function resolveVUMeterTheme(
clip: theme.vumeter.clip ?? 'rgba(255, 120, 80, 0.9)',
scale: theme.vumeter.scale ?? scopes.guides,
labels: theme.vumeter.labels ?? blendText(app.text, app.textMuted, 0.35),
+ needleLeft: theme.vumeter.needleLeft ?? level,
+ needleRight: theme.vumeter.needleRight ?? theme.vumeter.peak ?? 'rgb(255, 127, 0)',
+ needleCombined: theme.vumeter.needleCombined ?? level,
background: theme.vumeter.background ?? scopes.background,
}
}
@@ -1879,6 +1891,8 @@ export function createMigratedAccentTheme(accent: string): PrismTheme | null {
base.spectrogram.mono = base.app.accent
base.vumeter.level = base.app.accent
base.vumeter.track = withAlpha(base.app.accent, 0.08)
+ base.vumeter.needleLeft = base.app.accent
+ base.vumeter.needleCombined = base.app.accent
base.lufsmeter.level = base.app.accent
base.lufsmeter.track = withAlpha(base.app.accent, 0.08)
base.lufsmeter.target = withAlpha(base.app.accent, 0.25)
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 7d19d65..dadedd9 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -1,6 +1,6 @@
import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram'
-import type { VUMeterMode, VUMeterOrientation } from './vumeter'
+import type { VUMeterMode, VUMeterNeedleChannels, VUMeterOrientation } from './vumeter'
import { DEFAULT_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter'
import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform'
import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum'
@@ -41,6 +41,7 @@ export interface ScopeSettings {
vumeter: {
mode: VUMeterMode
orientation: VUMeterOrientation
+ needleChannels: VUMeterNeedleChannels
}
lufsmeter: {
mode: LUFSMeterMode
@@ -66,7 +67,7 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 },
vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 },
spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' },
- vumeter: { mode: 'bar', orientation: 'horizontal' },
+ vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo' },
lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT },
waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, multiband: false },
nowPlaying: {
diff --git a/src/types/theme.ts b/src/types/theme.ts
index e19e374..b487932 100644
--- a/src/types/theme.ts
+++ b/src/types/theme.ts
@@ -100,6 +100,9 @@ export interface ThemeVUMeterTokens {
clip?: string
scale?: string
labels?: string
+ needleLeft?: string
+ needleRight?: string
+ needleCombined?: string
}
export interface ThemeLUFSMeterTokens {
@@ -285,6 +288,9 @@ export interface ResolvedVUMeterTheme {
clip: string
scale: string
labels: string
+ needleLeft: string
+ needleRight: string
+ needleCombined: string
background: string
}
diff --git a/src/types/vumeter.ts b/src/types/vumeter.ts
index 3be54d2..a633fcb 100644
--- a/src/types/vumeter.ts
+++ b/src/types/vumeter.ts
@@ -1,11 +1,14 @@
export type VUMeterMode = 'needle' | 'bar'
export type VUMeterOrientation = 'horizontal' | 'vertical'
+export type VUMeterNeedleChannels = 'stereo' | 'combined'
export const VU_METER_MODES: readonly VUMeterMode[] = ['needle', 'bar']
export const VU_METER_ORIENTATIONS: readonly VUMeterOrientation[] = ['horizontal', 'vertical']
+export const VU_METER_NEEDLE_CHANNELS: readonly VUMeterNeedleChannels[] = ['stereo', 'combined']
export const DEFAULT_VU_METER_MODE: VUMeterMode = 'bar'
export const DEFAULT_VU_METER_ORIENTATION: VUMeterOrientation = 'horizontal'
+export const DEFAULT_VU_METER_NEEDLE_CHANNELS: VUMeterNeedleChannels = 'stereo'
export function isVUMeterMode(value: unknown): value is VUMeterMode {
return typeof value === 'string' && VU_METER_MODES.includes(value as VUMeterMode)
@@ -14,3 +17,7 @@ export function isVUMeterMode(value: unknown): value is VUMeterMode {
export function isVUMeterOrientation(value: unknown): value is VUMeterOrientation {
return typeof value === 'string' && VU_METER_ORIENTATIONS.includes(value as VUMeterOrientation)
}
+
+export function isVUMeterNeedleChannels(value: unknown): value is VUMeterNeedleChannels {
+ return typeof value === 'string' && VU_METER_NEEDLE_CHANNELS.includes(value as VUMeterNeedleChannels)
+}
diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts
index 69d399e..463ff05 100644
--- a/test/profile-library.test.ts
+++ b/test/profile-library.test.ts
@@ -7,6 +7,7 @@ import { FileBackedProfileLibrary } from '../src/main/profileLibrary'
import {
createDefaultProfile,
extractLocalProfileMetadata,
+ mergeScopeSettings,
normalizeScopeOrder,
profileFileToProfile,
profileToFileData,
@@ -77,6 +78,28 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(restored.scopeSettings.nowPlaying.showControls, true)
})
+test('mergeScopeSettings defaults missing or invalid VU needle channel settings to stereo', () => {
+ const combined = mergeScopeSettings({
+ vumeter: {
+ mode: 'needle',
+ orientation: 'horizontal',
+ needleChannels: 'combined',
+ },
+ })
+ const invalid = mergeScopeSettings({
+ vumeter: {
+ mode: 'needle',
+ orientation: 'horizontal',
+ needleChannels: 'mid',
+ },
+ })
+ const missing = mergeScopeSettings({})
+
+ assert.equal(combined.vumeter.needleChannels, 'combined')
+ assert.equal(invalid.vumeter.needleChannels, 'stereo')
+ assert.equal(missing.vumeter.needleChannels, 'stereo')
+})
+
test('library saves, renames, deletes, and resolves filename collisions', async () => {
const harness = await createHarness()
diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts
index 9637bd2..7ad8061 100644
--- a/test/renderer-helpers.test.ts
+++ b/test/renderer-helpers.test.ts
@@ -86,6 +86,15 @@ import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope'
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
+import {
+ VU_NEEDLE_FACE_HEIGHT_CSS_PX,
+ VU_NEEDLE_FACE_WIDTH_CSS_PX,
+ classicVuToNormalized,
+ dbfsToClassicVu,
+ resolveVUNeedleFaceLayout,
+ resolveVUNeedleReadings,
+ stereoRmsDbAverage,
+} from '../src/renderer/visualizers/VUMeter'
import {
drawVectorscopeGridForMode,
getVectorscopeLayout,
@@ -2216,9 +2225,13 @@ test('Vectorscope keeps the original linear projection behavior', () => {
test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.lufsmeter.readout = 'shortTerm'
+ profile.scopeSettings.vumeter.needleChannels = 'combined'
const authoredTheme = createDefaultTheme()
authoredTheme.scopes.background = 'rgb(6, 7, 8)'
authoredTheme.vumeter.track = 'rgb(9, 10, 11)'
+ authoredTheme.vumeter.needleLeft = 'rgb(20, 21, 22)'
+ authoredTheme.vumeter.needleRight = 'rgb(23, 24, 25)'
+ authoredTheme.vumeter.needleCombined = 'rgb(26, 27, 28)'
authoredTheme.lufsmeter.track = 'rgb(12, 13, 14)'
authoredTheme.lufsmeter.target = 'rgb(15, 16, 17)'
const theme = resolveTheme(authoredTheme)
@@ -2231,6 +2244,10 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe
assert.equal(vumeter.trackColor, 'rgb(9, 10, 11)')
assert.equal(vumeter.scaleColor, theme.vumeter.scale)
assert.equal(vumeter.labelColor, theme.vumeter.labels)
+ assert.equal(vumeter.needleLeftColor, 'rgb(20, 21, 22)')
+ assert.equal(vumeter.needleRightColor, 'rgb(23, 24, 25)')
+ assert.equal(vumeter.needleCombinedColor, 'rgb(26, 27, 28)')
+ assert.equal(vumeter.needleChannels, 'combined')
const lufsmeter = scopeSettingsToOptions('lufsmeter', profile.scopeSettings.lufsmeter, theme.lufsmeter)
assert.equal(lufsmeter.backgroundColor, 'rgb(6, 7, 8)')
@@ -2241,6 +2258,73 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe
assert.equal(lufsmeter.readout, 'shortTerm')
})
+test('VUMeter shared needle helpers map dBFS to a classic VU face', () => {
+ assert.equal(dbfsToClassicVu(-6), 0)
+ assert.equal(dbfsToClassicVu(0), 3)
+ assert.equal(dbfsToClassicVu(-40), -20)
+ assertAlmostEqual(classicVuToNormalized(-20), 0, 1e-12, '-20 VU should start the scale')
+ assertAlmostEqual(classicVuToNormalized(0), 0.81, 1e-12, '0 VU should sit near the hot zone')
+ assertAlmostEqual(classicVuToNormalized(3), 1, 1e-12, '+3 VU should end the scale')
+})
+
+test('VUMeter needle face layout stays fixed instead of scaling up', () => {
+ const wide = resolveVUNeedleFaceLayout(1200, 500)
+ assert.equal(wide.width, VU_NEEDLE_FACE_WIDTH_CSS_PX)
+ assert.equal(wide.height, VU_NEEDLE_FACE_HEIGHT_CSS_PX)
+ assertAlmostEqual(wide.x, (1200 - VU_NEEDLE_FACE_WIDTH_CSS_PX) / 2, 1e-12, 'fixed face should center in extra width')
+ assertAlmostEqual(wide.y, 500 - VU_NEEDLE_FACE_HEIGHT_CSS_PX, 1e-12, 'fixed face should bottom-align in extra height')
+ assert.equal(wide.scale, 1)
+
+ const retinaWide = resolveVUNeedleFaceLayout(2400, 1000, 2)
+ assert.equal(retinaWide.width, VU_NEEDLE_FACE_WIDTH_CSS_PX * 2)
+ assert.equal(retinaWide.height, VU_NEEDLE_FACE_HEIGHT_CSS_PX * 2)
+ assert.equal(retinaWide.scale, 1)
+
+ const small = resolveVUNeedleFaceLayout(280, 180)
+ assert.equal(small.width, 280)
+ assert.equal(small.height, 180)
+ assertAlmostEqual(small.scale, 0.5, 1e-12, 'fixed face should only shrink when the canvas is smaller')
+})
+
+test('VUMeter shared needle helpers switch between stereo needles and combined RMS', () => {
+ const stereo = resolveVUNeedleReadings({
+ leftDb: -6,
+ rightDb: -12,
+ leftPeakDb: -3,
+ rightPeakDb: -9,
+ needleChannels: 'stereo',
+ })
+ const combined = resolveVUNeedleReadings({
+ leftDb: -6,
+ rightDb: -12,
+ leftPeakDb: -3,
+ rightPeakDb: -9,
+ needleChannels: 'combined',
+ })
+ const expectedCombinedDb = stereoRmsDbAverage(-6, -12)
+
+ assert.equal(stereo.length, 2)
+ assert.equal(stereo[0]?.id, 'left')
+ assert.equal(stereo[1]?.id, 'right')
+ assert.equal(combined.length, 1)
+ assert.equal(combined[0]?.id, 'combined')
+ assertAlmostEqual(combined[0]?.db ?? 0, expectedCombinedDb, 1e-12, 'combined needle should average stereo RMS power')
+ assert.equal(combined[0]?.peakDb, -3)
+ assertAlmostEqual(combined[0]?.vu ?? 0, dbfsToClassicVu(expectedCombinedDb), 1e-12, 'combined needle should use classic VU mapping')
+})
+
+test('scopeSummary reports VU needle channel mode', () => {
+ const profile = createDefaultProfile('Default')
+
+ assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'BAR · HORIZONTAL')
+
+ profile.scopeSettings.vumeter.mode = 'needle'
+ assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · STEREO')
+
+ profile.scopeSettings.vumeter.needleChannels = 'combined'
+ assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED')
+})
+
test('scopeSummary includes only waveform display modes', () => {
const profile = createDefaultProfile('Default')
diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts
index a2edf46..7b49607 100644
--- a/test/theme-library.test.ts
+++ b/test/theme-library.test.ts
@@ -64,6 +64,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
theme.scopes.background = '#030712'
theme.spectrum.heatMid = 'rgb(200, 50, 120)'
theme.vumeter.track = '#111827'
+ theme.vumeter.needleLeft = 'rgb(70, 80, 90)'
const serialized = serializeThemeFile(theme)
const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_NAME)
@@ -74,6 +75,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.match(serialized, /\[Controls\]/)
assert.match(serialized, /\[Scopes\]/)
assert.match(serialized, /flat_controls = true/)
+ assert.match(serialized, /needle_left = 70, 80, 90/)
assert.doesNotMatch(serialized, /^id = /m)
assert.doesNotMatch(serialized, /^name = /m)
@@ -84,6 +86,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)')
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)')
+ assert.equal(parsed.vumeter.needleLeft, 'rgb(70, 80, 90)')
assert.equal(parsed.nowPlaying.background, theme.nowPlaying.background)
})
@@ -172,6 +175,44 @@ heat_high = 71, 81, 91, 255
assert.equal(defaultResolved.spectrum.heatBase, 'transparent')
})
+test('VUMeter needle color tokens parse and fall back to existing VU colors', () => {
+ const parsed = parseThemeFileContent(`
+[Theme]
+format = prism-theme
+version = 2
+
+[VUMeter]
+level = 10, 20, 30
+peak = 40, 50, 60
+needle_left = 70, 80, 90
+needle_right = 100, 110, 120
+needle_combined = 130, 140, 150
+`, 'Needles')
+ const fallback = parseThemeFileContent(`
+[Theme]
+format = prism-theme
+version = 2
+
+[VUMeter]
+level = 10, 20, 30
+peak = 40, 50, 60
+`, 'Needle Fallback')
+
+ assert.equal(parsed.vumeter.needleLeft, 'rgb(70, 80, 90)')
+ assert.equal(parsed.vumeter.needleRight, 'rgb(100, 110, 120)')
+ assert.equal(parsed.vumeter.needleCombined, 'rgb(130, 140, 150)')
+
+ const resolved = resolveTheme(parsed)
+ assert.equal(resolved.vumeter.needleLeft, 'rgb(70, 80, 90)')
+ assert.equal(resolved.vumeter.needleRight, 'rgb(100, 110, 120)')
+ assert.equal(resolved.vumeter.needleCombined, 'rgb(130, 140, 150)')
+
+ const resolvedFallback = resolveTheme(fallback)
+ assert.equal(resolvedFallback.vumeter.needleLeft, 'rgb(10, 20, 30)')
+ assert.equal(resolvedFallback.vumeter.needleRight, 'rgb(40, 50, 60)')
+ assert.equal(resolvedFallback.vumeter.needleCombined, 'rgb(10, 20, 30)')
+})
+
test('parseThemeFileContent derives the theme name from the filename stem and ignores legacy header names', () => {
const parsed = parseThemeFileContent(`
[Theme]
@@ -343,6 +384,12 @@ test('bundled tester themes preserve authored tokens and migrated accent themes
'Stanky Leg',
])
+ const defaultTheme = bundledThemes.find((theme) => theme.name === DEFAULT_THEME_NAME)
+ assert.ok(defaultTheme)
+ assert.equal(defaultTheme.vumeter.needleLeft, 'rgb(199, 223, 255)')
+ assert.equal(defaultTheme.vumeter.needleRight, 'rgb(255, 71, 126)')
+ assert.equal(defaultTheme.vumeter.needleCombined, 'rgb(244, 248, 255)')
+
const alphaCentauri = bundledThemes.find((theme) => theme.name === 'Alpha Centauri')
assert.ok(alphaCentauri)
assert.equal(alphaCentauri.credit, 'MxnGxzr')
@@ -350,6 +397,7 @@ test('bundled tester themes preserve authored tokens and migrated accent themes
assert.equal(alphaCentauri.description, 'It exists')
assert.equal(alphaCentauri.app.accent, 'rgb(0, 50, 220)')
assert.equal(alphaCentauri.spectrogram.background, 'rgba(255, 255, 255, 0)')
+ assert.equal(alphaCentauri.vumeter.needleLeft, undefined)
const chromaGreen = bundledThemes.find((theme) => theme.name === 'Chroma Green')
assert.ok(chromaGreen)
@@ -365,6 +413,8 @@ test('bundled tester themes preserve authored tokens and migrated accent themes
assert.ok(migrated)
assert.equal(migrated.oscilloscope.line, migrated.app.accent)
assert.equal(migrated.vectorscope.trace, migrated.app.accent)
+ assert.equal(migrated.vumeter.needleLeft, migrated.app.accent)
+ assert.equal(migrated.vumeter.needleCombined, migrated.app.accent)
})
test('library seeds default themes and template file', async () => {