diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index e69d163..7b2cb3d 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -1,5 +1,8 @@ import { StrictMode, type JSX } from 'react' 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 './styles.css' import ScopeApp from './ScopeApp' diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index d210b30..60e3760 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -9,6 +9,8 @@ import '@fontsource/inter/400.css' import '@fontsource/inter/500.css' import '@fontsource/inter/600.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 { bootstrapWindowBackgroundFromQuery } from './windowBackground' diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index 899f166..70ab830 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -83,8 +83,8 @@ const INITIAL_NATIVE_SNAPSHOT: VUMeterNativeSnapshot = { 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 +const NEEDLE_PIVOT_FRACTION = 0.885 +const NEEDLE_STEREO_GAP_FRACTION = 0.12 export const VU_NEEDLE_FACE_WIDTH_CSS_PX = 560 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' }, ] +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 }> = [ { vu: -20, position: 0 }, { vu: -10, position: 0.24 }, @@ -759,6 +767,7 @@ export class VUMeter { labelAlpha = 0.6, minLabelSize = 8, maxLabelSize = 20, + outlineAlpha = 0, ): void { const centerX = x + w / 2 const corr = Math.max(-1, Math.min(1, this.correlation)) @@ -796,6 +805,12 @@ export class VUMeter { ctx.fillText('Ø', centerX, y + h / 2) ctx.textAlign = 'right' 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 { @@ -817,19 +832,32 @@ export class VUMeter { 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 labelFontSize = 26 * faceScale + const outerArcWidth = 18 * faceScale + const innerArcWidth = 13.5 * faceScale + const needleWidth = 3 * 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 labelOffset = outerArcWidth / 2 + tickOutward + labelFontSize * 0.68 + let radiusXByWidth = Number.POSITIVE_INFINITY + for (const { vu, label } of CLASSIC_VU_LABELS) { + 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 - outerArcWidth / 2 - tickOutward @@ -869,14 +897,14 @@ export class VUMeter { outerArcWidth, 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.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 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() } @@ -918,30 +946,63 @@ export class VUMeter { if (!mainReading) return ctx.lineCap = 'butt' - ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.052) + ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.075) ctx.lineWidth = outerWidth ctx.beginPath() ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, startAngle, hotAngle) ctx.stroke() - ctx.strokeStyle = alphaColor(this.options.clipColor, 0.085) + ctx.strokeStyle = alphaColor(this.options.clipColor, 0.15) ctx.beginPath() ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, hotAngle, endAngle) ctx.stroke() if (leftReading) { - ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.04) + ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.06) ctx.lineWidth = innerWidth ctx.beginPath() ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, startAngle, hotAngle) ctx.stroke() - ctx.strokeStyle = alphaColor(this.options.clipColor, 0.065) + ctx.strokeStyle = alphaColor(this.options.clipColor, 0.12) 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, 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( @@ -990,8 +1051,6 @@ export class VUMeter { startAngle: number, endAngle: number, labelFontSize: number, - width: number, - sidePadding: number, tickOutward: number, ): void { const averageRadius = (radiusX + radiusY) / 2 @@ -1002,14 +1061,16 @@ export class VUMeter { 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 isMajor = isClassicVuMajorLabel(vu) + const shouldShowLabel = shouldShowClassicVuLabel(vu) 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) + ? alphaColor(this.options.clipColor, 1) + : alphaColor(this.options.scaleColor, isMajor ? 0.96 : 0.82) + 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 tickEnd = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 - inward) ctx.beginPath() @@ -1018,46 +1079,22 @@ export class VUMeter { 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 + const labelPoint = this.needleEllipsePoint(centerX, centerY, radiusX, radiusY, angle, outerWidth / 2 + tickOutward + labelFontSize * 0.68) 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` + ? alphaColor(this.options.clipColor, 1) + : alphaColor(this.options.labelColor, 1) + ctx.font = `${isMajor ? 700 : 600} ${isMajor ? labelFontSize : labelFontSize * 0.92}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText(label, x, y) - } - } - - const percentFontSize = labelFontSize * 0.62 - ctx.font = `${percentFontSize}px "JetBrains Mono", monospace` - ctx.fillStyle = alphaColor(this.options.labelColor, 0.48) - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - const percentMarks = [ - { vu: -20, label: '0' }, - { vu: -10, label: '20' }, - { vu: -5, label: '40' }, - { vu: -3, label: '60' }, - { vu: -1, label: '80' }, - { vu: 0, label: '100' }, - ] - for (const { vu, label } of percentMarks) { - const angle = this.vuToNeedleAngle(vu, startAngle, endAngle) - const x = centerX + Math.cos(angle) * (radiusX * 0.55) - const y = centerY + Math.sin(angle) * (radiusY * 0.55) - if (y < centerY - 4) { - ctx.fillText(label, x, y) + ctx.fillText(label, labelPoint.x, labelPoint.y) } } const titleFontSize = Math.min(labelFontSize * 0.88, averageRadius * 0.095) const titleY = centerY - radiusY * 0.14 if (titleY > 4) { - ctx.fillStyle = alphaColor(this.options.labelColor, 0.48) - ctx.font = `${titleFontSize}px "JetBrains Mono", monospace` + ctx.fillStyle = alphaColor(this.options.labelColor, 0.66) + ctx.font = `700 ${titleFontSize}px "JetBrains Mono", monospace` ctx.fillText('VU', centerX, titleY) } } @@ -1132,13 +1169,11 @@ export class VUMeter { 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) + this.drawSharedNeedle(ctx, leftReading, centerX, centerY, radiusX, radiusY, startAngle, endAngle, 0.62, needleWidth * 0.76) } 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) + this.drawNeedleHub(ctx, centerX, centerY, averageRadius, mainReading) } } @@ -1162,6 +1197,14 @@ export class VUMeter { const baseX = centerX - Math.cos(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.lineWidth = lineWidth ctx.lineCap = 'round' @@ -1176,37 +1219,129 @@ export class VUMeter { 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( ctx: CanvasRenderingContext2D, readings: VUNeedleReading[], meterAreaHeight: number, width: number, sidePadding: number, - labelFontSize: number, + faceScale: 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' + const plateHeight = 31 * faceScale + const plateY = meterAreaHeight - plateHeight - faceScale if (!leftReading) { - ctx.fillStyle = alphaColor(this.getNeedleColor(mainReading), 0.94) - ctx.textAlign = 'center' - ctx.fillText(`${this.formatNeedleDb(mainReading.db)} dB`, width / 2, y) + const plateWidth = Math.min(170 * faceScale, width - sidePadding * 2) + this.drawNeedleReadoutPlate( + ctx, + mainReading, + null, + (width - plateWidth) / 2, + plateY, + plateWidth, + plateHeight, + faceScale, + ) return } - ctx.fillStyle = alphaColor(this.getNeedleColor(leftReading), 0.88) - ctx.textAlign = 'left' - ctx.fillText(`L ${this.formatNeedleDb(leftReading.db)} dB`, sidePadding, y) + const centerGap = 52 * faceScale + const plateWidth = Math.min( + 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.fillText(`${this.formatNeedleDb(mainReading.db)} dB R`, width - sidePadding, y) + ctx.fillText('dB', x + width - padding, centerY) } private formatNeedleDb(db: number): string { diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 3a10e22..163e3c9 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -609,6 +609,7 @@ interface FakeCanvasRecorder { lineStrokes: Array<{ commands: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }> strokeStyle: string + lineWidth: number }> strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }> arcs: Array<{ @@ -647,6 +648,7 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca let currentLineDash: number[] = [] let currentFillStyle = '' let currentStrokeStyle = '' + let currentLineWidth = 1 let currentFont = '' let currentCompositeOperation: GlobalCompositeOperation = 'source-over' let currentPath: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }> = [] @@ -674,7 +676,11 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca }, stroke() { 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) { @@ -683,6 +689,7 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise = false) { recorder?.arcs.push({ x, y, radius, startAngle, endAngle, anticlockwise, lineDash: [...currentLineDash] }) }, + ellipse() {}, drawImage(...args: unknown[]) { recorder?.drawImageCalls.push({ compositeOperation: currentCompositeOperation, args }) }, @@ -710,7 +717,12 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 12 return { width: String(text).length * fontSize * 0.62 } as TextMetrics }, - lineWidth: 1, + get lineWidth() { + return currentLineWidth + }, + set lineWidth(value: number) { + currentLineWidth = value + }, get font() { return currentFont }, @@ -3805,6 +3817,108 @@ test('VUMeter needle face layout stays fixed instead of scaling up', () => { 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', () => { const stereo = resolveVUNeedleReadings({ leftDb: -6, diff --git a/vite.plugin-ui.config.ts b/vite.plugin-ui.config.ts index d5a7dff..343316f 100644 --- a/vite.plugin-ui.config.ts +++ b/vite.plugin-ui.config.ts @@ -72,6 +72,9 @@ export default defineConfig({ build: { outDir: resolve(__dirname, 'plugin/webview-dist'), 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 // deterministically and CMake's embedded BinaryData symbols stay stable. rollupOptions: {