update vu needles

This commit is contained in:
Boof2015
2026-08-16 21:47:43 -04:00
parent 04f254512c
commit c73dac4fc1
5 changed files with 334 additions and 77 deletions
+3
View File
@@ -1,5 +1,8 @@
import { StrictMode, type JSX } from 'react' import { StrictMode, type JSX } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@fontsource/jetbrains-mono/latin-400.css'
import '@fontsource/jetbrains-mono/latin-600.css'
import '@fontsource/jetbrains-mono/latin-700.css'
import '../renderer/styles/globals.css' import '../renderer/styles/globals.css'
import './styles.css' import './styles.css'
import ScopeApp from './ScopeApp' import ScopeApp from './ScopeApp'
+2
View File
@@ -9,6 +9,8 @@ import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css' import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css' import '@fontsource/inter/600.css'
import '@fontsource/jetbrains-mono/400.css' import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/latin-600.css'
import '@fontsource/jetbrains-mono/latin-700.css'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope' import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { bootstrapWindowBackgroundFromQuery } from './windowBackground' import { bootstrapWindowBackgroundFromQuery } from './windowBackground'
+210 -75
View File
@@ -83,8 +83,8 @@ const INITIAL_NATIVE_SNAPSHOT: VUMeterNativeSnapshot = {
const NEEDLE_VISUAL_SMOOTHING_SECONDS = 0.065 const NEEDLE_VISUAL_SMOOTHING_SECONDS = 0.065
const NEEDLE_PEAK_DECAY_DB_PER_SECOND = 12 const NEEDLE_PEAK_DECAY_DB_PER_SECOND = 12
const NEEDLE_PIVOT_FRACTION = 0.91 const NEEDLE_PIVOT_FRACTION = 0.885
const NEEDLE_STEREO_GAP_FRACTION = 0.14 const NEEDLE_STEREO_GAP_FRACTION = 0.12
export const VU_NEEDLE_FACE_WIDTH_CSS_PX = 560 export const VU_NEEDLE_FACE_WIDTH_CSS_PX = 560
export const VU_NEEDLE_FACE_HEIGHT_CSS_PX = 360 export const VU_NEEDLE_FACE_HEIGHT_CSS_PX = 360
@@ -184,6 +184,14 @@ export const CLASSIC_VU_LABELS: ReadonlyArray<{ vu: number; label: string }> = [
{ vu: 3, label: '+3' }, { vu: 3, label: '+3' },
] ]
function isClassicVuMajorLabel(vu: number): boolean {
return vu <= -10 || vu === 0 || vu > 0
}
function shouldShowClassicVuLabel(vu: number): boolean {
return isClassicVuMajorLabel(vu) || vu === -5 || vu === -3 || vu === -1
}
const CLASSIC_VU_ANCHORS: ReadonlyArray<{ vu: number; position: number }> = [ const CLASSIC_VU_ANCHORS: ReadonlyArray<{ vu: number; position: number }> = [
{ vu: -20, position: 0 }, { vu: -20, position: 0 },
{ vu: -10, position: 0.24 }, { vu: -10, position: 0.24 },
@@ -759,6 +767,7 @@ export class VUMeter {
labelAlpha = 0.6, labelAlpha = 0.6,
minLabelSize = 8, minLabelSize = 8,
maxLabelSize = 20, maxLabelSize = 20,
outlineAlpha = 0,
): void { ): void {
const centerX = x + w / 2 const centerX = x + w / 2
const corr = Math.max(-1, Math.min(1, this.correlation)) const corr = Math.max(-1, Math.min(1, this.correlation))
@@ -796,6 +805,12 @@ export class VUMeter {
ctx.fillText('Ø', centerX, y + h / 2) ctx.fillText('Ø', centerX, y + h / 2)
ctx.textAlign = 'right' ctx.textAlign = 'right'
ctx.fillText('+1', x + w - 2, y + h / 2) ctx.fillText('+1', x + w - 2, y + h / 2)
if (outlineAlpha > 0 && w > 1 && h > 1) {
ctx.strokeStyle = alphaColor(this.options.scaleColor, outlineAlpha)
ctx.lineWidth = 1
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1)
}
} }
private drawNeedleMode(width: number, height: number): void { private drawNeedleMode(width: number, height: number): void {
@@ -817,19 +832,32 @@ export class VUMeter {
const gap = 4 * faceScale const gap = 4 * faceScale
const meterAreaHeight = Math.max(1, faceHeight - corrHeight - gap) const meterAreaHeight = Math.max(1, faceHeight - corrHeight - gap)
const sidePadding = 11 * faceScale const sidePadding = 11 * faceScale
const labelFontSize = 23.5 * faceScale const labelFontSize = 26 * faceScale
const outerArcWidth = 22 * faceScale const outerArcWidth = 18 * faceScale
const innerArcWidth = 17 * faceScale const innerArcWidth = 13.5 * faceScale
const needleWidth = 3.25 * faceScale const needleWidth = 3 * faceScale
const pivotY = meterAreaHeight * NEEDLE_PIVOT_FRACTION const pivotY = meterAreaHeight * NEEDLE_PIVOT_FRACTION
const tickOutward = 5 * faceScale const tickOutward = 5 * faceScale
const topPadding = 10 * faceScale const topPadding = 10 * faceScale
const horizontalLabelAllowance = labelFontSize * 0.32
const verticalLabelAllowance = labelFontSize * 0.62 const verticalLabelAllowance = labelFontSize * 0.62
const radiusXByWidth = (faceWidth / 2 - sidePadding) / spanCos const labelOffset = outerArcWidth / 2 + tickOutward + labelFontSize * 0.68
- outerArcWidth / 2 let radiusXByWidth = Number.POSITIVE_INFINITY
- tickOutward for (const { vu, label } of CLASSIC_VU_LABELS) {
- horizontalLabelAllowance if (!shouldShowClassicVuLabel(vu)) continue
const isMajor = isClassicVuMajorLabel(vu)
ctx.font = `${isMajor ? 700 : 600} ${isMajor ? labelFontSize : labelFontSize * 0.92}px "JetBrains Mono", monospace`
const halfLabelWidth = ctx.measureText(label).width / 2
const angle = this.vuToNeedleAngle(vu, startAngle, endAngle)
const horizontalDirection = Math.abs(Math.cos(angle))
if (horizontalDirection <= 1e-6) continue
radiusXByWidth = Math.min(
radiusXByWidth,
(faceWidth / 2 - sidePadding - halfLabelWidth) / horizontalDirection - labelOffset,
)
}
if (!Number.isFinite(radiusXByWidth)) {
radiusXByWidth = (faceWidth / 2 - sidePadding) / spanCos - labelOffset
}
const radiusYByHeight = pivotY const radiusYByHeight = pivotY
- outerArcWidth / 2 - outerArcWidth / 2
- tickOutward - tickOutward
@@ -869,14 +897,14 @@ export class VUMeter {
outerArcWidth, outerArcWidth,
innerArcWidth, innerArcWidth,
) )
this.drawSharedNeedleScale(ctx, centerX, centerY, outerRadiusX, outerRadiusY, outerArcWidth, startAngle, endAngle, labelFontSize, faceWidth, sidePadding, tickOutward) this.drawSharedNeedleScale(ctx, centerX, centerY, outerRadiusX, outerRadiusY, outerArcWidth, startAngle, endAngle, labelFontSize, tickOutward)
this.drawNeedlePeakMarkers(ctx, centerX, centerY, outerRadiusX, outerRadiusY, innerRadiusX, innerRadiusY, startAngle, endAngle, readoutReadings, outerArcWidth, innerArcWidth) 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.drawSharedNeedles(ctx, centerX, centerY, outerRadiusX, outerRadiusY, startAngle, endAngle, visualReadings, needleWidth)
this.drawNeedleReadouts(ctx, readoutReadings, meterAreaHeight, faceWidth, sidePadding, labelFontSize) this.drawNeedleReadouts(ctx, readoutReadings, meterAreaHeight, faceWidth, sidePadding, faceScale)
const barLeft = Math.max(10 * faceScale, sidePadding) const barLeft = Math.max(10 * faceScale, sidePadding)
const barWidth = Math.max(1, faceWidth - barLeft * 2) 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) this.drawCorrelationBar(ctx, barLeft, meterAreaHeight + gap, barWidth, corrHeight, cr, cg, cb, 0.72, 0.76, 8 * faceScale, 18 * faceScale, 0.24)
ctx.restore() ctx.restore()
} }
@@ -918,30 +946,63 @@ export class VUMeter {
if (!mainReading) return if (!mainReading) return
ctx.lineCap = 'butt' ctx.lineCap = 'butt'
ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.052) ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.075)
ctx.lineWidth = outerWidth ctx.lineWidth = outerWidth
ctx.beginPath() ctx.beginPath()
ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, startAngle, hotAngle) ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, startAngle, hotAngle)
ctx.stroke() ctx.stroke()
ctx.strokeStyle = alphaColor(this.options.clipColor, 0.085) ctx.strokeStyle = alphaColor(this.options.clipColor, 0.15)
ctx.beginPath() ctx.beginPath()
ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, hotAngle, endAngle) ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, hotAngle, endAngle)
ctx.stroke() ctx.stroke()
if (leftReading) { if (leftReading) {
ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.04) ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.06)
ctx.lineWidth = innerWidth ctx.lineWidth = innerWidth
ctx.beginPath() ctx.beginPath()
ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, startAngle, hotAngle) ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, startAngle, hotAngle)
ctx.stroke() ctx.stroke()
ctx.strokeStyle = alphaColor(this.options.clipColor, 0.065) ctx.strokeStyle = alphaColor(this.options.clipColor, 0.12)
ctx.beginPath() ctx.beginPath()
ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, hotAngle, endAngle) ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, hotAngle, endAngle)
ctx.stroke() ctx.stroke()
this.drawNeedleLevelArc(ctx, leftReading, centerX, centerY, innerRadiusX, innerRadiusY, innerWidth, startAngle, hotAngle, endAngle, 0.72) this.drawNeedleLevelArc(ctx, leftReading, centerX, centerY, innerRadiusX, innerRadiusY, innerWidth, startAngle, hotAngle, endAngle, 0.78)
} }
this.drawNeedleLevelArc(ctx, mainReading, centerX, centerY, outerRadiusX, outerRadiusY, outerWidth, startAngle, hotAngle, endAngle, 0.95) this.drawNeedleLevelArc(ctx, mainReading, centerX, centerY, outerRadiusX, outerRadiusY, outerWidth, startAngle, hotAngle, endAngle, 0.96)
if (leftReading) {
this.drawNeedleRailOutline(ctx, centerX, centerY, innerRadiusX, innerRadiusY, innerWidth, startAngle, endAngle, 0.14)
}
this.drawNeedleRailOutline(ctx, centerX, centerY, outerRadiusX, outerRadiusY, outerWidth, startAngle, endAngle, 0.18)
}
private drawNeedleRailOutline(
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
radiusX: number,
radiusY: number,
railWidth: number,
startAngle: number,
endAngle: number,
alpha: number,
): void {
ctx.strokeStyle = alphaColor(this.options.scaleColor, alpha)
ctx.lineWidth = Math.max(1, railWidth * 0.065)
ctx.lineCap = 'butt'
for (const offset of [-railWidth / 2, railWidth / 2]) {
ctx.beginPath()
ctx.ellipse(
centerX,
centerY,
Math.max(1, radiusX + offset),
Math.max(1, radiusY + offset),
0,
startAngle,
endAngle,
)
ctx.stroke()
}
} }
private drawNeedleLevelArc( private drawNeedleLevelArc(
@@ -990,8 +1051,6 @@ export class VUMeter {
startAngle: number, startAngle: number,
endAngle: number, endAngle: number,
labelFontSize: number, labelFontSize: number,
width: number,
sidePadding: number,
tickOutward: number, tickOutward: number,
): void { ): void {
const averageRadius = (radiusX + radiusY) / 2 const averageRadius = (radiusX + radiusY) / 2
@@ -1002,14 +1061,16 @@ export class VUMeter {
for (const { vu, label } of CLASSIC_VU_LABELS) { for (const { vu, label } of CLASSIC_VU_LABELS) {
const angle = this.vuToNeedleAngle(vu, startAngle, endAngle) const angle = this.vuToNeedleAngle(vu, startAngle, endAngle)
const isHot = vu >= 0 const isHot = vu >= 0
const isMajor = vu <= -10 || vu === 0 || vu > 0 const isMajor = isClassicVuMajorLabel(vu)
const shouldShowLabel = isMajor || vu === -5 || vu === -3 || vu === -2 || vu === -1 const shouldShowLabel = shouldShowClassicVuLabel(vu)
const inward = isMajor ? majorInward : minorInward const inward = isMajor ? majorInward : minorInward
ctx.strokeStyle = isHot ctx.strokeStyle = isHot
? alphaColor(this.options.clipColor, 0.92) ? alphaColor(this.options.clipColor, 1)
: alphaColor(this.options.scaleColor, 0.72) : alphaColor(this.options.scaleColor, isMajor ? 0.96 : 0.82)
ctx.lineWidth = isMajor ? Math.max(2.25, averageRadius * 0.014) : Math.max(1.15, averageRadius * 0.008) ctx.lineWidth = isMajor
? Math.max(2.6, averageRadius * 0.016)
: Math.max(1.5, averageRadius * 0.0095)
const tickStart = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward) const tickStart = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward)
const tickEnd = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 - inward) const tickEnd = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 - inward)
ctx.beginPath() ctx.beginPath()
@@ -1018,46 +1079,22 @@ export class VUMeter {
ctx.stroke() ctx.stroke()
if (shouldShowLabel) { if (shouldShowLabel) {
const labelPoint = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward + labelFontSize * 0.64) const labelPoint = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward + labelFontSize * 0.68)
const x = clamp(labelPoint.x, sidePadding, width - sidePadding)
const y = labelPoint.y
ctx.fillStyle = isHot ctx.fillStyle = isHot
? alphaColor(this.options.clipColor, 0.98) ? alphaColor(this.options.clipColor, 1)
: alphaColor(this.options.labelColor, 0.9) : alphaColor(this.options.labelColor, 1)
ctx.font = `${isMajor ? '700 ' : ''}${isMajor ? labelFontSize : labelFontSize * 0.9}px "JetBrains Mono", monospace` ctx.font = `${isMajor ? 700 : 600} ${isMajor ? labelFontSize : labelFontSize * 0.92}px "JetBrains Mono", monospace`
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
ctx.fillText(label, x, y) ctx.fillText(label, labelPoint.x, labelPoint.y)
}
}
const percentFontSize = labelFontSize * 0.62
ctx.font = `${percentFontSize}px "JetBrains Mono", monospace`
ctx.fillStyle = alphaColor(this.options.labelColor, 0.48)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const percentMarks = [
{ vu: -20, label: '0' },
{ vu: -10, label: '20' },
{ vu: -5, label: '40' },
{ vu: -3, label: '60' },
{ vu: -1, label: '80' },
{ vu: 0, label: '100' },
]
for (const { vu, label } of percentMarks) {
const angle = this.vuToNeedleAngle(vu, startAngle, endAngle)
const x = centerX + Math.cos(angle) * (radiusX * 0.55)
const y = centerY + Math.sin(angle) * (radiusY * 0.55)
if (y < centerY - 4) {
ctx.fillText(label, x, y)
} }
} }
const titleFontSize = Math.min(labelFontSize * 0.88, averageRadius * 0.095) const titleFontSize = Math.min(labelFontSize * 0.88, averageRadius * 0.095)
const titleY = centerY - radiusY * 0.14 const titleY = centerY - radiusY * 0.14
if (titleY > 4) { if (titleY > 4) {
ctx.fillStyle = alphaColor(this.options.labelColor, 0.48) ctx.fillStyle = alphaColor(this.options.labelColor, 0.66)
ctx.font = `${titleFontSize}px "JetBrains Mono", monospace` ctx.font = `700 ${titleFontSize}px "JetBrains Mono", monospace`
ctx.fillText('VU', centerX, titleY) ctx.fillText('VU', centerX, titleY)
} }
} }
@@ -1132,13 +1169,11 @@ export class VUMeter {
const leftReading = readings.length > 1 ? readings[0] : null const leftReading = readings.length > 1 ? readings[0] : null
const averageRadius = (radiusX + radiusY) / 2 const averageRadius = (radiusX + radiusY) / 2
if (leftReading) { if (leftReading) {
this.drawSharedNeedle(ctx, leftReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.5, needleWidth * 0.72) this.drawSharedNeedle(ctx, leftReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.62, needleWidth * 0.76)
} }
if (mainReading) { if (mainReading) {
this.drawSharedNeedle(ctx, mainReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.96, needleWidth) this.drawSharedNeedle(ctx, mainReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.96, needleWidth)
const pivotSize = Math.max(2.3, averageRadius * 0.015) this.drawNeedleHub(ctx, centerX, centerY, averageRadius, mainReading)
ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.65)
ctx.fillRect(centerX - pivotSize, centerY - pivotSize, pivotSize * 2, pivotSize * 2)
} }
} }
@@ -1162,6 +1197,14 @@ export class VUMeter {
const baseX = centerX - Math.cos(angle) * counterWeight const baseX = centerX - Math.cos(angle) * counterWeight
const baseY = centerY - Math.sin(angle) * counterWeight const baseY = centerY - Math.sin(angle) * counterWeight
ctx.strokeStyle = alphaColor(this.options.scaleColor, alpha * 0.32)
ctx.lineWidth = lineWidth + Math.max(1.25, averageRadius * 0.008)
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(baseX, baseY)
ctx.lineTo(tip.x, tip.y)
ctx.stroke()
ctx.strokeStyle = alphaColor(color, alpha) ctx.strokeStyle = alphaColor(color, alpha)
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.lineCap = 'round' ctx.lineCap = 'round'
@@ -1176,37 +1219,129 @@ export class VUMeter {
ctx.fill() ctx.fill()
} }
private drawNeedleHub(
ctx: CanvasRenderingContext2D,
centerX: number,
centerY: number,
averageRadius: number,
reading: VUNeedleReading,
): void {
const outerRadius = Math.max(2.5, averageRadius * 0.031)
ctx.fillStyle = alphaColor(this.options.labelColor, 0.12)
ctx.strokeStyle = alphaColor(this.options.scaleColor, 0.72)
ctx.lineWidth = Math.max(1, averageRadius * 0.007)
ctx.beginPath()
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = alphaColor(this.getNeedleColor(reading), 0.94)
ctx.beginPath()
ctx.arc(centerX, centerY, outerRadius * 0.42, 0, Math.PI * 2)
ctx.fill()
}
private drawNeedleReadouts( private drawNeedleReadouts(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
readings: VUNeedleReading[], readings: VUNeedleReading[],
meterAreaHeight: number, meterAreaHeight: number,
width: number, width: number,
sidePadding: number, sidePadding: number,
labelFontSize: number, faceScale: number,
): void { ): void {
const mainReading = readings[readings.length - 1] const mainReading = readings[readings.length - 1]
if (!mainReading) return if (!mainReading) return
const leftReading = readings.length > 1 ? readings[0] : null const leftReading = readings.length > 1 ? readings[0] : null
const fontSize = labelFontSize * 0.88 const plateHeight = 31 * faceScale
const y = meterAreaHeight - 3 const plateY = meterAreaHeight - plateHeight - faceScale
ctx.font = `${fontSize}px "JetBrains Mono", monospace`
ctx.textBaseline = 'bottom'
if (!leftReading) { if (!leftReading) {
ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.94) const plateWidth = Math.min(170 * faceScale, width - sidePadding * 2)
ctx.textAlign = 'center' this.drawNeedleReadoutPlate(
ctx.fillText(`${this.formatNeedleDb(mainReading.db)} dB`, width / 2, y) ctx,
mainReading,
null,
(width - plateWidth) / 2,
plateY,
plateWidth,
plateHeight,
faceScale,
)
return return
} }
ctx.fillStyle = alphaColor(this.getNeedleColor(leftReading), 0.88) const centerGap = 52 * faceScale
ctx.textAlign = 'left' const plateWidth = Math.min(
ctx.fillText(`L ${this.formatNeedleDb(leftReading.db)} dB`, sidePadding, y) 148 * faceScale,
Math.max(1, (width - sidePadding * 2 - centerGap) / 2),
)
this.drawNeedleReadoutPlate(
ctx,
leftReading,
'L',
sidePadding,
plateY,
plateWidth,
plateHeight,
faceScale,
)
this.drawNeedleReadoutPlate(
ctx,
mainReading,
'R',
width - sidePadding - plateWidth,
plateY,
plateWidth,
plateHeight,
faceScale,
)
}
ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.96) private drawNeedleReadoutPlate(
ctx: CanvasRenderingContext2D,
reading: VUNeedleReading,
channelLabel: 'L' | 'R' | null,
x: number,
y: number,
width: number,
height: number,
faceScale: number,
): void {
const channelColor = this.getNeedleColor(reading)
const padding = 8 * faceScale
const centerY = y + height / 2 + 0.5 * faceScale
ctx.fillStyle = alphaColor(this.options.labelColor, 0.055)
ctx.fillRect(x, y, width, height)
ctx.fillStyle = alphaColor(channelColor, 0.72)
ctx.fillRect(x, y, width, Math.max(1, 1.5 * faceScale))
ctx.strokeStyle = alphaColor(this.options.scaleColor, 0.24)
ctx.lineWidth = Math.max(1, faceScale)
ctx.strokeRect(x + 0.5 * faceScale, y + 0.5 * faceScale, width - faceScale, height - faceScale)
ctx.textBaseline = 'middle'
if (channelLabel) {
ctx.fillStyle = alphaColor(channelColor, 0.82)
ctx.font = `700 ${13.5 * faceScale}px "JetBrains Mono", monospace`
ctx.textAlign = 'left'
ctx.fillText(channelLabel, x + padding, centerY)
}
ctx.fillStyle = alphaColor(channelColor, 0.98)
ctx.font = `600 ${19.5 * faceScale}px "JetBrains Mono", monospace`
ctx.textAlign = channelLabel ? 'left' : 'center'
ctx.fillText(
this.formatNeedleDb(reading.db),
channelLabel ? x + padding + 24 * faceScale : x + width / 2 - 10 * faceScale,
centerY,
)
ctx.fillStyle = alphaColor(this.options.labelColor, 0.56)
ctx.font = `600 ${11.5 * faceScale}px "JetBrains Mono", monospace`
ctx.textAlign = 'right' ctx.textAlign = 'right'
ctx.fillText(`${this.formatNeedleDb(mainReading.db)} dB R`, width - sidePadding, y) ctx.fillText('dB', x + width - padding, centerY)
} }
private formatNeedleDb(db: number): string { private formatNeedleDb(db: number): string {
+116 -2
View File
@@ -609,6 +609,7 @@ interface FakeCanvasRecorder {
lineStrokes: Array<{ lineStrokes: Array<{
commands: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }> commands: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }>
strokeStyle: string strokeStyle: string
lineWidth: number
}> }>
strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }> strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }>
arcs: Array<{ arcs: Array<{
@@ -647,6 +648,7 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
let currentLineDash: number[] = [] let currentLineDash: number[] = []
let currentFillStyle = '' let currentFillStyle = ''
let currentStrokeStyle = '' let currentStrokeStyle = ''
let currentLineWidth = 1
let currentFont = '' let currentFont = ''
let currentCompositeOperation: GlobalCompositeOperation = 'source-over' let currentCompositeOperation: GlobalCompositeOperation = 'source-over'
let currentPath: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }> = [] let currentPath: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }> = []
@@ -674,7 +676,11 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
}, },
stroke() { stroke() {
if (currentPath.length > 0) { if (currentPath.length > 0) {
recorder?.lineStrokes.push({ commands: [...currentPath], strokeStyle: currentStrokeStyle }) recorder?.lineStrokes.push({
commands: [...currentPath],
strokeStyle: currentStrokeStyle,
lineWidth: currentLineWidth,
})
} }
}, },
strokeRect(x: number, y: number, width: number, height: number) { strokeRect(x: number, y: number, width: number, height: number) {
@@ -683,6 +689,7 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise = false) { arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise = false) {
recorder?.arcs.push({ x, y, radius, startAngle, endAngle, anticlockwise, lineDash: [...currentLineDash] }) recorder?.arcs.push({ x, y, radius, startAngle, endAngle, anticlockwise, lineDash: [...currentLineDash] })
}, },
ellipse() {},
drawImage(...args: unknown[]) { drawImage(...args: unknown[]) {
recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation, args }) recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation, args })
}, },
@@ -710,7 +717,12 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 12 const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 12
return { width: String(text).length * fontSize * 0.62 } as TextMetrics return { width: String(text).length * fontSize * 0.62 } as TextMetrics
}, },
lineWidth: 1, get lineWidth() {
return currentLineWidth
},
set lineWidth(value: number) {
currentLineWidth = value
},
get font() { get font() {
return currentFont return currentFont
}, },
@@ -3805,6 +3817,108 @@ test('VUMeter needle face layout stays fixed instead of scaling up', () => {
assert.equal(retinaNarrow.y, 180) assert.equal(retinaNarrow.y, 180)
}) })
test('VUMeter needle face renders a readable primary scale without redundant inner percentages', () => {
const dataSource = {
getPendingVUMeterSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => false,
subscribeToSessionChanges: () => () => {},
}
const renderNeedleFace = (
width: number,
height: number,
needleChannels: 'stereo' | 'combined',
): FakeCanvasRecorder => {
const recorder = createFakeCanvasRecorder()
const meter = new VUMeter(createFakeCanvas(recorder, width, height), {
mode: 'needle',
needleChannels,
dataSource,
nativeAnalyzer: null,
labelColor: 'rgb(180, 200, 220)',
scaleColor: 'rgb(90, 100, 110)',
clipColor: 'rgb(250, 80, 70)',
})
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
} finally {
meter.dispose()
}
return recorder
}
const full = renderNeedleFace(560, 360, 'stereo')
const fullColdEndpoint = full.fillTexts.find((text) => text.text === '20' && text.font.startsWith('700 '))
const fullMajor = full.fillTexts.find((text) => text.text === '+3')
const fullIntermediate = full.fillTexts.find((text) => text.text === '5')
const fullLeftLabel = full.fillTexts.find((text) => text.text === 'L')
const fullRightLabel = full.fillTexts.find((text) => text.text === 'R')
const fullReadoutValue = full.fillTexts.find((text) => text.text === '-∞')
const fullReadoutUnit = full.fillTexts.find((text) => text.text === 'dB')
assert.equal(fullMajor?.font, '700 26px "JetBrains Mono", monospace')
assert.equal(fullMajor?.fillStyle, 'rgba(250, 80, 70, 1)')
assert.equal(fullIntermediate?.font, '600 23.92px "JetBrains Mono", monospace')
assert.equal(fullIntermediate?.fillStyle, 'rgba(180, 200, 220, 1)')
assert.equal(fullLeftLabel?.font, '700 13.5px "JetBrains Mono", monospace')
assert.equal(fullRightLabel?.font, '700 13.5px "JetBrains Mono", monospace')
assert.equal(fullReadoutValue?.font, '600 19.5px "JetBrains Mono", monospace')
assert.equal(fullReadoutUnit?.font, '600 11.5px "JetBrains Mono", monospace')
assert.equal(full.fillTexts.some((text) => ['40', '60', '80', '100'].includes(text.text)), false)
assert.equal(full.fillTexts.some((text) => text.font.startsWith('500 ')), false)
assert.equal(
full.fillRects.filter((rect) => rect.fillStyle === 'rgba(180, 200, 220, 0.055)').length,
2,
)
assert.ok(full.arcs.some((arc) => arc.radius > 5))
const fullScaleTicks = full.lineStrokes.slice(0, 12)
const fullColdTickStart = fullScaleTicks[0]?.commands[0]
const fullHotTickStart = fullScaleTicks[11]?.commands[0]
assert.equal(fullColdTickStart?.kind, 'moveTo')
assert.equal(fullHotTickStart?.kind, 'moveTo')
assert.ok((fullColdEndpoint?.x ?? Number.POSITIVE_INFINITY) < (fullColdTickStart?.x ?? Number.NEGATIVE_INFINITY))
assert.ok((fullMajor?.x ?? Number.NEGATIVE_INFINITY) > (fullHotTickStart?.x ?? Number.POSITIVE_INFINITY))
const majorTick = full.lineStrokes.find((stroke) => stroke.strokeStyle === 'rgba(90, 100, 110, 0.96)')
const minorTick = full.lineStrokes.find((stroke) => stroke.strokeStyle === 'rgba(90, 100, 110, 0.82)')
assert.ok(majorTick)
assert.ok(minorTick)
assert.ok(majorTick.lineWidth > minorTick.lineWidth)
const compact = renderNeedleFace(280, 180, 'combined')
const compactColdEndpoint = compact.fillTexts.find((text) => text.text === '20' && text.font.startsWith('700 '))
const compactMajor = compact.fillTexts.find((text) => text.text === '+3')
const compactIntermediate = compact.fillTexts.find((text) => text.text === '5')
const compactReadoutValue = compact.fillTexts.find((text) => text.text === '-∞')
const compactReadoutUnit = compact.fillTexts.find((text) => text.text === 'dB')
const compactPrimaryLabels = compact.fillTexts.filter((text) => (
text.font === '700 13px "JetBrains Mono", monospace'
|| text.font === '600 11.96px "JetBrains Mono", monospace'
))
assert.equal(compactMajor?.font, '700 13px "JetBrains Mono", monospace')
assert.equal(compactIntermediate?.font, '600 11.96px "JetBrains Mono", monospace')
assert.equal(compactReadoutValue?.font, '600 9.75px "JetBrains Mono", monospace')
assert.equal(compactReadoutUnit?.font, '600 5.75px "JetBrains Mono", monospace')
assert.equal(compact.fillTexts.some((text) => ['40', '60', '80', '100'].includes(text.text)), false)
assert.equal(compactPrimaryLabels.length, 9)
for (const label of compactPrimaryLabels) {
const fontSize = Number(label.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const halfLabelWidth = label.text.length * fontSize * 0.62 / 2
assert.ok(label.x - halfLabelWidth >= 5.5)
assert.ok(label.x + halfLabelWidth <= 274.5)
assert.ok(label.y >= 0 && label.y <= 180)
}
const compactScaleTicks = compact.lineStrokes.slice(0, 12)
const compactColdTickStart = compactScaleTicks[0]?.commands[0]
const compactHotTickStart = compactScaleTicks[11]?.commands[0]
assert.ok((compactColdEndpoint?.x ?? Number.POSITIVE_INFINITY) < (compactColdTickStart?.x ?? Number.NEGATIVE_INFINITY))
assert.ok((compactMajor?.x ?? Number.NEGATIVE_INFINITY) > (compactHotTickStart?.x ?? Number.POSITIVE_INFINITY))
})
test('VUMeter shared needle helpers switch between stereo needles and combined RMS', () => { test('VUMeter shared needle helpers switch between stereo needles and combined RMS', () => {
const stereo = resolveVUNeedleReadings({ const stereo = resolveVUNeedleReadings({
leftDb: -6, leftDb: -6,
+3
View File
@@ -72,6 +72,9 @@ export default defineConfig({
build: { build: {
outDir: resolve(__dirname, 'plugin/webview-dist'), outDir: resolve(__dirname, 'plugin/webview-dist'),
emptyOutDir: true, emptyOutDir: true,
// The plugin ships as one embedded HTML resource. Keep the meter's Latin
// font files inside the inlined stylesheet so no runtime asset URLs remain.
assetsInlineLimit: 64 * 1024,
// Stable (unhashed) asset names so the C++ resource provider can map them // Stable (unhashed) asset names so the C++ resource provider can map them
// deterministically and CMake's embedded BinaryData symbols stay stable. // deterministically and CMake's embedded BinaryData symbols stay stable.
rollupOptions: { rollupOptions: {