mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
Improved VU needle mode
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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({
|
||||
<option value="needle">Needle</option>
|
||||
</SelectControl>
|
||||
|
||||
<SelectControl
|
||||
label="Orientation"
|
||||
value={current.orientation}
|
||||
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
|
||||
>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
<option value="vertical">Vertical</option>
|
||||
</SelectControl>
|
||||
{current.mode === 'bar' ? (
|
||||
<SelectControl
|
||||
label="Orientation"
|
||||
value={current.orientation}
|
||||
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
|
||||
>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
<option value="vertical">Vertical</option>
|
||||
</SelectControl>
|
||||
) : (
|
||||
<SelectControl
|
||||
label="Needles"
|
||||
value={current.needleChannels}
|
||||
onChange={(value) => onUpdate('vumeter', { needleChannels: value as ScopeSettings['vumeter']['needleChannels'] })}
|
||||
>
|
||||
<option value="stereo">Stereo</option>
|
||||
<option value="combined">Combined</option>
|
||||
</SelectControl>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<ScopeSettings['lufsmeter']> = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null
|
||||
? parsed.lufsmeter
|
||||
: {}
|
||||
const rawVUMeter: Partial<ScopeSettings['vumeter']> = typeof parsed.vumeter === 'object' && parsed.vumeter !== null
|
||||
? parsed.vumeter
|
||||
: {}
|
||||
const rawNowPlaying: Partial<ScopeSettings['nowPlaying']> = 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,
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
type SectionSchema<T extends object> = Record<string, Extract<keyof T, string>>
|
||||
@@ -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<ThemeVUMeterTokens>
|
||||
|
||||
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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user