mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-20 20:39:59 +02:00
1267 lines
43 KiB
TypeScript
1267 lines
43 KiB
TypeScript
import { audioRouter } from '../audio/AudioRouter'
|
|
import {
|
|
vumeter as nativeVUMeter,
|
|
type VUMeterNativeAnalyzer,
|
|
type VUMeterNativeSnapshot,
|
|
} from '../audio/native'
|
|
import { resolveColorToRgb } from '../utils/color'
|
|
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
|
import { FrameScheduler } from './frameScheduler'
|
|
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
|
import {
|
|
VUMeterBallistics,
|
|
VU_METER_MAX_DB,
|
|
VU_METER_MIN_DB,
|
|
type VUMeterSnapshot,
|
|
} from './vuMeterBallistics'
|
|
import {
|
|
DEFAULT_VU_METER_NEEDLE_CHANNELS,
|
|
DEFAULT_VU_METER_ORIENTATION,
|
|
DEFAULT_VU_REFERENCE_DBFS,
|
|
type VUMeterNeedleChannels,
|
|
type VUMeterMode,
|
|
type VUMeterOrientation,
|
|
} from '../../types/vumeter'
|
|
|
|
export interface VUMeterDataSource extends VisualizerSessionSource {
|
|
getPendingVUMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
|
|
}
|
|
|
|
export interface VUMeterOptions {
|
|
mode?: VUMeterMode
|
|
orientation?: VUMeterOrientation
|
|
backgroundColor?: string
|
|
lineColor?: string
|
|
trackColor?: string
|
|
peakColor?: string
|
|
clipColor?: string
|
|
scaleColor?: string
|
|
labelColor?: string
|
|
needleLeftColor?: string
|
|
needleRightColor?: string
|
|
needleCombinedColor?: string
|
|
needleChannels?: VUMeterNeedleChannels
|
|
referenceDb?: number
|
|
dataSource?: VUMeterDataSource
|
|
frameScheduler?: FrameScheduler
|
|
nativeAnalyzer?: VUMeterNativeAnalyzer | null
|
|
}
|
|
|
|
type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
|
|
|
|
const defaultOptions: ResolvedVUMeterOptions = {
|
|
mode: 'bar',
|
|
orientation: DEFAULT_VU_METER_ORIENTATION,
|
|
backgroundColor: 'transparent',
|
|
lineColor: '#38bdf8',
|
|
trackColor: 'rgba(56, 189, 248, 0.08)',
|
|
peakColor: 'rgb(255, 127, 0)',
|
|
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,
|
|
referenceDb: DEFAULT_VU_REFERENCE_DBFS,
|
|
}
|
|
|
|
const defaultVUMeterDataSource: VUMeterDataSource = {
|
|
getPendingVUMeterSamples: () => audioRouter.flushPendingVUMeterSamples(),
|
|
...defaultVisualizerSessionSource,
|
|
}
|
|
|
|
const INITIAL_NATIVE_SNAPSHOT: VUMeterNativeSnapshot = {
|
|
vuLDb: VU_METER_MIN_DB,
|
|
vuRDb: VU_METER_MIN_DB,
|
|
barLDb: VU_METER_MIN_DB,
|
|
barRDb: VU_METER_MIN_DB,
|
|
peakLDb: VU_METER_MIN_DB,
|
|
peakRDb: VU_METER_MIN_DB,
|
|
correlation: 0,
|
|
}
|
|
|
|
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})`
|
|
}
|
|
|
|
function alphaColor(color: string, alpha: number): string {
|
|
const { r, g, b } = resolveColorToRgb(color)
|
|
return colorWithAlpha(r, g, b, alpha)
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value))
|
|
}
|
|
|
|
function finiteNumber(value: number, fallback: number): number {
|
|
return Number.isFinite(value) ? value : fallback
|
|
}
|
|
|
|
function normalizeNativeSnapshot(snapshot: VUMeterNativeSnapshot | null): VUMeterNativeSnapshot {
|
|
if (!snapshot) {
|
|
return { ...INITIAL_NATIVE_SNAPSHOT }
|
|
}
|
|
|
|
return {
|
|
vuLDb: finiteNumber(snapshot.vuLDb, VU_METER_MIN_DB),
|
|
vuRDb: finiteNumber(snapshot.vuRDb, VU_METER_MIN_DB),
|
|
barLDb: finiteNumber(snapshot.barLDb, VU_METER_MIN_DB),
|
|
barRDb: finiteNumber(snapshot.barRDb, VU_METER_MIN_DB),
|
|
peakLDb: finiteNumber(snapshot.peakLDb, VU_METER_MIN_DB),
|
|
peakRDb: finiteNumber(snapshot.peakRDb, VU_METER_MIN_DB),
|
|
correlation: finiteNumber(snapshot.correlation, 0),
|
|
}
|
|
}
|
|
|
|
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 widthScale = safeWidth / targetWidth
|
|
const heightScale = safeHeight / targetHeight
|
|
const scale = Math.min(1, widthScale, heightScale)
|
|
const width = targetWidth * scale
|
|
const height = targetHeight * scale
|
|
const widthLimited = widthScale < Math.min(1, heightScale)
|
|
|
|
return {
|
|
x: Math.max(0, (safeWidth - width) / 2),
|
|
y: Math.max(0, widthLimited ? (safeHeight - height) / 2 : 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, referenceDb: number = DEFAULT_VU_REFERENCE_DBFS): number {
|
|
if (!Number.isFinite(db)) {
|
|
return CLASSIC_VU_MIN
|
|
}
|
|
return clamp(db - referenceDb, 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,
|
|
referenceDb = DEFAULT_VU_REFERENCE_DBFS,
|
|
}: {
|
|
leftDb: number
|
|
rightDb: number
|
|
leftPeakDb: number
|
|
rightPeakDb: number
|
|
needleChannels: VUMeterNeedleChannels
|
|
referenceDb?: number
|
|
}): VUNeedleReading[] {
|
|
if (needleChannels === 'combined') {
|
|
const db = stereoRmsDbAverage(leftDb, rightDb)
|
|
const peakDb = Math.max(leftPeakDb, rightPeakDb)
|
|
return [{
|
|
id: 'combined',
|
|
db,
|
|
peakDb,
|
|
vu: dbfsToClassicVu(db, referenceDb),
|
|
peakVu: dbfsToClassicVu(peakDb, referenceDb),
|
|
}]
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: 'left',
|
|
db: leftDb,
|
|
peakDb: leftPeakDb,
|
|
vu: dbfsToClassicVu(leftDb, referenceDb),
|
|
peakVu: dbfsToClassicVu(leftPeakDb, referenceDb),
|
|
},
|
|
{
|
|
id: 'right',
|
|
db: rightDb,
|
|
peakDb: rightPeakDb,
|
|
vu: dbfsToClassicVu(rightDb, referenceDb),
|
|
peakVu: dbfsToClassicVu(rightPeakDb, referenceDb),
|
|
},
|
|
]
|
|
}
|
|
|
|
// ---- VU Meter class ----
|
|
|
|
export class VUMeter {
|
|
private canvas: HTMLCanvasElement
|
|
private ctx: CanvasRenderingContext2D
|
|
private options: ResolvedVUMeterOptions
|
|
private dataSource: VUMeterDataSource
|
|
private nativeAnalyzer: VUMeterNativeAnalyzer | null
|
|
private frameLoop: VisualizerFrameLoop
|
|
private meterBallistics: VUMeterBallistics
|
|
private currentSampleRate = 0
|
|
private pushScratchL = new Float32Array(0)
|
|
private pushScratchR = new Float32Array(0)
|
|
private unsubscribeSessionChange: (() => void) | null = null
|
|
|
|
// Meter state
|
|
private vuL = VU_METER_MIN_DB
|
|
private vuR = VU_METER_MIN_DB
|
|
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
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) throw new Error('Could not get 2D context')
|
|
this.ctx = ctx
|
|
|
|
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
|
|
this.options = { ...defaultOptions, ...optionOverrides }
|
|
this.dataSource = dataSource ?? defaultVUMeterDataSource
|
|
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVUMeter : nativeAnalyzer
|
|
this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate())
|
|
this.frameLoop = new VisualizerFrameLoop({
|
|
frameScheduler,
|
|
shouldRun: () => this.dataSource.isPlaying(),
|
|
onFrame: this.drawFrame,
|
|
})
|
|
this.resetMeters()
|
|
this.subscribeToSessionChanges()
|
|
}
|
|
|
|
private subscribeToSessionChanges(): void {
|
|
if (this.unsubscribeSessionChange) {
|
|
this.unsubscribeSessionChange()
|
|
}
|
|
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
|
|
this.resetMeters()
|
|
})
|
|
}
|
|
|
|
private resetMeters(): void {
|
|
this.currentSampleRate = Math.max(1, this.dataSource.getSampleRate())
|
|
this.meterBallistics.reinitialize(this.currentSampleRate)
|
|
if (this.isNativeAnalyzerReady()) {
|
|
this.nativeAnalyzer?.setSampleRate(this.currentSampleRate)
|
|
this.nativeAnalyzer?.reset()
|
|
}
|
|
this.applySnapshot(this.meterBallistics.getSnapshot())
|
|
this.resetNeedleVisuals()
|
|
this.invalidate()
|
|
}
|
|
|
|
setOptions(options: Partial<VUMeterOptions>): void {
|
|
const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options
|
|
this.options = { ...this.options, ...optionUpdates }
|
|
let didReset = false
|
|
if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) {
|
|
this.nativeAnalyzer = nativeAnalyzer
|
|
this.resetMeters()
|
|
didReset = true
|
|
}
|
|
if (dataSource && dataSource !== this.dataSource) {
|
|
this.dataSource = dataSource
|
|
this.subscribeToSessionChanges()
|
|
this.resetMeters()
|
|
didReset = true
|
|
}
|
|
if (!didReset) {
|
|
this.invalidate()
|
|
}
|
|
}
|
|
|
|
start(): void {
|
|
this.frameLoop.start()
|
|
}
|
|
|
|
stop(): void {
|
|
this.frameLoop.stop()
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.frameLoop.invalidate()
|
|
}
|
|
|
|
resize(): void {
|
|
// Canvas resize handled externally
|
|
this.invalidate()
|
|
}
|
|
|
|
private applySnapshot(snapshot: VUMeterSnapshot): void {
|
|
this.vuL = snapshot.vuLDb
|
|
this.vuR = snapshot.vuRDb
|
|
this.peakL = snapshot.peakLDb
|
|
this.peakR = snapshot.peakRDb
|
|
this.correlation = snapshot.correlation
|
|
}
|
|
|
|
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 = Math.max(1, this.dataSource.getSampleRate())
|
|
if (
|
|
Math.abs(sampleRate - this.currentSampleRate) > 100
|
|
|| Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100
|
|
) {
|
|
this.resetMeters()
|
|
}
|
|
|
|
const chunks = this.dataSource.getPendingVUMeterSamples()
|
|
if (!this.dataSource.isPlaying()) {
|
|
const snapshot = this.isNativeAnalyzerReady()
|
|
? normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null)
|
|
: this.meterBallistics.getSnapshot()
|
|
this.applySnapshot(snapshot)
|
|
return
|
|
}
|
|
|
|
if (this.isNativeAnalyzerReady()) {
|
|
if (chunks.length > 0) {
|
|
const batch = this.concatStereoChunks(chunks)
|
|
if (batch.left.length > 0 && batch.right.length > 0) {
|
|
this.nativeAnalyzer?.pushSamples(batch.left, batch.right)
|
|
}
|
|
}
|
|
this.applySnapshot(normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null))
|
|
return
|
|
}
|
|
|
|
this.applySnapshot(this.meterBallistics.process(chunks, nowMs))
|
|
}
|
|
|
|
private isNativeAnalyzerReady(): boolean {
|
|
if (!this.nativeAnalyzer) {
|
|
return false
|
|
}
|
|
return this.nativeAnalyzer.isAvailable?.() ?? true
|
|
}
|
|
|
|
private concatStereoChunks(chunks: Array<{ left: Float32Array; right: Float32Array }>): { left: Float32Array; right: Float32Array } {
|
|
if (chunks.length === 1) {
|
|
const chunk = chunks[0]
|
|
const length = Math.min(chunk.left.length, chunk.right.length)
|
|
return {
|
|
left: chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length),
|
|
right: chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length),
|
|
}
|
|
}
|
|
|
|
let totalLength = 0
|
|
for (const chunk of chunks) {
|
|
totalLength += Math.min(chunk.left.length, chunk.right.length)
|
|
}
|
|
if (totalLength === 0) {
|
|
return { left: new Float32Array(0), right: new Float32Array(0) }
|
|
}
|
|
|
|
if (this.pushScratchL.length < totalLength) {
|
|
this.pushScratchL = new Float32Array(totalLength)
|
|
this.pushScratchR = new Float32Array(totalLength)
|
|
}
|
|
|
|
const left = this.pushScratchL.subarray(0, totalLength)
|
|
const right = this.pushScratchR.subarray(0, totalLength)
|
|
let offset = 0
|
|
for (const chunk of chunks) {
|
|
const length = Math.min(chunk.left.length, chunk.right.length)
|
|
if (length <= 0) {
|
|
continue
|
|
}
|
|
left.set(chunk.left.subarray(0, length), offset)
|
|
right.set(chunk.right.subarray(0, length), offset)
|
|
offset += length
|
|
}
|
|
|
|
return { left, right }
|
|
}
|
|
|
|
private dbToNormalized(db: number): number {
|
|
// Map dBFS to bar position via the VU calibration so bars and the needle
|
|
// share the same scale (0 VU sits at the hot threshold, +3 VU at full scale).
|
|
return classicVuToNormalized(dbfsToClassicVu(db, this.options.referenceDb))
|
|
}
|
|
|
|
private drawBarMode(width: number, height: number): void {
|
|
if (this.options.orientation === 'vertical') {
|
|
this.drawVerticalBarMode(width, height)
|
|
return
|
|
}
|
|
|
|
this.drawHorizontalBarMode(width, height)
|
|
}
|
|
|
|
private drawHorizontalBarMode(width: number, height: number): void {
|
|
const ctx = this.ctx
|
|
const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor)
|
|
|
|
const meterHeight = Math.max(1, Math.floor(height * 0.28))
|
|
const corrHeight = Math.max(1, Math.floor(height * 0.16))
|
|
const gap = Math.max(2, Math.floor(height * 0.04))
|
|
const labelWidth = Math.max(24, Math.floor(width * 0.07))
|
|
const dbLabelWidth = Math.max(52, Math.floor(width * 0.1))
|
|
const barLeft = labelWidth + 4
|
|
const barRight = width - dbLabelWidth - 4
|
|
const barWidth = Math.max(1, barRight - barLeft)
|
|
|
|
// Total content height
|
|
const totalHeight = meterHeight * 2 + corrHeight + gap * 2
|
|
const topOffset = Math.max(0, Math.floor((height - totalHeight) / 2))
|
|
|
|
// ---- L meter ----
|
|
const lY = topOffset
|
|
this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.vuL, this.peakL, cr, cg, cb)
|
|
this.drawMeterLabel(ctx, 0, lY, labelWidth, meterHeight, 'L')
|
|
this.drawDbLabel(ctx, barRight + 4, lY, dbLabelWidth, meterHeight, this.vuL)
|
|
|
|
// ---- R meter ----
|
|
const rY = lY + meterHeight + gap
|
|
this.drawHorizontalMeterBar(ctx, barLeft, rY, barWidth, meterHeight, this.vuR, this.peakR, cr, cg, cb)
|
|
this.drawMeterLabel(ctx, 0, rY, labelWidth, meterHeight, 'R')
|
|
this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.vuR)
|
|
|
|
// ---- Correlation meter ----
|
|
const corrY = rY + meterHeight + gap
|
|
this.drawCorrelationBar(ctx, barLeft, corrY, barWidth, corrHeight, cr, cg, cb)
|
|
}
|
|
|
|
private drawVerticalBarMode(width: number, height: number): void {
|
|
const ctx = this.ctx
|
|
const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor)
|
|
|
|
const sidePadding = Math.max(4, Math.floor(width * 0.08))
|
|
const channelGap = Math.max(4, Math.floor(width * 0.08))
|
|
const labelHeight = Math.max(14, Math.floor(height * 0.08))
|
|
const dbHeight = Math.max(14, Math.floor(height * 0.1))
|
|
const corrHeight = Math.max(10, Math.floor(height * 0.11))
|
|
const gapY = Math.max(4, Math.floor(height * 0.03))
|
|
const maxMeterWidth = Math.max(4, Math.floor((width - channelGap) / 2))
|
|
const availableMeterWidth = Math.max(8, width - sidePadding * 2 - channelGap)
|
|
const meterWidth = Math.min(Math.max(6, Math.floor(availableMeterWidth / 2)), maxMeterWidth)
|
|
const totalMeterWidth = meterWidth * 2 + channelGap
|
|
const meterLeft = Math.max(0, Math.floor((width - totalMeterWidth) / 2))
|
|
const meterTop = gapY + labelHeight
|
|
const meterHeight = Math.max(1, height - labelHeight - dbHeight - corrHeight - gapY * 4)
|
|
const dbY = meterTop + meterHeight + gapY
|
|
const corrY = dbY + dbHeight + gapY
|
|
const corrX = Math.max(4, Math.floor(width * 0.06))
|
|
const corrWidth = Math.max(1, width - corrX * 2)
|
|
|
|
const lX = meterLeft
|
|
const rX = meterLeft + meterWidth + channelGap
|
|
|
|
this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L')
|
|
this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.vuL, this.peakL, cr, cg, cb)
|
|
this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.vuL)
|
|
|
|
this.drawMeterLabel(ctx, rX, 0, meterWidth, labelHeight, 'R')
|
|
this.drawVerticalMeterBar(ctx, rX, meterTop, meterWidth, meterHeight, this.vuR, this.peakR, cr, cg, cb)
|
|
this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.vuR)
|
|
|
|
this.drawCorrelationBar(ctx, corrX, corrY, corrWidth, corrHeight, cr, cg, cb)
|
|
}
|
|
|
|
private drawHorizontalMeterBar(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, w: number, h: number,
|
|
levelDb: number, peakDb: number,
|
|
cr: number, cg: number, cb: number
|
|
): void {
|
|
const levelNorm = this.dbToNormalized(levelDb)
|
|
const peakNorm = this.dbToNormalized(peakDb)
|
|
const levelWidth = levelNorm * w
|
|
const hotThreshold = classicVuToNormalized(0) * w
|
|
|
|
// Background track
|
|
ctx.fillStyle = this.options.trackColor
|
|
ctx.fillRect(x, y, w, h)
|
|
|
|
// Main level bar
|
|
if (levelWidth > 0) {
|
|
const safeWidth = Math.min(levelWidth, hotThreshold)
|
|
if (safeWidth > 0) {
|
|
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
|
|
ctx.fillRect(x, y, safeWidth, h)
|
|
}
|
|
if (levelWidth > hotThreshold) {
|
|
// Hot zone: transition to warm/red
|
|
const hotWidth = levelWidth - hotThreshold
|
|
const hotProgress = Math.min(1, hotWidth / Math.max(1, w - hotThreshold))
|
|
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
|
|
const hotG = Math.round(cg * (1 - hotProgress * 0.6))
|
|
const hotB = Math.round(cb * (1 - hotProgress * 0.7))
|
|
ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82)
|
|
ctx.fillRect(x + hotThreshold, y, hotWidth, h)
|
|
}
|
|
}
|
|
|
|
// Peak indicator line
|
|
if (peakNorm > 0.001) {
|
|
const peakX = x + peakNorm * w
|
|
const peakInHot = dbfsToClassicVu(peakDb, this.options.referenceDb) > 0
|
|
ctx.fillStyle = peakInHot
|
|
? this.options.clipColor
|
|
: this.options.peakColor
|
|
ctx.fillRect(peakX - 1, y, 2, h)
|
|
}
|
|
|
|
// Scale ticks (VU units)
|
|
ctx.fillStyle = this.options.scaleColor
|
|
const tickVus = [-20, -10, -5, -3, -1, 0, 1, 2, 3]
|
|
for (const vu of tickVus) {
|
|
const tickX = x + classicVuToNormalized(vu) * w
|
|
ctx.fillRect(tickX, y + h - 3, 1, 3)
|
|
}
|
|
}
|
|
|
|
private drawVerticalMeterBar(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, w: number, h: number,
|
|
levelDb: number, peakDb: number,
|
|
cr: number, cg: number, cb: number
|
|
): void {
|
|
const levelNorm = this.dbToNormalized(levelDb)
|
|
const peakNorm = this.dbToNormalized(peakDb)
|
|
const levelHeight = levelNorm * h
|
|
const hotThreshold = classicVuToNormalized(0) * h
|
|
|
|
ctx.fillStyle = this.options.trackColor
|
|
ctx.fillRect(x, y, w, h)
|
|
|
|
if (levelHeight > 0) {
|
|
const safeHeight = Math.min(levelHeight, hotThreshold)
|
|
if (safeHeight > 0) {
|
|
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.82)
|
|
ctx.fillRect(x, y + h - safeHeight, w, safeHeight)
|
|
}
|
|
if (levelHeight > hotThreshold) {
|
|
const hotHeight = levelHeight - hotThreshold
|
|
const hotProgress = Math.min(1, hotHeight / Math.max(1, h - hotThreshold))
|
|
const hotR = Math.round(cr + (255 - cr) * hotProgress * 0.7)
|
|
const hotG = Math.round(cg * (1 - hotProgress * 0.6))
|
|
const hotB = Math.round(cb * (1 - hotProgress * 0.7))
|
|
ctx.fillStyle = colorWithAlpha(hotR, hotG, hotB, 0.82)
|
|
ctx.fillRect(x, y + h - levelHeight, w, hotHeight)
|
|
}
|
|
}
|
|
|
|
if (peakNorm > 0.001) {
|
|
const peakY = y + h - peakNorm * h
|
|
const peakInHot = dbfsToClassicVu(peakDb, this.options.referenceDb) > 0
|
|
ctx.fillStyle = peakInHot
|
|
? this.options.clipColor
|
|
: this.options.peakColor
|
|
ctx.fillRect(x, peakY - 1, w, 2)
|
|
}
|
|
|
|
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.84)
|
|
const tickVus = [-20, -10, -5, -3, -1, 0, 1, 2, 3]
|
|
for (const vu of tickVus) {
|
|
const tickY = y + h - classicVuToNormalized(vu) * h
|
|
ctx.fillRect(x, tickY, w, 1)
|
|
}
|
|
}
|
|
|
|
private drawMeterLabel(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, w: number, h: number,
|
|
label: string
|
|
): void {
|
|
ctx.fillStyle = this.options.labelColor
|
|
ctx.font = `${Math.min(22, Math.max(10, h * 0.65))}px "JetBrains Mono", monospace`
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(label, x + w / 2, y + h / 2)
|
|
}
|
|
|
|
private drawDbLabel(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, _w: number, h: number,
|
|
db: number
|
|
): void {
|
|
const displayDb = Number.isFinite(db) ? Math.max(VU_METER_MIN_DB, Math.min(0, db)) : VU_METER_MIN_DB
|
|
const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}`
|
|
ctx.fillStyle = alphaColor(this.options.labelColor, 0.8)
|
|
ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace`
|
|
ctx.textAlign = 'left'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(text, x, y + h / 2)
|
|
}
|
|
|
|
private drawCenteredDbLabel(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, w: number, h: number,
|
|
db: number
|
|
): void {
|
|
const displayDb = Number.isFinite(db) ? Math.max(VU_METER_MIN_DB, Math.min(0, db)) : VU_METER_MIN_DB
|
|
const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}`
|
|
ctx.fillStyle = alphaColor(this.options.labelColor, 0.8)
|
|
ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace`
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(text, x + w / 2, y + h / 2)
|
|
}
|
|
|
|
private drawCorrelationBar(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number, y: number, w: number, h: 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))
|
|
|
|
// Background track
|
|
ctx.fillStyle = this.options.trackColor
|
|
ctx.fillRect(x, y, w, h)
|
|
|
|
// Center line
|
|
ctx.fillStyle = this.options.scaleColor
|
|
ctx.fillRect(centerX - 0.5, y, 1, h)
|
|
|
|
// Correlation indicator
|
|
const indicatorWidth = Math.abs(corr) * (w / 2)
|
|
if (indicatorWidth > 0.5) {
|
|
if (corr >= 0) {
|
|
// Positive correlation: draw rightward from center (good)
|
|
ctx.fillStyle = colorWithAlpha(cr, cg, cb, 0.6)
|
|
ctx.fillRect(centerX, y, indicatorWidth, h)
|
|
} else {
|
|
// Negative correlation: draw leftward from center (out of phase)
|
|
ctx.fillStyle = alphaColor(this.options.clipColor, 0.6)
|
|
ctx.fillRect(centerX - indicatorWidth, y, indicatorWidth, h)
|
|
}
|
|
}
|
|
|
|
// Labels
|
|
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, labelAlpha)
|
|
ctx.textAlign = 'left'
|
|
ctx.fillText('-1', x + 2, y + h / 2)
|
|
ctx.textAlign = 'center'
|
|
ctx.fillText('Ø', centerX, y + h / 2)
|
|
ctx.textAlign = 'right'
|
|
ctx.fillText('+1', x + w - 2, y + h / 2)
|
|
}
|
|
|
|
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
|
|
|
|
ctx.save()
|
|
ctx.translate(faceLayout.x, faceLayout.y)
|
|
|
|
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,
|
|
referenceDb: this.options.referenceDb,
|
|
})
|
|
const readoutReadings = visualReadings
|
|
|
|
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 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,
|
|
centerX: number,
|
|
centerY: number,
|
|
outerRadiusX: number,
|
|
outerRadiusY: number,
|
|
innerRadiusX: number,
|
|
innerRadiusY: number,
|
|
startAngle: number,
|
|
endAngle: number,
|
|
readings: VUNeedleReading[],
|
|
outerWidth: number,
|
|
innerWidth: number,
|
|
): void {
|
|
const hotAngle = this.vuToNeedleAngle(0, startAngle, endAngle)
|
|
const mainReading = readings[readings.length - 1]
|
|
const leftReading = readings.length > 1 ? readings[0] : null
|
|
if (!mainReading) return
|
|
|
|
ctx.lineCap = 'butt'
|
|
ctx.strokeStyle = alphaColor(this.getNeedleColor(mainReading), 0.052)
|
|
ctx.lineWidth = outerWidth
|
|
ctx.beginPath()
|
|
ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, startAngle, hotAngle)
|
|
ctx.stroke()
|
|
ctx.strokeStyle = alphaColor(this.options.clipColor, 0.085)
|
|
ctx.beginPath()
|
|
ctx.ellipse(centerX, centerY, outerRadiusX, outerRadiusY, 0, hotAngle, endAngle)
|
|
ctx.stroke()
|
|
|
|
if (leftReading) {
|
|
ctx.strokeStyle = alphaColor(this.getNeedleColor(leftReading), 0.04)
|
|
ctx.lineWidth = innerWidth
|
|
ctx.beginPath()
|
|
ctx.ellipse(centerX, centerY, innerRadiusX, innerRadiusY, 0, startAngle, hotAngle)
|
|
ctx.stroke()
|
|
ctx.strokeStyle = alphaColor(this.options.clipColor, 0.065)
|
|
ctx.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()
|
|
}
|
|
|
|
if (levelAngle > hotAngle) {
|
|
ctx.strokeStyle = alphaColor(this.options.clipColor, Math.min(1, alpha + 0.04))
|
|
ctx.lineWidth = lineWidth
|
|
ctx.lineCap = 'butt'
|
|
ctx.beginPath()
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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'
|
|
|
|
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 {
|
|
if (!Number.isFinite(db)) return '-∞'
|
|
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 => {
|
|
const { canvas, ctx, options } = this
|
|
const width = canvas.width
|
|
const height = canvas.height
|
|
|
|
if (width <= 0 || height <= 0) {
|
|
return
|
|
}
|
|
|
|
const nowMs = performance.now()
|
|
this.processAudio(nowMs)
|
|
this.updateNeedleVisuals(nowMs)
|
|
|
|
ctx.clearRect(0, 0, width, height)
|
|
if (options.backgroundColor !== 'transparent') {
|
|
ctx.fillStyle = options.backgroundColor
|
|
ctx.fillRect(0, 0, width, height)
|
|
}
|
|
|
|
if (options.mode === 'needle') {
|
|
this.drawNeedleMode(width, height)
|
|
} else {
|
|
this.drawBarMode(width, height)
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this.stop()
|
|
this.frameLoop.dispose()
|
|
if (this.unsubscribeSessionChange) {
|
|
this.unsubscribeSessionChange()
|
|
this.unsubscribeSessionChange = null
|
|
}
|
|
if (this.isNativeAnalyzerReady()) {
|
|
this.nativeAnalyzer?.reset()
|
|
}
|
|
}
|
|
}
|