mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
Improved LUFS meter
This commit is contained in:
@@ -19,7 +19,7 @@ Seven visualizers driven by a native C++ analysis engine:
|
||||
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split
|
||||
- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes
|
||||
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
|
||||
- **LUFS Meter** — Integrated loudness metering following ITU-R BS.1770
|
||||
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
|
||||
- **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes
|
||||
|
||||

|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
|
||||
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
|
||||
export const LOCKED_LOUDNESS_METER_WIDTH_PX = 150
|
||||
|
||||
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
|
||||
return scope === 'spectrogram'
|
||||
@@ -25,6 +26,10 @@ export function buildAnalyzerGridTemplateColumns(
|
||||
return `minmax(clamp(96px, 18vw, calc(var(--analyzer-height, 240px) - 8px)), ${weight}fr)`
|
||||
}
|
||||
|
||||
if (scope === 'lufsmeter') {
|
||||
return `minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px)`
|
||||
}
|
||||
|
||||
if (usesCollapsedDefaultWeight(scope) && weight <= 0) {
|
||||
return `minmax(0, ${DEFAULT_COLLAPSED_SCOPE_WEIGHT}fr)`
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
vectorscope: 'Vectorscope',
|
||||
spectrogram: 'Spectrogram',
|
||||
vumeter: 'VU Meter',
|
||||
lufsmeter: 'LUFS Meter',
|
||||
lufsmeter: 'Loudness Meter',
|
||||
waveform: 'Waveform',
|
||||
nowPlaying: 'Now Playing',
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ export function scopeSettingsToOptions(
|
||||
scaleColor: t.scale,
|
||||
labelColor: t.labels,
|
||||
mode: s.mode,
|
||||
readout: s.readout,
|
||||
}
|
||||
}
|
||||
case 'waveform': {
|
||||
|
||||
@@ -19,6 +19,17 @@ function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): strin
|
||||
}
|
||||
}
|
||||
|
||||
function lufsReadoutLabel(readout: ScopeSettings['lufsmeter']['readout']): string {
|
||||
switch (readout) {
|
||||
case 'integrated':
|
||||
return 'Integrated'
|
||||
case 'shortTerm':
|
||||
return 'Short-term'
|
||||
case 'momentary':
|
||||
return 'Momentary'
|
||||
}
|
||||
}
|
||||
|
||||
function nowPlayingVisibleLabels(settings: ScopeSettings['nowPlaying']): string[] {
|
||||
const labels: string[] = []
|
||||
if (settings.showCoverArt) labels.push('Cover')
|
||||
@@ -66,7 +77,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
|
||||
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
|
||||
}
|
||||
case 'lufsmeter':
|
||||
return 'Bar Meter'
|
||||
return `${lufsReadoutLabel((settings as ScopeSettings['lufsmeter']).readout)} LUFS`
|
||||
case 'waveform': {
|
||||
const scopeSettings = settings as ScopeSettings['waveform']
|
||||
const summary = [scopeSettings.mode === 'stereo' ? 'Stereo' : 'Mono']
|
||||
@@ -486,11 +497,13 @@ export default function ScopeSettingsSection({
|
||||
const current = settings as ScopeSettings['lufsmeter']
|
||||
return (
|
||||
<SelectControl
|
||||
label="Mode"
|
||||
value={current.mode}
|
||||
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
|
||||
label="Readout"
|
||||
value={current.readout}
|
||||
onChange={(value) => onUpdate('lufsmeter', { readout: value as ScopeSettings['lufsmeter']['readout'] })}
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="integrated">Integrated</option>
|
||||
<option value="shortTerm">Short-term</option>
|
||||
<option value="momentary">Momentary</option>
|
||||
</SelectControl>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import type { LUFSMeterMode } from '../../types/lufsmeter'
|
||||
import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter'
|
||||
import { resolveColorToRgb } from '../utils/color'
|
||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||
import { FrameScheduler } from './frameScheduler'
|
||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
import {
|
||||
VUMeterBallistics,
|
||||
VU_METER_MIN_DB,
|
||||
type VUMeterSnapshot,
|
||||
} from './vuMeterBallistics'
|
||||
|
||||
export interface LUFSMeterDataSource extends VisualizerSessionSource {
|
||||
getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
|
||||
@@ -11,6 +16,7 @@ export interface LUFSMeterDataSource extends VisualizerSessionSource {
|
||||
|
||||
export interface LUFSMeterOptions {
|
||||
mode?: LUFSMeterMode
|
||||
readout?: LUFSMeterReadout
|
||||
backgroundColor?: string
|
||||
lineColor?: string
|
||||
trackColor?: string
|
||||
@@ -25,6 +31,7 @@ type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | '
|
||||
|
||||
const defaultOptions: ResolvedLUFSMeterOptions = {
|
||||
mode: 'bar',
|
||||
readout: 'shortTerm',
|
||||
backgroundColor: 'transparent',
|
||||
lineColor: '#38bdf8',
|
||||
trackColor: 'rgba(56, 189, 248, 0.08)',
|
||||
@@ -38,10 +45,28 @@ const defaultLUFSMeterDataSource: LUFSMeterDataSource = {
|
||||
...defaultVisualizerSessionSource,
|
||||
}
|
||||
|
||||
function colorWithAlpha(r: number, g: number, b: number, a: number): string {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`
|
||||
}
|
||||
|
||||
function relativeLuminanceChannel(channel: number): number {
|
||||
const normalized = Math.max(0, Math.min(255, channel)) / 255
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function contrastRatio(luminanceA: number, luminanceB: number): number {
|
||||
const lighter = Math.max(luminanceA, luminanceB)
|
||||
const darker = Math.min(luminanceA, luminanceB)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
// ---- Constants ----
|
||||
|
||||
const METER_MIN_LUFS = -60
|
||||
const METER_MAX_LUFS = 0
|
||||
const COMPACT_METER_MIN_DB = -50
|
||||
const COMPACT_METER_MAX_DB = 0
|
||||
const MOMENTARY_WINDOW_S = 0.4
|
||||
const SHORT_TERM_WINDOW_S = 3.0
|
||||
const INTEGRATED_BLOCK_S = 0.4
|
||||
@@ -57,6 +82,16 @@ const INTEGRATED_HISTOGRAM_BIN_COUNT = Math.round(
|
||||
(INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH
|
||||
) + 1
|
||||
|
||||
const INITIAL_VU_SNAPSHOT: VUMeterSnapshot = {
|
||||
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,
|
||||
}
|
||||
|
||||
// ---- K-weighting filter coefficients (ITU-R BS.1770) ----
|
||||
|
||||
interface BiquadCoeffs {
|
||||
@@ -126,7 +161,7 @@ function histogramLufsAtIndex(index: number): number {
|
||||
return INTEGRATED_HISTOGRAM_MIN_LUFS + (index * INTEGRATED_HISTOGRAM_BIN_WIDTH)
|
||||
}
|
||||
|
||||
// ---- LUFS Meter class ----
|
||||
// ---- Loudness meter class ----
|
||||
|
||||
export class LUFSMeter {
|
||||
private canvas: HTMLCanvasElement
|
||||
@@ -134,6 +169,7 @@ export class LUFSMeter {
|
||||
private options: ResolvedLUFSMeterOptions
|
||||
private dataSource: LUFSMeterDataSource
|
||||
private frameLoop: VisualizerFrameLoop
|
||||
private meterBallistics: VUMeterBallistics
|
||||
|
||||
// K-weighting filter state (per channel, two stages)
|
||||
private preFilterL = createBiquadState()
|
||||
@@ -161,6 +197,7 @@ export class LUFSMeter {
|
||||
private momentaryLUFS = METER_MIN_LUFS
|
||||
private shortTermLUFS = METER_MIN_LUFS
|
||||
private integratedLUFS = METER_MIN_LUFS
|
||||
private fastSnapshot: VUMeterSnapshot = { ...INITIAL_VU_SNAPSHOT }
|
||||
private unsubscribeSessionChange: (() => void) | null = null
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: LUFSMeterOptions = {}) {
|
||||
@@ -172,6 +209,7 @@ export class LUFSMeter {
|
||||
const { dataSource, frameScheduler, ...optionOverrides } = options
|
||||
this.options = { ...defaultOptions, ...optionOverrides }
|
||||
this.dataSource = dataSource ?? defaultLUFSMeterDataSource
|
||||
this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate())
|
||||
this.frameLoop = new VisualizerFrameLoop({
|
||||
frameScheduler,
|
||||
shouldRun: () => this.dataSource.isPlaying(),
|
||||
@@ -194,6 +232,7 @@ export class LUFSMeter {
|
||||
private initRingBuffer(sampleRate: number): void {
|
||||
this.currentSampleRate = Math.max(1, sampleRate)
|
||||
this.kWeightingCoeffs = getKWeightingCoeffs(this.currentSampleRate)
|
||||
this.meterBallistics.reinitialize(this.currentSampleRate)
|
||||
const bufferSize = Math.ceil(this.currentSampleRate * SHORT_TERM_WINDOW_S)
|
||||
this.ringBufferL = new Float32Array(bufferSize)
|
||||
this.ringBufferR = new Float32Array(bufferSize)
|
||||
@@ -205,6 +244,8 @@ export class LUFSMeter {
|
||||
this.momentaryLUFS = METER_MIN_LUFS
|
||||
this.shortTermLUFS = METER_MIN_LUFS
|
||||
this.integratedLUFS = METER_MIN_LUFS
|
||||
this.meterBallistics.reset()
|
||||
this.fastSnapshot = this.meterBallistics.getSnapshot()
|
||||
this.ringBufferL.fill(0)
|
||||
this.ringBufferR.fill(0)
|
||||
this.ringBufferPos = 0
|
||||
@@ -264,12 +305,16 @@ export class LUFSMeter {
|
||||
const playing = this.dataSource.isPlaying()
|
||||
|
||||
if (!playing && chunks.length === 0) {
|
||||
this.meterBallistics.reset()
|
||||
this.fastSnapshot = this.meterBallistics.getSnapshot()
|
||||
// Decay toward silence only when truly stopped
|
||||
this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
|
||||
this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
|
||||
return
|
||||
}
|
||||
|
||||
this.fastSnapshot = this.meterBallistics.process(chunks, performance.now())
|
||||
|
||||
// Process any new audio chunks into the ring buffer
|
||||
if (chunks.length > 0) {
|
||||
const { pre, rlb } = this.kWeightingCoeffs
|
||||
@@ -419,101 +464,200 @@ export class LUFSMeter {
|
||||
this.drawBars(width, height)
|
||||
}
|
||||
|
||||
private drawBars(width: number, height: number): void {
|
||||
private selectedLufs(): number {
|
||||
switch (this.options.readout) {
|
||||
case 'momentary':
|
||||
return this.momentaryLUFS
|
||||
case 'shortTerm':
|
||||
return this.shortTermLUFS
|
||||
case 'integrated':
|
||||
default:
|
||||
return this.integratedLUFS
|
||||
}
|
||||
}
|
||||
|
||||
private compactDbToNormalized(db: number): number {
|
||||
const clamped = Math.max(COMPACT_METER_MIN_DB, Math.min(COMPACT_METER_MAX_DB, db))
|
||||
return (clamped - COMPACT_METER_MIN_DB) / (COMPACT_METER_MAX_DB - COMPACT_METER_MIN_DB)
|
||||
}
|
||||
|
||||
private contrastForLevelColor(): string {
|
||||
const { r, g, b } = resolveColorToRgb(this.options.lineColor)
|
||||
const luminance = 0.2126 * relativeLuminanceChannel(r)
|
||||
+ 0.7152 * relativeLuminanceChannel(g)
|
||||
+ 0.0722 * relativeLuminanceChannel(b)
|
||||
return contrastRatio(luminance, 0) >= contrastRatio(luminance, 1)
|
||||
? 'rgba(0, 0, 0, 0.9)'
|
||||
: 'rgba(255, 255, 255, 0.94)'
|
||||
}
|
||||
|
||||
private resolveReadoutTextLayout(
|
||||
candidates: string[],
|
||||
maxWidth: number,
|
||||
maxFontSize: number,
|
||||
minFontSize: number,
|
||||
): { text: string; fontSize: number } {
|
||||
const ctx = this.ctx
|
||||
const { r: tintR, g: tintG, b: tintB } = resolveColorToRgb(this.options.lineColor)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
const padding = Math.round(8 * dpr)
|
||||
const labelHeight = Math.round(20 * dpr)
|
||||
const readoutHeight = Math.round(18 * dpr)
|
||||
const scaleWidth = Math.round(32 * dpr)
|
||||
const barAreaTop = padding + labelHeight
|
||||
const barAreaBottom = height - padding - readoutHeight
|
||||
const barAreaHeight = Math.max(1, barAreaBottom - barAreaTop)
|
||||
const barAreaWidth = width - scaleWidth - padding
|
||||
|
||||
const barCount = 3
|
||||
const barGap = Math.round(4 * dpr)
|
||||
const totalGaps = (barCount - 1) * barGap
|
||||
const barWidth = Math.max(4, Math.floor((barAreaWidth - totalGaps) / barCount))
|
||||
|
||||
const values = [this.momentaryLUFS, this.shortTermLUFS, this.integratedLUFS]
|
||||
const labels = ['M', 'S', 'I']
|
||||
const dbRange = METER_MAX_LUFS - METER_MIN_LUFS
|
||||
|
||||
const fontSize = Math.min(Math.round(13 * dpr), Math.max(Math.round(9 * dpr), Math.round(barWidth * 0.4)))
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const x = scaleWidth + i * (barWidth + barGap)
|
||||
const lufs = values[i]
|
||||
const normalized = Math.max(0, Math.min(1, (lufs - METER_MIN_LUFS) / dbRange))
|
||||
const barH = Math.round(normalized * barAreaHeight)
|
||||
|
||||
// Bar label
|
||||
ctx.font = `600 ${fontSize}px "Inter", system-ui, sans-serif`
|
||||
ctx.fillStyle = this.options.labelColor
|
||||
ctx.fillText(labels[i], x + barWidth / 2, padding)
|
||||
|
||||
// Bar background
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, barAreaTop, barWidth, barAreaHeight)
|
||||
|
||||
// Bar fill — gradient from dim at bottom to bright at top
|
||||
if (barH > 0) {
|
||||
const gradient = ctx.createLinearGradient(0, barAreaBottom, 0, barAreaBottom - barH)
|
||||
gradient.addColorStop(0, `rgba(${tintR}, ${tintG}, ${tintB}, 0.3)`)
|
||||
gradient.addColorStop(0.5, `rgba(${tintR}, ${tintG}, ${tintB}, 0.6)`)
|
||||
gradient.addColorStop(1, `rgba(${tintR}, ${tintG}, ${tintB}, 0.9)`)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, barAreaBottom - barH, barWidth, barH)
|
||||
for (const text of candidates) {
|
||||
ctx.font = `800 ${maxFontSize}px "JetBrains Mono", "SF Mono", monospace`
|
||||
const measuredWidth = ctx.measureText(text).width
|
||||
if (measuredWidth <= maxWidth) {
|
||||
return { text, fontSize: maxFontSize }
|
||||
}
|
||||
|
||||
// Bright cap line at top of bar
|
||||
if (barH > 1) {
|
||||
ctx.fillStyle = `rgb(${tintR}, ${tintG}, ${tintB})`
|
||||
ctx.fillRect(x, barAreaBottom - barH, barWidth, Math.max(1, Math.round(2 * dpr)))
|
||||
const scaledFontSize = Math.floor(maxFontSize * (maxWidth / Math.max(1, measuredWidth)))
|
||||
if (scaledFontSize >= minFontSize) {
|
||||
return { text, fontSize: scaledFontSize }
|
||||
}
|
||||
|
||||
// LUFS readout below bar
|
||||
const displayLufs = lufs <= METER_MIN_LUFS + 1 ? '-∞' : lufs.toFixed(1)
|
||||
ctx.font = `500 ${Math.max(Math.round(8 * dpr), fontSize - Math.round(2 * dpr))}px "JetBrains Mono", "SF Mono", monospace`
|
||||
ctx.fillStyle = this.options.labelColor
|
||||
ctx.fillText(displayLufs, x + barWidth / 2, barAreaBottom + Math.round(4 * dpr))
|
||||
}
|
||||
|
||||
// Target reference line (-14 LUFS)
|
||||
const targetNorm = Math.max(0, Math.min(1, (TARGET_LUFS - METER_MIN_LUFS) / dbRange))
|
||||
const targetY = Math.round(barAreaBottom - targetNorm * barAreaHeight)
|
||||
ctx.strokeStyle = this.options.targetColor
|
||||
ctx.lineWidth = Math.max(1, dpr)
|
||||
ctx.setLineDash([Math.round(4 * dpr), Math.round(3 * dpr)])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(scaleWidth, targetY)
|
||||
ctx.lineTo(scaleWidth + barCount * barWidth + (barCount - 1) * barGap, targetY)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
return {
|
||||
text: candidates[candidates.length - 1] ?? '',
|
||||
fontSize: minFontSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Scale markings on left
|
||||
const scaleFont = Math.max(Math.round(7 * dpr), Math.round(9 * dpr))
|
||||
ctx.font = `400 ${scaleFont}px "JetBrains Mono", "SF Mono", monospace`
|
||||
private drawFastPeakBar(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
levelDb: number,
|
||||
peakDb: number,
|
||||
tint: { r: number; g: number; b: number },
|
||||
dpr: number,
|
||||
): void {
|
||||
const ctx = this.ctx
|
||||
const barBottom = y + height
|
||||
const levelHeight = Math.round(this.compactDbToNormalized(levelDb) * height)
|
||||
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, y, width, height)
|
||||
|
||||
if (levelHeight > 0) {
|
||||
ctx.fillStyle = colorWithAlpha(tint.r, tint.g, tint.b, 0.88)
|
||||
ctx.fillRect(x, barBottom - levelHeight, width, levelHeight)
|
||||
}
|
||||
|
||||
const peakNorm = this.compactDbToNormalized(peakDb)
|
||||
if (peakNorm > 0.001) {
|
||||
const peakY = Math.round(barBottom - peakNorm * height)
|
||||
ctx.fillStyle = `rgb(${tint.r}, ${tint.g}, ${tint.b})`
|
||||
ctx.fillRect(x, peakY, width, Math.max(1, Math.round(2 * dpr)))
|
||||
}
|
||||
}
|
||||
|
||||
private drawBars(width: number, height: number): void {
|
||||
const ctx = this.ctx
|
||||
const tint = resolveColorToRgb(this.options.lineColor)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
const paddingX = Math.max(Math.round(4 * dpr), Math.floor(width * 0.012))
|
||||
const paddingY = Math.max(Math.round(4 * dpr), Math.floor(height * 0.025))
|
||||
const meterTop = paddingY
|
||||
const meterBottom = height - paddingY
|
||||
const meterHeight = Math.max(1, meterBottom - meterTop)
|
||||
const scaleWidth = Math.max(Math.round(26 * dpr), Math.min(Math.round(42 * dpr), Math.floor(width * 0.16)))
|
||||
const barWidth = Math.max(Math.round(6 * dpr), Math.min(Math.round(14 * dpr), Math.floor(width * 0.04)))
|
||||
const barGap = Math.max(Math.round(3 * dpr), Math.floor(width * 0.012))
|
||||
const lufsBarGap = Math.max(Math.round(5 * dpr), Math.floor(width * 0.018))
|
||||
const lufsBarWidth = Math.max(Math.round(12 * dpr), Math.min(Math.round(28 * dpr), Math.floor(width * 0.07)))
|
||||
const tagGap = Math.max(Math.round(8 * dpr), Math.floor(width * 0.025))
|
||||
const leftBarX = paddingX + scaleWidth
|
||||
const rightBarX = leftBarX + barWidth + barGap
|
||||
const lufsBarX = rightBarX + barWidth + lufsBarGap
|
||||
const tagX = lufsBarX + lufsBarWidth + tagGap
|
||||
const tagWidth = Math.max(1, width - paddingX - tagX)
|
||||
const meterRight = tagX + tagWidth
|
||||
|
||||
this.drawFastPeakBar(
|
||||
leftBarX,
|
||||
meterTop,
|
||||
barWidth,
|
||||
meterHeight,
|
||||
this.fastSnapshot.barLDb,
|
||||
this.fastSnapshot.peakLDb,
|
||||
tint,
|
||||
dpr,
|
||||
)
|
||||
this.drawFastPeakBar(
|
||||
rightBarX,
|
||||
meterTop,
|
||||
barWidth,
|
||||
meterHeight,
|
||||
this.fastSnapshot.barRDb,
|
||||
this.fastSnapshot.peakRDb,
|
||||
tint,
|
||||
dpr,
|
||||
)
|
||||
|
||||
const selectedLufs = this.selectedLufs()
|
||||
const loudnessNorm = this.compactDbToNormalized(selectedLufs)
|
||||
const loudnessY = Math.round(meterBottom - loudnessNorm * meterHeight)
|
||||
const lufsBarHeight = Math.round(loudnessNorm * meterHeight)
|
||||
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(lufsBarX, meterTop, lufsBarWidth, meterHeight)
|
||||
if (lufsBarHeight > 0) {
|
||||
ctx.fillStyle = this.options.lineColor
|
||||
ctx.fillRect(lufsBarX, meterBottom - lufsBarHeight, lufsBarWidth, lufsBarHeight)
|
||||
ctx.fillRect(lufsBarX, loudnessY, lufsBarWidth, Math.max(1, Math.round(2 * dpr)))
|
||||
}
|
||||
|
||||
const targetY = Math.round(meterBottom - this.compactDbToNormalized(TARGET_LUFS) * meterHeight)
|
||||
ctx.fillStyle = this.options.targetColor
|
||||
ctx.fillRect(leftBarX, targetY, Math.max(1, meterRight - leftBarX), Math.max(1, Math.round(dpr)))
|
||||
|
||||
const tickValues = [0, -6, -12, -24, -36, -50]
|
||||
const tickMarkWidth = Math.max(4, Math.round(8 * dpr))
|
||||
const tickFontSize = Math.max(Math.round(8 * dpr), Math.min(Math.round(15 * dpr), Math.floor(height * 0.06)))
|
||||
ctx.font = `600 ${tickFontSize}px "JetBrains Mono", "SF Mono", monospace`
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = this.options.scaleColor
|
||||
|
||||
const tickValues = [-60, -48, -36, -24, -18, -14, -9, -6, -3, 0]
|
||||
for (const tick of tickValues) {
|
||||
const norm = (tick - METER_MIN_LUFS) / dbRange
|
||||
if (norm < 0 || norm > 1) continue
|
||||
const y = Math.round(barAreaBottom - norm * barAreaHeight)
|
||||
ctx.fillText(`${tick}`, scaleWidth - Math.round(4 * dpr), y)
|
||||
|
||||
// Tick mark
|
||||
ctx.fillRect(scaleWidth - Math.round(3 * dpr), y, Math.round(2 * dpr), Math.max(1, dpr))
|
||||
const y = Math.round(meterBottom - this.compactDbToNormalized(tick) * meterHeight)
|
||||
const labelY = Math.max(
|
||||
meterTop + tickFontSize / 2,
|
||||
Math.min(meterBottom - tickFontSize / 2, y),
|
||||
)
|
||||
ctx.fillText(`${Math.abs(tick)}`, paddingX + scaleWidth - Math.round(8 * dpr), labelY)
|
||||
ctx.fillRect(paddingX + scaleWidth - tickMarkWidth, y, tickMarkWidth, Math.max(1, Math.round(dpr)))
|
||||
}
|
||||
|
||||
const displayValue = selectedLufs <= METER_MIN_LUFS + 1
|
||||
? '-∞'
|
||||
: selectedLufs.toFixed(1)
|
||||
const displayCandidates = [
|
||||
`${displayValue}LUFS`,
|
||||
displayValue,
|
||||
]
|
||||
const tagHeight = Math.min(
|
||||
meterHeight,
|
||||
Math.max(Math.round(22 * dpr), Math.min(Math.round(34 * dpr), Math.floor(height * 0.16))),
|
||||
)
|
||||
const tagY = Math.round(Math.max(meterTop, Math.min(meterBottom - tagHeight, loudnessY - tagHeight / 2)))
|
||||
const tagPadding = Math.max(Math.round(4 * dpr), Math.min(Math.round(8 * dpr), Math.floor(tagWidth * 0.08)))
|
||||
const maxReadoutFontSize = Math.max(
|
||||
Math.round(10 * dpr),
|
||||
Math.min(Math.round(20 * dpr), Math.floor(tagHeight * 0.52)),
|
||||
)
|
||||
const minReadoutFontSize = Math.min(maxReadoutFontSize, Math.max(Math.round(7 * dpr), Math.floor(tagHeight * 0.38)))
|
||||
const readoutLayout = this.resolveReadoutTextLayout(
|
||||
displayCandidates,
|
||||
Math.max(1, tagWidth - tagPadding * 2),
|
||||
maxReadoutFontSize,
|
||||
minReadoutFontSize,
|
||||
)
|
||||
|
||||
ctx.fillStyle = this.options.lineColor
|
||||
ctx.fillRect(tagX, tagY, tagWidth, tagHeight)
|
||||
|
||||
ctx.font = `800 ${readoutLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace`
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = this.contrastForLevelColor()
|
||||
ctx.fillText(readoutLayout.text, tagX + tagPadding, tagY + tagHeight / 2)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../types/profile'
|
||||
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope'
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
import { isLUFSMeterReadout } from '../types/lufsmeter'
|
||||
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
|
||||
import { clampWaveformScrollSpeed } from '../types/waveform'
|
||||
|
||||
@@ -138,6 +139,9 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null
|
||||
? parsed.waveform
|
||||
: {}
|
||||
const rawLUFSMeter: Partial<ScopeSettings['lufsmeter']> = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null
|
||||
? parsed.lufsmeter
|
||||
: {}
|
||||
const rawNowPlaying: Partial<ScopeSettings['nowPlaying']> = typeof legacyParsed.nowPlaying === 'object' && legacyParsed.nowPlaying !== null
|
||||
? legacyParsed.nowPlaying
|
||||
: (typeof legacyParsed.astra === 'object' && legacyParsed.astra !== null ? legacyParsed.astra : {})
|
||||
@@ -152,7 +156,13 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
||||
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
|
||||
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
|
||||
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
|
||||
lufsmeter: {
|
||||
...DEFAULT_SCOPE_SETTINGS.lufsmeter,
|
||||
...rawLUFSMeter,
|
||||
readout: isLUFSMeterReadout(rawLUFSMeter.readout)
|
||||
? rawLUFSMeter.readout
|
||||
: DEFAULT_SCOPE_SETTINGS.lufsmeter.readout,
|
||||
},
|
||||
waveform: {
|
||||
...DEFAULT_SCOPE_SETTINGS.waveform,
|
||||
mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono'
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
export type LUFSMeterMode = 'bar'
|
||||
export type LUFSMeterReadout = 'integrated' | 'shortTerm' | 'momentary'
|
||||
|
||||
export const LUFS_METER_MODES: readonly LUFSMeterMode[] = ['bar']
|
||||
export const LUFS_METER_READOUTS: readonly LUFSMeterReadout[] = ['integrated', 'shortTerm', 'momentary']
|
||||
|
||||
export const DEFAULT_LUFS_METER_MODE: LUFSMeterMode = 'bar'
|
||||
export const DEFAULT_LUFS_METER_READOUT: LUFSMeterReadout = 'shortTerm'
|
||||
|
||||
export function isLUFSMeterMode(value: unknown): value is LUFSMeterMode {
|
||||
return typeof value === 'string' && LUFS_METER_MODES.includes(value as LUFSMeterMode)
|
||||
}
|
||||
|
||||
export function isLUFSMeterReadout(value: unknown): value is LUFSMeterReadout {
|
||||
return typeof value === 'string' && LUFS_METER_READOUTS.includes(value as LUFSMeterReadout)
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
vectorscope: 'Vectorscope',
|
||||
spectrogram: 'Spectrogram',
|
||||
vumeter: 'VU Meter',
|
||||
lufsmeter: 'LUFS Meter',
|
||||
lufsmeter: 'Loudness Meter',
|
||||
waveform: 'Waveform',
|
||||
nowPlaying: 'Now Playing',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
|
||||
import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram'
|
||||
import type { VUMeterMode, VUMeterOrientation } from './vumeter'
|
||||
import type { LUFSMeterMode } from './lufsmeter'
|
||||
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'
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ScopeSettings {
|
||||
}
|
||||
lufsmeter: {
|
||||
mode: LUFSMeterMode
|
||||
readout: LUFSMeterReadout
|
||||
}
|
||||
waveform: {
|
||||
mode: WaveformMode
|
||||
@@ -66,7 +67,7 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
|
||||
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' },
|
||||
lufsmeter: { mode: 'bar' },
|
||||
lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT },
|
||||
waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, multiband: false },
|
||||
nowPlaying: {
|
||||
showCoverArt: true,
|
||||
|
||||
@@ -207,6 +207,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false)
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.heatmapSmoothing, 0.5)
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat')
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.lufsmeter.readout, 'shortTerm')
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.mode, 'stereo')
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.scrollSpeed, 2)
|
||||
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.multiband, true)
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
resolveMainWindowSettingsHeight,
|
||||
resolveMainWindowSettingsPanelHeight,
|
||||
} from '../src/renderer/mainWindowSettings'
|
||||
import {
|
||||
LOCKED_LOUDNESS_METER_WIDTH_PX,
|
||||
buildAnalyzerGridTemplateColumns,
|
||||
} from '../src/renderer/analyzerLayout'
|
||||
import {
|
||||
formatAstraTime,
|
||||
getAstraPlaybackProgress,
|
||||
@@ -532,6 +536,7 @@ function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8):
|
||||
|
||||
interface FakeCanvasRecorder {
|
||||
fillRects: Array<{ x: number; y: number; width: number; height: number; fillStyle: string }>
|
||||
fillTexts: Array<{ text: string; x: number; y: number; fillStyle: string; font: string }>
|
||||
strokeRects: Array<{ x: number; y: number; width: number; height: number; lineDash: number[] }>
|
||||
arcs: Array<{
|
||||
x: number
|
||||
@@ -550,6 +555,7 @@ interface FakeCanvasRecorder {
|
||||
function createFakeCanvasRecorder(): FakeCanvasRecorder {
|
||||
return {
|
||||
fillRects: [],
|
||||
fillTexts: [],
|
||||
strokeRects: [],
|
||||
arcs: [],
|
||||
lineDashes: [],
|
||||
@@ -562,6 +568,7 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
let currentLineDash: number[] = []
|
||||
let currentFillStyle = ''
|
||||
let currentStrokeStyle = ''
|
||||
let currentFont = ''
|
||||
let currentCompositeOperation: GlobalCompositeOperation = 'source-over'
|
||||
|
||||
const context = {
|
||||
@@ -569,7 +576,9 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
fillRect(x: number, y: number, width: number, height: number) {
|
||||
recorder?.fillRects.push({ x, y, width, height, fillStyle: currentFillStyle })
|
||||
},
|
||||
fillText() {},
|
||||
fillText(text: string, x: number, y: number) {
|
||||
recorder?.fillTexts.push({ text: String(text), x, y, fillStyle: currentFillStyle, font: currentFont })
|
||||
},
|
||||
beginPath() {},
|
||||
closePath() {},
|
||||
fill() {},
|
||||
@@ -604,11 +613,18 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
addColorStop() {},
|
||||
} as CanvasGradient
|
||||
},
|
||||
measureText() {
|
||||
return { width: 0 } as TextMetrics
|
||||
measureText(text: string) {
|
||||
const fontSizeMatch = currentFont.match(/(\d+(?:\.\d+)?)px/)
|
||||
const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 12
|
||||
return { width: String(text).length * fontSize * 0.62 } as TextMetrics
|
||||
},
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
get font() {
|
||||
return currentFont
|
||||
},
|
||||
set font(value: string) {
|
||||
currentFont = value
|
||||
},
|
||||
textAlign: 'left',
|
||||
textBaseline: 'top',
|
||||
lineCap: 'butt',
|
||||
@@ -638,11 +654,15 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
|
||||
return context as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createFakeCanvas(recorder: FakeCanvasRecorder | null = null): HTMLCanvasElement {
|
||||
function createFakeCanvas(
|
||||
recorder: FakeCanvasRecorder | null = null,
|
||||
width = 320,
|
||||
height = 180,
|
||||
): HTMLCanvasElement {
|
||||
const context = createFakeCanvasContext(recorder)
|
||||
return {
|
||||
width: 320,
|
||||
height: 180,
|
||||
width,
|
||||
height,
|
||||
getContext: (kind: string) => kind === '2d' ? context : null,
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
@@ -1257,6 +1277,19 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
|
||||
])
|
||||
})
|
||||
|
||||
test('analyzer layout locks the loudness meter width', () => {
|
||||
const columns = buildAnalyzerGridTemplateColumns(
|
||||
['spectrum', 'lufsmeter', 'waveform'],
|
||||
{ spectrum: 1, lufsmeter: 0.15, waveform: 1 },
|
||||
)
|
||||
|
||||
assert.equal(LOCKED_LOUDNESS_METER_WIDTH_PX, 150)
|
||||
assert.equal(
|
||||
columns,
|
||||
`minmax(0, 1fr) minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px) minmax(0, 1fr)`,
|
||||
)
|
||||
})
|
||||
|
||||
test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
profile.scopeSettings.spectrum.showSideLine = true
|
||||
@@ -2182,6 +2215,7 @@ 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'
|
||||
const authoredTheme = createDefaultTheme()
|
||||
authoredTheme.scopes.background = 'rgb(6, 7, 8)'
|
||||
authoredTheme.vumeter.track = 'rgb(9, 10, 11)'
|
||||
@@ -2204,6 +2238,7 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe
|
||||
assert.equal(lufsmeter.targetColor, 'rgb(15, 16, 17)')
|
||||
assert.equal(lufsmeter.scaleColor, theme.lufsmeter.scale)
|
||||
assert.equal(lufsmeter.labelColor, theme.lufsmeter.labels)
|
||||
assert.equal(lufsmeter.readout, 'shortTerm')
|
||||
})
|
||||
|
||||
test('scopeSummary includes only waveform display modes', () => {
|
||||
@@ -2218,6 +2253,18 @@ test('scopeSummary includes only waveform display modes', () => {
|
||||
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Stereo · RGB')
|
||||
})
|
||||
|
||||
test('scopeSummary includes loudness readout source', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
|
||||
assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Short-term LUFS')
|
||||
|
||||
profile.scopeSettings.lufsmeter.readout = 'integrated'
|
||||
assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Integrated LUFS')
|
||||
|
||||
profile.scopeSettings.lufsmeter.readout = 'momentary'
|
||||
assert.equal(scopeSummary('lufsmeter', profile.scopeSettings.lufsmeter), 'Momentary LUFS')
|
||||
})
|
||||
|
||||
test('scopeSummary includes spectrum peak mode when enabled', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
|
||||
@@ -3504,6 +3551,123 @@ test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => {
|
||||
assert.notEqual(pointTarget.low.left[0], 0)
|
||||
})
|
||||
|
||||
test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => {
|
||||
const dom = installFakeCanvasDom()
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const leftChunk = new Float32Array(4800)
|
||||
const rightChunk = new Float32Array(4800)
|
||||
for (let index = 0; index < leftChunk.length; index += 1) {
|
||||
const sample = index % 2 === 0 ? 0.55 : -0.55
|
||||
leftChunk[index] = sample
|
||||
rightChunk[index] = sample * 0.75
|
||||
}
|
||||
|
||||
let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [
|
||||
{ left: leftChunk, right: rightChunk },
|
||||
]
|
||||
const dataSource = {
|
||||
getPendingLUFSMeterSamples: () => {
|
||||
const drained = pendingChunks
|
||||
pendingChunks = []
|
||||
return drained
|
||||
},
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => true,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
const meter = new LUFSMeter(createFakeCanvas(recorder), {
|
||||
dataSource,
|
||||
readout: 'momentary',
|
||||
lineColor: 'rgb(255, 0, 96)',
|
||||
trackColor: 'rgba(255, 0, 96, 0.08)',
|
||||
targetColor: 'rgb(1, 2, 3)',
|
||||
scaleColor: 'rgb(4, 5, 6)',
|
||||
labelColor: 'rgb(7, 8, 9)',
|
||||
})
|
||||
|
||||
try {
|
||||
;(meter as unknown as { drawFrame: () => void }).drawFrame()
|
||||
|
||||
const trackRects = recorder.fillRects.filter((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.08)')
|
||||
assert.equal(trackRects.length >= 3, true)
|
||||
assert.equal(trackRects.some((rect) => rect.width > 12), true)
|
||||
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.88)'), true)
|
||||
assert.equal(
|
||||
recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 12 && rect.width < 80),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 120 && rect.height <= 34),
|
||||
true,
|
||||
)
|
||||
assert.equal(recorder.fillRects.some((rect) => rect.fillStyle === 'rgb(1, 2, 3)'), true)
|
||||
|
||||
for (const scaleLabel of ['0', '6', '12', '24', '36', '50']) {
|
||||
assert.equal(
|
||||
recorder.fillTexts.some((text) => text.text === scaleLabel && text.fillStyle === 'rgb(4, 5, 6)'),
|
||||
true,
|
||||
)
|
||||
}
|
||||
assert.equal(
|
||||
recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'),
|
||||
true,
|
||||
)
|
||||
} finally {
|
||||
meter.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('LUFSMeter fits readout text inside narrow tags', () => {
|
||||
const dom = installFakeCanvasDom()
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
const leftChunk = new Float32Array(4800)
|
||||
const rightChunk = new Float32Array(4800)
|
||||
leftChunk.fill(0.55)
|
||||
rightChunk.fill(0.55)
|
||||
|
||||
let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [
|
||||
{ left: leftChunk, right: rightChunk },
|
||||
]
|
||||
const dataSource = {
|
||||
getPendingLUFSMeterSamples: () => {
|
||||
const drained = pendingChunks
|
||||
pendingChunks = []
|
||||
return drained
|
||||
},
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => true,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), {
|
||||
dataSource,
|
||||
readout: 'momentary',
|
||||
lineColor: 'rgb(255, 0, 96)',
|
||||
trackColor: 'rgba(255, 0, 96, 0.08)',
|
||||
targetColor: 'rgb(1, 2, 3)',
|
||||
scaleColor: 'rgb(4, 5, 6)',
|
||||
labelColor: 'rgb(7, 8, 9)',
|
||||
})
|
||||
|
||||
try {
|
||||
;(meter as unknown as { drawFrame: () => void }).drawFrame()
|
||||
|
||||
const tagRect = recorder.fillRects.find((rect) => (
|
||||
rect.fillStyle === 'rgb(255, 0, 96)' && rect.width > 50 && rect.height <= 34
|
||||
))
|
||||
const readout = recorder.fillTexts.find((text) => text.text.endsWith('LUFS'))
|
||||
assert.ok(tagRect)
|
||||
assert.ok(readout)
|
||||
|
||||
const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
|
||||
const estimatedTextWidth = readout.text.length * fontSize * 0.62
|
||||
assert.equal(estimatedTextWidth <= tagRect.width - 8, true)
|
||||
} finally {
|
||||
meter.dispose()
|
||||
dom.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('LUFSMeter keeps integrated history bounded over long runs', () => {
|
||||
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = []
|
||||
const dataSource = {
|
||||
|
||||
Reference in New Issue
Block a user