mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 12:04:18 +02:00
update vu needles
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user