Accuracy in LUFS/VU, configureable VU target

This commit is contained in:
Boof2015
2026-05-07 03:44:45 -04:00
parent d614952a15
commit 9dfd5732a4
8 changed files with 218 additions and 98 deletions
+1
View File
@@ -270,6 +270,7 @@ export function scopeSettingsToOptions(
mode: s.mode,
orientation: s.orientation,
needleChannels: s.needleChannels,
referenceDb: s.referenceDb,
}
}
case 'lufsmeter': {
@@ -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({
<option value="combined">Combined</option>
</SelectControl>
)}
{(() => {
const matchedPreset = findVUReferencePreset(current.referenceDb)
const selectValue = matchedPreset ? matchedPreset.id : 'custom'
return (
<>
<SelectControl
label="Reference"
value={selectValue}
onChange={(value) => {
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) => (
<option key={preset.id} value={preset.id}>
{`${preset.label} (${preset.dbfs} dBFS) — ${preset.description}`}
</option>
))}
<option value="custom">Custom</option>
</SelectControl>
{selectValue === 'custom' && (
<RangeControl
label="Custom reference"
value={current.referenceDb}
valueLabel={`${current.referenceDb.toFixed(1)} dBFS`}
min={VU_REFERENCE_MIN_DBFS}
max={VU_REFERENCE_MAX_DBFS}
step={0.5}
onChange={(value) => onUpdate('vumeter', { referenceDb: sanitizeVUReferenceDbfs(value) })}
/>
)}
</>
)
})()}
{current.referenceDb !== DEFAULT_VU_REFERENCE_DBFS && (
<ToggleGroup label="Calibration">
<ToggleChip
label="Reset to default"
active={false}
onClick={() => onUpdate('vumeter', { referenceDb: DEFAULT_VU_REFERENCE_DBFS })}
/>
</ToggleGroup>
)}
</>
)
})()}
+54 -50
View File
@@ -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
+37 -39
View File
@@ -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,
+2 -1
View File
@@ -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,
+3 -2
View File
@@ -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: {
+34
View File
@@ -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
}
+21 -5
View File
@@ -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', () => {