From 98ebe3fc8f383b482af004344b2c4dff611fcbc8 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 5 May 2026 16:47:59 -0400 Subject: [PATCH] Improved LUFS meter --- README.md | 2 +- src/renderer/analyzerLayout.ts | 5 + src/renderer/components/BottomBar.tsx | 2 +- src/renderer/components/ScopeModule.tsx | 1 + .../components/ScopeSettingsSection.tsx | 23 +- src/renderer/visualizers/LUFSMeter.ts | 314 +++++++++++++----- src/shared/profileState.ts | 12 +- src/types/lufsmeter.ts | 7 + src/types/scope.ts | 2 +- src/types/settings.ts | 5 +- test/profile-library.test.ts | 1 + test/renderer-helpers.test.ts | 178 +++++++++- 12 files changed, 449 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index b115159..723253d 100644 --- a/README.md +++ b/README.md @@ -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 ![Prism scopes](assets/prism-demo-12fps.gif) diff --git a/src/renderer/analyzerLayout.ts b/src/renderer/analyzerLayout.ts index 1329b7b..b769944 100644 --- a/src/renderer/analyzerLayout.ts +++ b/src/renderer/analyzerLayout.ts @@ -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)` } diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index b48b772..f01d6ed 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -17,7 +17,7 @@ const SCOPE_LABELS: Record = { vectorscope: 'Vectorscope', spectrogram: 'Spectrogram', vumeter: 'VU Meter', - lufsmeter: 'LUFS Meter', + lufsmeter: 'Loudness Meter', waveform: 'Waveform', nowPlaying: 'Now Playing', } diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 5aedc42..d773a11 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -279,6 +279,7 @@ export function scopeSettingsToOptions( scaleColor: t.scale, labelColor: t.labels, mode: s.mode, + readout: s.readout, } } case 'waveform': { diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 2961589..2e4da68 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -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 ( onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })} + label="Readout" + value={current.readout} + onChange={(value) => onUpdate('lufsmeter', { readout: value as ScopeSettings['lufsmeter']['readout'] })} > - + + + ) })()} diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 25bcf07..fddf5be 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -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 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 { diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 98b5c82..a27f9f7 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -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 = typeof parsed.waveform === 'object' && parsed.waveform !== null ? parsed.waveform : {} + const rawLUFSMeter: Partial = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null + ? parsed.lufsmeter + : {} const rawNowPlaying: Partial = 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' diff --git a/src/types/lufsmeter.ts b/src/types/lufsmeter.ts index 3bc31a3..bef0471 100644 --- a/src/types/lufsmeter.ts +++ b/src/types/lufsmeter.ts @@ -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) +} diff --git a/src/types/scope.ts b/src/types/scope.ts index 151237d..bd29b0d 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -51,7 +51,7 @@ export const SCOPE_LABELS: Record = { vectorscope: 'Vectorscope', spectrogram: 'Spectrogram', vumeter: 'VU Meter', - lufsmeter: 'LUFS Meter', + lufsmeter: 'Loudness Meter', waveform: 'Waveform', nowPlaying: 'Now Playing', } diff --git a/src/types/settings.ts b/src/types/settings.ts index f812ee1..7d19d65 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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, diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 0a5d1a0..69d399e 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -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) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 7688e6b..9637bd2 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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 = {