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 () => {