From 9dfd5732a406d76af971565274932e030a5d5e1c Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 7 May 2026 03:44:45 -0400 Subject: [PATCH] Accuracy in LUFS/VU, configureable VU target --- src/renderer/components/ScopeModule.tsx | 1 + .../components/ScopeSettingsSection.tsx | 67 ++++++++++- src/renderer/visualizers/LUFSMeter.ts | 104 +++++++++--------- src/renderer/visualizers/VUMeter.ts | 76 +++++++------ src/shared/profileState.ts | 3 +- src/types/settings.ts | 5 +- src/types/vumeter.ts | 34 ++++++ test/renderer-helpers.test.ts | 26 ++++- 8 files changed, 218 insertions(+), 98 deletions(-) diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 0331280..e817c61 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -270,6 +270,7 @@ export function scopeSettingsToOptions( mode: s.mode, orientation: s.orientation, needleChannels: s.needleChannels, + referenceDb: s.referenceDb, } } case 'lufsmeter': { diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index f200a46..64534d2 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -2,6 +2,14 @@ import type { CSSProperties, JSX, ReactNode } from 'react' import type { ScopeKind } from '../../types/scope' import { SCOPE_LABELS } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import { + DEFAULT_VU_REFERENCE_DBFS, + VU_REFERENCE_MAX_DBFS, + VU_REFERENCE_MIN_DBFS, + VU_REFERENCE_PRESETS, + findVUReferencePreset, + sanitizeVUReferenceDbfs, +} from '../../types/vumeter' import ThemedSelect from './ThemedSelect' function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { @@ -74,9 +82,14 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] } case 'vumeter': { const scopeSettings = settings as ScopeSettings['vumeter'] - return scopeSettings.mode === 'needle' + const matchedPreset = findVUReferencePreset(scopeSettings.referenceDb) + const refLabel = matchedPreset + ? matchedPreset.label + : `${scopeSettings.referenceDb.toFixed(1)} dBFS` + const base = scopeSettings.mode === 'needle' ? `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.needleChannels.toUpperCase()}` : `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}` + return `${base} · ${refLabel}` } case 'lufsmeter': return `${lufsReadoutLabel((settings as ScopeSettings['lufsmeter']).readout)} LUFS` @@ -502,6 +515,58 @@ export default function ScopeSettingsSection({ )} + + {(() => { + const matchedPreset = findVUReferencePreset(current.referenceDb) + const selectValue = matchedPreset ? matchedPreset.id : 'custom' + return ( + <> + { + if (value === 'custom') { + // Stay on the current value; the slider below takes over. + return + } + const preset = VU_REFERENCE_PRESETS.find((entry) => entry.id === value) + if (preset) { + onUpdate('vumeter', { referenceDb: preset.dbfs }) + } + }} + > + {VU_REFERENCE_PRESETS.map((preset) => ( + + ))} + + + + {selectValue === 'custom' && ( + onUpdate('vumeter', { referenceDb: sanitizeVUReferenceDbfs(value) })} + /> + )} + + ) + })()} + + {current.referenceDb !== DEFAULT_VU_REFERENCE_DBFS && ( + + onUpdate('vumeter', { referenceDb: DEFAULT_VU_REFERENCE_DBFS })} + /> + + )} ) })()} diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index fddf5be..4ce705d 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -92,43 +92,57 @@ const INITIAL_VU_SNAPSHOT: VUMeterSnapshot = { correlation: 0, } -// ---- K-weighting filter coefficients (ITU-R BS.1770) ---- +// ---- K-weighting filter coefficients (ITU-R BS.1770-4) ---- interface BiquadCoeffs { b0: number; b1: number; b2: number a1: number; a2: number } -// Pre-filter (high shelf) — 48kHz -const PRE_FILTER_48K: BiquadCoeffs = { - b0: 1.53512485958697, b1: -2.69169618940638, b2: 1.19839281085285, - a1: -1.69065929318241, a2: 0.73248077421585, +// BS.1770-4 reference design parameters. The values below reproduce the +// standard's reference coefficients at 48 kHz to within 1e-5 and remain +// accurate at any sample rate (44.1k, 48k, 88.2k, 96k, 192k) via the +// bilinear transform with frequency pre-warping. Derivation follows the +// canonical analog prototype used by the ITU reference and pyloudnorm. +const PRE_FILTER_F0_HZ = 1681.9744509555319 +const PRE_FILTER_GAIN_DB = 3.999843853973347 +const PRE_FILTER_Q = 0.7071752369554193 +const RLB_FILTER_F0_HZ = 38.13547087613982 +const RLB_FILTER_Q = 0.5003270373223665 + +function preFilterCoeffs(sampleRate: number): BiquadCoeffs { + const K = Math.tan(Math.PI * PRE_FILTER_F0_HZ / sampleRate) + const Vh = Math.pow(10, PRE_FILTER_GAIN_DB / 20) + const Vb = Math.pow(Vh, 0.499666774155997) + const KK = K * K + const a0 = 1 + K / PRE_FILTER_Q + KK + return { + b0: (Vh + (Vb * K) / PRE_FILTER_Q + KK) / a0, + b1: (2 * (KK - Vh)) / a0, + b2: (Vh - (Vb * K) / PRE_FILTER_Q + KK) / a0, + a1: (2 * (KK - 1)) / a0, + a2: (1 - K / PRE_FILTER_Q + KK) / a0, + } } -// RLB weighting (high pass) — 48kHz -const RLB_FILTER_48K: BiquadCoeffs = { - b0: 1.0, b1: -2.0, b2: 1.0, - a1: -1.99004745483398, a2: 0.99007225036621, -} - -// Pre-filter — 44.1kHz -const PRE_FILTER_44K: BiquadCoeffs = { - b0: 1.5308412300498355, b1: -2.6509799951536985, b2: 1.1690790799210956, - a1: -1.6636551132560204, a2: 0.7125954280732254, -} - -// RLB — 44.1kHz -const RLB_FILTER_44K: BiquadCoeffs = { - b0: 1.0, b1: -2.0, b2: 1.0, - a1: -1.9891696736297957, a2: 0.9891990357870394, +function rlbFilterCoeffs(sampleRate: number): BiquadCoeffs { + const K = Math.tan(Math.PI * RLB_FILTER_F0_HZ / sampleRate) + const KK = K * K + const a0 = 1 + K / RLB_FILTER_Q + KK + return { + b0: 1, + b1: -2, + b2: 1, + a1: (2 * (KK - 1)) / a0, + a2: (1 - K / RLB_FILTER_Q + KK) / a0, + } } function getKWeightingCoeffs(sampleRate: number): { pre: BiquadCoeffs; rlb: BiquadCoeffs } { - if (Math.abs(sampleRate - 44100) < 100) { - return { pre: PRE_FILTER_44K, rlb: RLB_FILTER_44K } + return { + pre: preFilterCoeffs(sampleRate), + rlb: rlbFilterCoeffs(sampleRate), } - // Default to 48kHz (also reasonable approximation for 96kHz, etc.) - return { pre: PRE_FILTER_48K, rlb: RLB_FILTER_48K } } // ---- Biquad filter state ---- @@ -185,10 +199,8 @@ export class LUFSMeter { private ringBufferPos = 0 private ringBufferFilled = 0 // how many samples have been written total (capped at buffer size) - // Integrated loudness: accumulate 400ms block mean-squares with 100ms hop - private integratedBlockSumL = 0 - private integratedBlockSumR = 0 - private integratedBlockSamples = 0 + // Integrated loudness: emit one 400ms block per 100ms hop, summed directly + // from the K-weighted ring buffer (BS.1770-4 overlapping-block method). private integratedHopCounter = 0 private integratedHistogramCounts = new Uint32Array(INTEGRATED_HISTOGRAM_BIN_COUNT) private integratedHistogramPowerSums = new Float64Array(INTEGRATED_HISTOGRAM_BIN_COUNT) @@ -250,9 +262,6 @@ export class LUFSMeter { this.ringBufferR.fill(0) this.ringBufferPos = 0 this.ringBufferFilled = 0 - this.integratedBlockSumL = 0 - this.integratedBlockSumR = 0 - this.integratedBlockSamples = 0 this.integratedHopCounter = 0 this.integratedHistogramCounts.fill(0) this.integratedHistogramPowerSums.fill(0) @@ -337,30 +346,25 @@ export class LUFSMeter { this.ringBufferPos = (this.ringBufferPos + 1) % bufLen if (this.ringBufferFilled < bufLen) this.ringBufferFilled++ - // Accumulate for integrated measurement - this.integratedBlockSumL += sqL - this.integratedBlockSumR += sqR - this.integratedBlockSamples++ this.integratedHopCounter++ - // Every hop interval, store a block loudness value - if (this.integratedHopCounter >= hopSamples && this.integratedBlockSamples >= blockSamples) { - const meanSqL = this.integratedBlockSumL / this.integratedBlockSamples - const meanSqR = this.integratedBlockSumR / this.integratedBlockSamples - const blockPower = Math.max(meanSqL + meanSqR, 1e-10) + // Every hop interval (100ms), emit one 400ms block computed from + // the ring buffer per BS.1770-4 overlapping-block method. + if (this.integratedHopCounter >= hopSamples && this.ringBufferFilled >= blockSamples) { + let sumL = 0 + let sumR = 0 + for (let j = 0; j < blockSamples; j++) { + const idx = (this.ringBufferPos - 1 - j + bufLen) % bufLen + sumL += this.ringBufferL[idx] + sumR += this.ringBufferR[idx] + } + const blockPower = Math.max(sumL / blockSamples + sumR / blockSamples, 1e-10) const blockLUFS = -0.691 + 10 * Math.log10(blockPower) if (blockLUFS > ABSOLUTE_GATE_LUFS) { const histogramIndex = histogramIndexFromLufs(blockLUFS) this.integratedHistogramCounts[histogramIndex] += 1 this.integratedHistogramPowerSums[histogramIndex] += blockPower } - - // Slide the block window: remove oldest hop worth of samples - // Approximate by keeping a running sum and subtracting the hop fraction - const hopFraction = hopSamples / this.integratedBlockSamples - this.integratedBlockSumL *= (1 - hopFraction) - this.integratedBlockSumR *= (1 - hopFraction) - this.integratedBlockSamples = Math.round(this.integratedBlockSamples * (1 - hopFraction)) this.integratedHopCounter = 0 } } @@ -383,7 +387,7 @@ export class LUFSMeter { sumR += this.ringBufferR[idx] } const rawM = -0.691 + 10 * Math.log10(Math.max(sumL / momentarySamples + sumR / momentarySamples, 1e-10)) - this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + Math.max(METER_MIN_LUFS, rawM) * (1 - SMOOTHING) + this.momentaryLUFS = Math.max(METER_MIN_LUFS, rawM) } // Compute short-term loudness (last 3s) @@ -399,7 +403,7 @@ export class LUFSMeter { sumR += this.ringBufferR[idx] } const rawS = -0.691 + 10 * Math.log10(Math.max(sumL / shortTermSamples + sumR / shortTermSamples, 1e-10)) - this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + Math.max(METER_MIN_LUFS, rawS) * (1 - SMOOTHING) + this.shortTermLUFS = Math.max(METER_MIN_LUFS, rawS) } // Compute integrated loudness with gating diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index e17bc51..9940d00 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -12,6 +12,7 @@ import { import { DEFAULT_VU_METER_NEEDLE_CHANNELS, DEFAULT_VU_METER_ORIENTATION, + DEFAULT_VU_REFERENCE_DBFS, type VUMeterNeedleChannels, type VUMeterMode, type VUMeterOrientation, @@ -35,6 +36,7 @@ export interface VUMeterOptions { needleRightColor?: string needleCombinedColor?: string needleChannels?: VUMeterNeedleChannels + referenceDb?: number dataSource?: VUMeterDataSource frameScheduler?: FrameScheduler } @@ -55,6 +57,7 @@ const defaultOptions: ResolvedVUMeterOptions = { needleRightColor: '#ff477e', needleCombinedColor: '#f4f8ff', needleChannels: DEFAULT_VU_METER_NEEDLE_CHANNELS, + referenceDb: DEFAULT_VU_REFERENCE_DBFS, } const defaultVUMeterDataSource: VUMeterDataSource = { @@ -165,11 +168,11 @@ export interface VUNeedleReading { peakVu: number } -export function dbfsToClassicVu(db: number): number { +export function dbfsToClassicVu(db: number, referenceDb: number = DEFAULT_VU_REFERENCE_DBFS): number { if (!Number.isFinite(db)) { return CLASSIC_VU_MIN } - return clamp(db + 6, CLASSIC_VU_MIN, CLASSIC_VU_MAX) + return clamp(db - referenceDb, CLASSIC_VU_MIN, CLASSIC_VU_MAX) } export function classicVuToNormalized(vu: number): number { @@ -206,12 +209,14 @@ export function resolveVUNeedleReadings({ 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) @@ -220,8 +225,8 @@ export function resolveVUNeedleReadings({ id: 'combined', db, peakDb, - vu: dbfsToClassicVu(db), - peakVu: dbfsToClassicVu(peakDb), + vu: dbfsToClassicVu(db, referenceDb), + peakVu: dbfsToClassicVu(peakDb, referenceDb), }] } @@ -230,15 +235,15 @@ export function resolveVUNeedleReadings({ id: 'left', db: leftDb, peakDb: leftPeakDb, - vu: dbfsToClassicVu(leftDb), - peakVu: dbfsToClassicVu(leftPeakDb), + vu: dbfsToClassicVu(leftDb, referenceDb), + peakVu: dbfsToClassicVu(leftPeakDb, referenceDb), }, { id: 'right', db: rightDb, peakDb: rightPeakDb, - vu: dbfsToClassicVu(rightDb), - peakVu: dbfsToClassicVu(rightPeakDb), + vu: dbfsToClassicVu(rightDb, referenceDb), + peakVu: dbfsToClassicVu(rightPeakDb, referenceDb), }, ] } @@ -257,8 +262,6 @@ export class VUMeter { // Meter state private vuL = VU_METER_MIN_DB private vuR = VU_METER_MIN_DB - private barL = VU_METER_MIN_DB - private barR = VU_METER_MIN_DB private peakL = VU_METER_MIN_DB private peakR = VU_METER_MIN_DB private correlation = 0 @@ -333,8 +336,6 @@ export class VUMeter { private applySnapshot(snapshot: VUMeterSnapshot): void { this.vuL = snapshot.vuLDb this.vuR = snapshot.vuRDb - this.barL = snapshot.barLDb - this.barR = snapshot.barRDb this.peakL = snapshot.peakLDb this.peakR = snapshot.peakRDb this.correlation = snapshot.correlation @@ -403,7 +404,9 @@ export class VUMeter { } private dbToNormalized(db: number): number { - return Math.max(0, Math.min(1, (db - VU_METER_MIN_DB) / (VU_METER_MAX_DB - VU_METER_MIN_DB))) + // 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 { @@ -434,15 +437,15 @@ export class VUMeter { // ---- L meter ---- const lY = topOffset - this.drawHorizontalMeterBar(ctx, barLeft, lY, barWidth, meterHeight, this.barL, this.peakL, cr, cg, cb) + 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.barL) + 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.barR, this.peakR, cr, cg, cb) + 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.barR) + this.drawDbLabel(ctx, barRight + 4, rY, dbLabelWidth, meterHeight, this.vuR) // ---- Correlation meter ---- const corrY = rY + meterHeight + gap @@ -475,12 +478,12 @@ export class VUMeter { const rX = meterLeft + meterWidth + channelGap this.drawMeterLabel(ctx, lX, 0, meterWidth, labelHeight, 'L') - this.drawVerticalMeterBar(ctx, lX, meterTop, meterWidth, meterHeight, this.barL, this.peakL, cr, cg, cb) - this.drawCenteredDbLabel(ctx, lX, dbY, meterWidth, dbHeight, this.barL) + 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.barR, this.peakR, cr, cg, cb) - this.drawCenteredDbLabel(ctx, rX, dbY, meterWidth, dbHeight, this.barR) + 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) } @@ -494,7 +497,7 @@ export class VUMeter { const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) const levelWidth = levelNorm * w - const hotThreshold = this.dbToNormalized(-6) * w + const hotThreshold = classicVuToNormalized(0) * w // Background track ctx.fillStyle = this.options.trackColor @@ -522,18 +525,18 @@ export class VUMeter { // Peak indicator line if (peakNorm > 0.001) { const peakX = x + peakNorm * w - const peakInHot = peakDb > -6 + 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 + // Scale ticks (VU units) ctx.fillStyle = this.options.scaleColor - const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] - for (const db of tickDbs) { - const tickX = x + this.dbToNormalized(db) * w + 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) } } @@ -547,7 +550,7 @@ export class VUMeter { const levelNorm = this.dbToNormalized(levelDb) const peakNorm = this.dbToNormalized(peakDb) const levelHeight = levelNorm * h - const hotThreshold = this.dbToNormalized(-6) * h + const hotThreshold = classicVuToNormalized(0) * h ctx.fillStyle = this.options.trackColor ctx.fillRect(x, y, w, h) @@ -571,7 +574,7 @@ export class VUMeter { if (peakNorm > 0.001) { const peakY = y + h - peakNorm * h - const peakInHot = peakDb > -6 + const peakInHot = dbfsToClassicVu(peakDb, this.options.referenceDb) > 0 ctx.fillStyle = peakInHot ? this.options.clipColor : this.options.peakColor @@ -579,9 +582,9 @@ export class VUMeter { } ctx.fillStyle = alphaColor(this.options.scaleColor, 0.84) - const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] - for (const db of tickDbs) { - const tickY = y + h - this.dbToNormalized(db) * h + 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) } } @@ -726,14 +729,9 @@ export class VUMeter { leftPeakDb: this.needlePeakL, rightPeakDb: this.needlePeakR, needleChannels: this.options.needleChannels, + referenceDb: this.options.referenceDb, }) - const readoutReadings = resolveVUNeedleReadings({ - leftDb: this.vuL, - rightDb: this.vuR, - leftPeakDb: this.needlePeakL, - rightPeakDb: this.needlePeakR, - needleChannels: this.options.needleChannels, - }) + const readoutReadings = visualReadings this.drawNeedleArcs( ctx, diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 9e26839..2a8612c 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -16,7 +16,7 @@ import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } fr import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings' import { isLUFSMeterReadout } from '../types/lufsmeter' import { normalizeSpectrumPeakInfoMode } from '../types/spectrum' -import { isVUMeterNeedleChannels } from '../types/vumeter' +import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter' import { clampWaveformScrollSpeed } from '../types/waveform' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] @@ -165,6 +165,7 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings { needleChannels: isVUMeterNeedleChannels(rawVUMeter.needleChannels) ? rawVUMeter.needleChannels : DEFAULT_SCOPE_SETTINGS.vumeter.needleChannels, + referenceDb: sanitizeVUReferenceDbfs(rawVUMeter.referenceDb), }, lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, diff --git a/src/types/settings.ts b/src/types/settings.ts index dadedd9..8144e8e 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,6 +1,6 @@ import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram' -import type { VUMeterMode, VUMeterNeedleChannels, VUMeterOrientation } from './vumeter' +import { DEFAULT_VU_REFERENCE_DBFS, type VUMeterMode, type VUMeterNeedleChannels, type VUMeterOrientation } from './vumeter' import { DEFAULT_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter' import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform' import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum' @@ -42,6 +42,7 @@ export interface ScopeSettings { mode: VUMeterMode orientation: VUMeterOrientation needleChannels: VUMeterNeedleChannels + referenceDb: number } lufsmeter: { mode: LUFSMeterMode @@ -67,7 +68,7 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, - vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo' }, + vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, multiband: false }, nowPlaying: { diff --git a/src/types/vumeter.ts b/src/types/vumeter.ts index a633fcb..7eb24d7 100644 --- a/src/types/vumeter.ts +++ b/src/types/vumeter.ts @@ -10,6 +10,27 @@ export const DEFAULT_VU_METER_MODE: VUMeterMode = 'bar' export const DEFAULT_VU_METER_ORIENTATION: VUMeterOrientation = 'horizontal' export const DEFAULT_VU_METER_NEEDLE_CHANNELS: VUMeterNeedleChannels = 'stereo' +// Reference-level calibration: 0 VU corresponds to this dBFS value. +export const DEFAULT_VU_REFERENCE_DBFS = -14 +export const VU_REFERENCE_MIN_DBFS = -30 +export const VU_REFERENCE_MAX_DBFS = 0 + +export interface VUReferencePreset { + readonly id: string + readonly label: string + readonly description: string + readonly dbfs: number +} + +export const VU_REFERENCE_PRESETS: readonly VUReferencePreset[] = [ + { id: 'k20', label: 'K-20', description: 'SMPTE film', dbfs: -20 }, + { id: 'k18', label: 'K-18', description: 'EBU broadcast', dbfs: -18 }, + { id: 'k14', label: 'K-14', description: 'Streaming', dbfs: -14 }, + { id: 'k12', label: 'K-12', description: 'Modern music', dbfs: -12 }, + { id: 'k10', label: 'K-10', description: 'Loud masters', dbfs: -10 }, + { id: 'k6', label: 'K-6', description: 'Hot / legacy', dbfs: -6 }, +] + export function isVUMeterMode(value: unknown): value is VUMeterMode { return typeof value === 'string' && VU_METER_MODES.includes(value as VUMeterMode) } @@ -21,3 +42,16 @@ export function isVUMeterOrientation(value: unknown): value is VUMeterOrientatio export function isVUMeterNeedleChannels(value: unknown): value is VUMeterNeedleChannels { return typeof value === 'string' && VU_METER_NEEDLE_CHANNELS.includes(value as VUMeterNeedleChannels) } + +export function sanitizeVUReferenceDbfs(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_VU_REFERENCE_DBFS + } + if (value < VU_REFERENCE_MIN_DBFS) return VU_REFERENCE_MIN_DBFS + if (value > VU_REFERENCE_MAX_DBFS) return VU_REFERENCE_MAX_DBFS + return value +} + +export function findVUReferencePreset(dbfs: number): VUReferencePreset | null { + return VU_REFERENCE_PRESETS.find((preset) => Math.abs(preset.dbfs - dbfs) < 1e-6) ?? null +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 7ad8061..265a19f 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -2259,8 +2259,10 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe }) test('VUMeter shared needle helpers map dBFS to a classic VU face', () => { - assert.equal(dbfsToClassicVu(-6), 0) - assert.equal(dbfsToClassicVu(0), 3) + // K-14: 0 VU = -14 dBFS + assert.equal(dbfsToClassicVu(-14), 0) + assert.equal(dbfsToClassicVu(-11), 3) + assert.equal(dbfsToClassicVu(-15), -1) assert.equal(dbfsToClassicVu(-40), -20) assertAlmostEqual(classicVuToNormalized(-20), 0, 1e-12, '-20 VU should start the scale') assertAlmostEqual(classicVuToNormalized(0), 0.81, 1e-12, '0 VU should sit near the hot zone') @@ -2316,13 +2318,27 @@ test('VUMeter shared needle helpers switch between stereo needles and combined R test('scopeSummary reports VU needle channel mode', () => { const profile = createDefaultProfile('Default') - assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'BAR · HORIZONTAL') + assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'BAR · HORIZONTAL · K-14') profile.scopeSettings.vumeter.mode = 'needle' - assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · STEREO') + assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · STEREO · K-14') profile.scopeSettings.vumeter.needleChannels = 'combined' - assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED') + assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED · K-14') + + profile.scopeSettings.vumeter.referenceDb = -13.5 + assert.equal(scopeSummary('vumeter', profile.scopeSettings.vumeter), 'NEEDLE · COMBINED · -13.5 dBFS') +}) + +test('VUMeter dBFS-to-VU mapping honors custom references', () => { + // Default (K-14) + assert.equal(dbfsToClassicVu(-14), 0) + // K-18 (broadcast) + assert.equal(dbfsToClassicVu(-18, -18), 0) + assert.equal(dbfsToClassicVu(-15, -18), 3) + // K-10 (loud masters) + assert.equal(dbfsToClassicVu(-10, -10), 0) + assert.equal(dbfsToClassicVu(-30, -10), -20) }) test('scopeSummary includes only waveform display modes', () => {