mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
Accuracy in LUFS/VU, configureable VU target
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user