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', () => {