From eda0fbef51ba5ff97ac71c123edcbbb219f7e55b Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:11:46 -0400 Subject: [PATCH] BS.1770 true-peak --- .github/workflows/main.yml | 3 + .github/workflows/release.yml | 3 + README.md | 12 +- native/src/lufsmeter.cpp | 170 ++++++++++--- native/src/lufsmeter.h | 20 +- native/src/main.cpp | 5 +- package.json | 2 + plugin/Source/LUFSMeterEngine.h | 11 +- scripts/run-ebu-true-peak-tests.mjs | 126 ++++++++++ scripts/run-tests.mjs | 1 + src/plugin-ui/BridgeLUFSMeterAnalyzer.ts | 2 +- src/plugin-ui/juceBridge.ts | 45 +++- src/renderer/audio/native/visualizer-dsp.d.ts | 5 +- src/renderer/visualizers/LUFSMeter.ts | 51 +++- test/lufsmeter-native.test.mjs | 234 ++++++++++++++++++ test/renderer-helpers.test.ts | 75 +++++- 16 files changed, 694 insertions(+), 71 deletions(-) create mode 100644 scripts/run-ebu-true-peak-tests.mjs create mode 100644 test/lufsmeter-native.test.mjs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6e12c10..fb3928c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -193,6 +193,9 @@ jobs: - name: Test native vectorscope calibration run: npm run test:vectorscope-native + - name: Test native BS.1770 true-peak calibration + run: npm run test:lufsmeter-native + - name: Build and test Prism TUI run: npm run test:tui diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7011835..8cf3513 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,9 @@ jobs: - name: Test native vectorscope calibration run: npm run test:vectorscope-native + - name: Test native BS.1770 true-peak calibration + run: npm run test:lufsmeter-native + - name: Build and test Prism TUI run: npm run test:tui diff --git a/README.md b/README.md index c01ef5b..58be4af 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Prism includes seven real-time scopes and meters: * **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB * **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes * **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays -* **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with stereo peak activity +* **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with BS.1770 true-peak activity * **Waveform** — Scrolling mono or stereo waveform with transient-preserving min/max sampling and optional multiband energy visualization Every scope can be configured independently. Resize and rearrange them into a rack, rotate supported scopes, pop them into separate windows, or pin them on top of other applications. @@ -98,7 +98,8 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r * −70 LUFS absolute gate * Relative gate at −10 LU below ungated programme loudness * Momentary, short-term, and integrated LUFS are calculated independently -* Peak readouts are sample peak, not true peak +* Stereo true-peak reconstruction uses a rate-aware 48-tap-per-phase FIR at 2× to 8× oversampling for common 44.1–192 kHz host rates +* Held L/R true-peak markers and a reset-scoped combined maximum are reported in dBTP ### VU and correlation @@ -277,12 +278,19 @@ npm run build # Build application assets npm run configure:tui # Configure the standalone CMake project npm run build:tui # Build prism-tui npm run test:tui # Build and run native TUI tests +npm run test:lufsmeter-native # Run generated BS.1770/EBU true-peak vectors npm run dist # Package for current platform npm run dist:mac # macOS npm run dist:win # Windows npm run dist:linux # Linux ``` +If you have downloaded and extracted the official [EBU Loudness Test Set](https://tech.ebu.ch/publications/ebu_loudness_test_set) for internal testing, run cases 15–23 without copying the WAVs into the repository. The files are not downloaded or redistributed by Prism, in accordance with the [EBU test-sequence usage terms](https://tech.ebu.ch/files/live/sites/tech/files/shared/testmaterial/use%20of%20EBU%20AUDIO%20test%20sequences.pdf). + +```bash +npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set +``` + The TUI build downloads the pinned FTXUI source through CMake. Linux also requires the PulseAudio development package. The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details. diff --git a/native/src/lufsmeter.cpp b/native/src/lufsmeter.cpp index 2f1274d..d01acb3 100644 --- a/native/src/lufsmeter.cpp +++ b/native/src/lufsmeter.cpp @@ -31,10 +31,37 @@ constexpr double RLB_FILTER_F0_HZ = 38.13547087613982; constexpr double RLB_FILTER_Q = 0.5003270373223665; constexpr double VU_INTEGRATION_WINDOW_MS = 300.0; -constexpr double VU_PEAK_HOLD_MS = 750.0; -constexpr double VU_PEAK_DECAY_DB_PER_SECOND = 18.0; +constexpr double TRUE_PEAK_HOLD_MS = 750.0; +constexpr double TRUE_PEAK_DECAY_DB_PER_SECOND = 18.0; constexpr double BAR_ATTACK_MS = 5.0; constexpr double BAR_RELEASE_MS = 180.0; +constexpr double TRUE_PEAK_MIN_OUTPUT_RATE_HZ = 320000.0; +constexpr size_t TRUE_PEAK_TAPS_PER_PHASE = 48; +constexpr size_t TRUE_PEAK_MAX_OVERSAMPLE_FACTOR = 64; +constexpr double TRUE_PEAK_KAISER_BETA = 8.6; + +double besselI0(double value) { + const double absoluteValue = std::abs(value); + if (absoluteValue < 3.75) { + const double ratio = value / 3.75; + const double squared = ratio * ratio; + return 1.0 + squared * (3.5156229 + squared * (3.0899424 + squared * (1.2067492 + + squared * (0.2659732 + squared * (0.0360768 + squared * 0.0045813))))); + } + + const double ratio = 3.75 / absoluteValue; + return (std::exp(absoluteValue) / std::sqrt(absoluteValue)) + * (0.39894228 + ratio * (0.01328592 + ratio * (0.00225319 + ratio * (-0.00157565 + + ratio * (0.00916281 + ratio * (-0.02057706 + ratio * (0.02635537 + + ratio * (-0.01647633 + ratio * 0.00392377)))))))); +} + +double normalizedSinc(double value) { + if (std::abs(value) < 1e-12) { + return 1.0; + } + return std::sin(PI * value) / (PI * value); +} LUFSMeterSnapshot makeInitialSnapshot() { return { @@ -47,6 +74,7 @@ LUFSMeterSnapshot makeInitialSnapshot() { static_cast(VU_METER_MIN_DB), static_cast(VU_METER_MIN_DB), static_cast(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), 0.0f, }; } @@ -82,6 +110,7 @@ void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) { integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0); configureFastMeter(); + configureTruePeakDetector(); reset(); } @@ -102,6 +131,44 @@ void LUFSMeterAnalyzer::configureFastMeter() { barReleaseCoeff_ = std::exp(-1.0 / (static_cast(sampleRate_) * (BAR_RELEASE_MS / 1000.0))); } +void LUFSMeterAnalyzer::configureTruePeakDetector() { + truePeakOversampleFactor_ = 1; + while (static_cast(sampleRate_) * truePeakOversampleFactor_ < TRUE_PEAK_MIN_OUTPUT_RATE_HZ + && truePeakOversampleFactor_ < TRUE_PEAK_MAX_OVERSAMPLE_FACTOR) { + truePeakOversampleFactor_ *= 2; + } + + truePeakCoefficients_.assign(truePeakOversampleFactor_ * TRUE_PEAK_TAPS_PER_PHASE, 0.0); + truePeakHistoryL_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0); + truePeakHistoryR_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0); + + const double windowRadius = static_cast(TRUE_PEAK_TAPS_PER_PHASE) / 2.0; + const double inverseI0Beta = 1.0 / besselI0(TRUE_PEAK_KAISER_BETA); + // Each phase is a causal fractional-delay reconstruction. The 24-sample + // delay keeps the 48 source taps centered without requiring future input. + for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) { + const double fraction = static_cast(phase) / truePeakOversampleFactor_; + double coefficientSum = 0.0; + for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) { + const double distance = static_cast(tap) - windowRadius + fraction; + const double normalizedDistance = distance / windowRadius; + const double windowArgument = std::sqrt(std::max(0.0, 1.0 - normalizedDistance * normalizedDistance)); + const double window = std::abs(normalizedDistance) <= 1.0 + ? besselI0(TRUE_PEAK_KAISER_BETA * windowArgument) * inverseI0Beta + : 0.0; + const double coefficient = normalizedSinc(distance) * window; + truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] = coefficient; + coefficientSum += coefficient; + } + + if (std::abs(coefficientSum) > 1e-12) { + for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) { + truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] /= coefficientSum; + } + } + } +} + void LUFSMeterAnalyzer::reset() { std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0); std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0); @@ -131,6 +198,10 @@ void LUFSMeterAnalyzer::reset() { lastPeakUpdateMs_ = 0.0; hasLastPeakUpdate_ = false; + std::fill(truePeakHistoryL_.begin(), truePeakHistoryL_.end(), 0.0); + std::fill(truePeakHistoryR_.begin(), truePeakHistoryR_.end(), 0.0); + truePeakHistoryWriteIndex_ = 0; + snapshot_ = makeInitialSnapshot(); } @@ -140,27 +211,35 @@ void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* right } const double nowMs = currentTimeMs(); - advancePeaks(nowMs); + advanceTruePeaks(nowMs); - double maxPeakL = 0.0; - double maxPeakR = 0.0; + double maxTruePeakL = 0.0; + double maxTruePeakR = 0.0; for (size_t index = 0; index < length; index += 1) { const float left = leftChannel[index]; const float right = rightChannel[index]; processLoudnessSample(left, right); - processFastMeterSample(left, right, maxPeakL, maxPeakR); + processFastMeterSample(left, right); + processTruePeakSample(left, right, maxTruePeakL, maxTruePeakR); } - maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true); - maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false); + const double truePeakLDb = truePeakAmplitudeToDb(maxTruePeakL); + const double truePeakRDb = truePeakAmplitudeToDb(maxTruePeakR); + maybeUpdateTruePeak(truePeakLDb, nowMs, true); + maybeUpdateTruePeak(truePeakRDb, nowMs, false); + snapshot_.maxTruePeakDb = static_cast(std::max({ + static_cast(snapshot_.maxTruePeakDb), + truePeakLDb, + truePeakRDb, + })); recomputeFastSnapshot(); updateMomentaryShortTermLoudness(); snapshot_.integratedLUFS = static_cast(computeGatedIntegratedLoudness()); } LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() { - advancePeaks(currentTimeMs()); + advanceTruePeaks(currentTimeMs()); recomputeFastSnapshot(); return snapshot_; } @@ -303,7 +382,7 @@ double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& s return output; } -void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) { +void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) { if (fastSqL_.empty()) { return; } @@ -334,16 +413,44 @@ void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& const double coeffR = absR > barEnvelopeR_ ? barAttackCoeff_ : barReleaseCoeff_; barEnvelopeL_ = coeffL * barEnvelopeL_ + (1.0 - coeffL) * absL; barEnvelopeR_ = coeffR * barEnvelopeR_ + (1.0 - coeffR) * absR; - - if (absL > maxPeakL) { - maxPeakL = absL; - } - if (absR > maxPeakR) { - maxPeakR = absR; - } } -void LUFSMeterAnalyzer::advancePeaks(double nowMs) { +void LUFSMeterAnalyzer::processTruePeakSample( + float left, + float right, + double& maxTruePeakL, + double& maxTruePeakR +) { + if (truePeakHistoryL_.empty() || truePeakCoefficients_.empty()) { + return; + } + + const double leftSample = static_cast(left); + const double rightSample = static_cast(right); + truePeakHistoryL_[truePeakHistoryWriteIndex_] = leftSample; + truePeakHistoryL_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = leftSample; + truePeakHistoryR_[truePeakHistoryWriteIndex_] = rightSample; + truePeakHistoryR_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = rightSample; + + for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) { + const size_t coefficientOffset = phase * TRUE_PEAK_TAPS_PER_PHASE; + double reconstructedL = 0.0; + double reconstructedR = 0.0; + const size_t newestHistoryIndex = truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE; + for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) { + const size_t historyIndex = newestHistoryIndex - tap; + const double coefficient = truePeakCoefficients_[coefficientOffset + tap]; + reconstructedL += coefficient * truePeakHistoryL_[historyIndex]; + reconstructedR += coefficient * truePeakHistoryR_[historyIndex]; + } + maxTruePeakL = std::max(maxTruePeakL, std::abs(reconstructedL)); + maxTruePeakR = std::max(maxTruePeakR, std::abs(reconstructedR)); + } + + truePeakHistoryWriteIndex_ = (truePeakHistoryWriteIndex_ + 1) % TRUE_PEAK_TAPS_PER_PHASE; +} + +void LUFSMeterAnalyzer::advanceTruePeaks(double nowMs) { if (!std::isfinite(nowMs)) { return; } @@ -358,23 +465,23 @@ void LUFSMeterAnalyzer::advancePeaks(double nowMs) { return; } - snapshot_.peakLDb = static_cast(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs)); - snapshot_.peakRDb = static_cast(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs)); + snapshot_.truePeakLDb = static_cast(applyPeakDecay(snapshot_.truePeakLDb, peakHoldUntilL_, nowMs)); + snapshot_.truePeakRDb = static_cast(applyPeakDecay(snapshot_.truePeakRDb, peakHoldUntilR_, nowMs)); lastPeakUpdateMs_ = nowMs; } -void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) { +void LUFSMeterAnalyzer::maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel) { if (leftChannel) { - if (peakDb > snapshot_.peakLDb) { - snapshot_.peakLDb = static_cast(peakDb); - peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS; + if (peakDb > snapshot_.truePeakLDb) { + snapshot_.truePeakLDb = static_cast(peakDb); + peakHoldUntilL_ = nowMs + TRUE_PEAK_HOLD_MS; } return; } - if (peakDb > snapshot_.peakRDb) { - snapshot_.peakRDb = static_cast(peakDb); - peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS; + if (peakDb > snapshot_.truePeakRDb) { + snapshot_.truePeakRDb = static_cast(peakDb); + peakHoldUntilR_ = nowMs + TRUE_PEAK_HOLD_MS; } } @@ -384,7 +491,7 @@ double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, d return currentDb; } - const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND; + const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * TRUE_PEAK_DECAY_DB_PER_SECOND; return std::max(VU_METER_MIN_DB, currentDb - decayAmount); } @@ -424,6 +531,13 @@ double LUFSMeterAnalyzer::amplitudeToDb(double amplitude) { return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB); } +double LUFSMeterAnalyzer::truePeakAmplitudeToDb(double amplitude) { + if (!std::isfinite(amplitude) || amplitude <= 0.0) { + return VU_METER_MIN_DB; + } + return std::max(VU_METER_MIN_DB, 20.0 * std::log10(std::max(amplitude, 1e-10))); +} + double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) { return std::max(minDb, std::min(maxDb, db)); } diff --git a/native/src/lufsmeter.h b/native/src/lufsmeter.h index 2f45bd8..0a20d09 100644 --- a/native/src/lufsmeter.h +++ b/native/src/lufsmeter.h @@ -14,8 +14,9 @@ struct LUFSMeterSnapshot { float vuRDb; float barLDb; float barRDb; - float peakLDb; - float peakRDb; + float truePeakLDb; + float truePeakRDb; + float maxTruePeakDb; float correlation; }; @@ -47,6 +48,7 @@ private: void configureForSampleRate(float sampleRate); void configureKWeighting(); void configureFastMeter(); + void configureTruePeakDetector(); void processLoudnessSample(float left, float right); void updateMomentaryShortTermLoudness(); double computeGatedIntegratedLoudness() const; @@ -54,13 +56,15 @@ private: double histogramLufsAtIndex(size_t index) const; double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input); - void processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR); - void advancePeaks(double nowMs); - void maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel); + void processFastMeterSample(float left, float right); + void processTruePeakSample(float left, float right, double& maxTruePeakL, double& maxTruePeakR); + void advanceTruePeaks(double nowMs); + void maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel); double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const; void recomputeFastSnapshot(); static double currentTimeMs(); static double amplitudeToDb(double amplitude); + static double truePeakAmplitudeToDb(double amplitude); static double clampDb(double db, double minDb, double maxDb); static BiquadCoeffs preFilterCoeffs(double sampleRate); static BiquadCoeffs rlbFilterCoeffs(double sampleRate); @@ -99,6 +103,12 @@ private: double lastPeakUpdateMs_ = 0.0; bool hasLastPeakUpdate_ = false; + size_t truePeakOversampleFactor_ = 1; + std::vector truePeakCoefficients_; + std::vector truePeakHistoryL_; + std::vector truePeakHistoryR_; + size_t truePeakHistoryWriteIndex_ = 0; + LUFSMeterSnapshot snapshot_{}; }; diff --git a/native/src/main.cpp b/native/src/main.cpp index e7370ed..ad21f93 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -690,8 +690,9 @@ Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) { obj.Set("vuRDb", Napi::Number::New(env, snapshot.vuRDb)); obj.Set("barLDb", Napi::Number::New(env, snapshot.barLDb)); obj.Set("barRDb", Napi::Number::New(env, snapshot.barRDb)); - obj.Set("peakLDb", Napi::Number::New(env, snapshot.peakLDb)); - obj.Set("peakRDb", Napi::Number::New(env, snapshot.peakRDb)); + obj.Set("truePeakLDb", Napi::Number::New(env, snapshot.truePeakLDb)); + obj.Set("truePeakRDb", Napi::Number::New(env, snapshot.truePeakRDb)); + obj.Set("maxTruePeakDb", Napi::Number::New(env, snapshot.maxTruePeakDb)); obj.Set("correlation", Napi::Number::New(env, snapshot.correlation)); return obj; } diff --git a/package.json b/package.json index 673c483..746a942 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:spectrogram-native": "node --test test/spectrogram-native.test.mjs", "test:vectorscope-native": "node --test test/vectorscope-native.test.mjs", + "test:lufsmeter-native": "node --test test/lufsmeter-native.test.mjs", + "test:lufsmeter-ebu": "node scripts/run-ebu-true-peak-tests.mjs", "test:tui": "node scripts/build/build-tui.cjs --test", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs", diff --git a/plugin/Source/LUFSMeterEngine.h b/plugin/Source/LUFSMeterEngine.h index a7dab59..200e11e 100644 --- a/plugin/Source/LUFSMeterEngine.h +++ b/plugin/Source/LUFSMeterEngine.h @@ -5,9 +5,9 @@ /** * Loudness (LUFS) meter engine. Pushes stereo audio into the reused - * `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast - * VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU - * engine the frame is plain numbers (no base64); getSnapshot() advances peak decay + * `Visualizer::LUFSMeterAnalyzer` (K-weighting, gated integration, true peak, and + * the same fast VU/correlation block) and emits its scalar snapshot each frame. + * The frame is plain numbers (no base64); getSnapshot() advances true-peak decay * on the steady clock, so it's safe to call every frame. configure() is a no-op — * LUFS settings (mode/readout) are render-side. */ @@ -44,8 +44,9 @@ public: obj->setProperty("vuRDb", snap.vuRDb); obj->setProperty("barLDb", snap.barLDb); obj->setProperty("barRDb", snap.barRDb); - obj->setProperty("peakLDb", snap.peakLDb); - obj->setProperty("peakRDb", snap.peakRDb); + obj->setProperty("truePeakLDb", snap.truePeakLDb); + obj->setProperty("truePeakRDb", snap.truePeakRDb); + obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb); obj->setProperty("correlation", snap.correlation); return juce::var(obj); } diff --git a/scripts/run-ebu-true-peak-tests.mjs b/scripts/run-ebu-true-peak-tests.mjs new file mode 100644 index 0000000..0f7355a --- /dev/null +++ b/scripts/run-ebu-true-peak-tests.mjs @@ -0,0 +1,126 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node') + +const sourcePath = process.argv[2] +if (!sourcePath) { + console.error('Usage: npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set') + process.exit(2) +} + +async function collectFiles(path) { + const details = await stat(path) + if (details.isFile()) return [path] + if (!details.isDirectory()) return [] + + const files = [] + for (const entry of await readdir(path, { withFileTypes: true })) { + files.push(...await collectFiles(join(path, entry.name))) + } + return files +} + +function decodeWave(buffer) { + if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF' + || buffer.toString('ascii', 8, 12) !== 'WAVE') { + throw new Error('not a RIFF/WAVE file') + } + + let format = null + let data = null + for (let offset = 12; offset + 8 <= buffer.length;) { + const chunkId = buffer.toString('ascii', offset, offset + 4) + const chunkSize = buffer.readUInt32LE(offset + 4) + const chunkOffset = offset + 8 + if (chunkOffset + chunkSize > buffer.length) throw new Error(`truncated ${chunkId} chunk`) + + if (chunkId === 'fmt ') { + if (chunkSize < 16) throw new Error('invalid fmt chunk') + let formatTag = buffer.readUInt16LE(chunkOffset) + if (formatTag === 0xfffe && chunkSize >= 40) { + formatTag = buffer.readUInt16LE(chunkOffset + 24) + } + format = { + formatTag, + channels: buffer.readUInt16LE(chunkOffset + 2), + sampleRate: buffer.readUInt32LE(chunkOffset + 4), + blockAlign: buffer.readUInt16LE(chunkOffset + 12), + bitsPerSample: buffer.readUInt16LE(chunkOffset + 14), + } + } else if (chunkId === 'data') { + data = buffer.subarray(chunkOffset, chunkOffset + chunkSize) + } + offset = chunkOffset + chunkSize + (chunkSize % 2) + } + + if (!format || !data) throw new Error('missing fmt or data chunk') + if (format.channels < 1 || format.blockAlign < 1 || format.sampleRate < 1) { + throw new Error('invalid WAVE format') + } + + const bytesPerSample = Math.ceil(format.bitsPerSample / 8) + const frameCount = Math.floor(data.length / format.blockAlign) + const left = new Float32Array(frameCount) + const right = new Float32Array(frameCount) + const readSample = (offset) => { + if (format.formatTag === 3 && format.bitsPerSample === 32) return data.readFloatLE(offset) + if (format.formatTag !== 1) throw new Error(`unsupported WAVE format tag ${format.formatTag}`) + if (format.bitsPerSample === 16) return data.readInt16LE(offset) / 0x8000 + if (format.bitsPerSample === 24) return data.readIntLE(offset, 3) / 0x800000 + if (format.bitsPerSample === 32) return data.readInt32LE(offset) / 0x80000000 + throw new Error(`unsupported PCM depth ${format.bitsPerSample}`) + } + + for (let frame = 0; frame < frameCount; frame += 1) { + const frameOffset = frame * format.blockAlign + left[frame] = readSample(frameOffset) + right[frame] = format.channels > 1 ? readSample(frameOffset + bytesPerSample) : left[frame] + } + return { sampleRate: format.sampleRate, left, right } +} + +function expectedDbtp(caseNumber) { + if (caseNumber >= 15 && caseNumber <= 18) return -6 + if (caseNumber === 19) return 3 + return 0 +} + +const files = await collectFiles(resolve(sourcePath)) +const cases = new Map() +for (const file of files) { + const match = basename(file).match(/3341[-_ ](1[5-9]|2[0-3])(?:\D|$)/i) + if (match && /\.wav(?:\.wav)?$/i.test(file)) cases.set(Number(match[1]), file) +} + +const missing = [] +for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) { + if (!cases.has(caseNumber)) missing.push(caseNumber) +} +if (missing.length > 0) { + console.error(`Could not find EBU Tech 3341 WAV cases: ${missing.join(', ')}`) + process.exit(2) +} + +let failed = false +for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) { + const file = cases.get(caseNumber) + try { + const wave = decodeWave(await readFile(file)) + lufsmeter.setSampleRate(wave.sampleRate) + lufsmeter.reset() + lufsmeter.pushSamples(wave.left, wave.right) + const actual = lufsmeter.getSnapshot().maxTruePeakDb + const expected = expectedDbtp(caseNumber) + const passed = actual >= expected - 0.4 && actual <= expected + 0.2 + console.log(`${passed ? 'PASS' : 'FAIL'} EBU 3341-${caseNumber}: ${actual.toFixed(3)} dBTP (expected ${expected}, +0.2/-0.4)`) + failed ||= !passed + } catch (error) { + failed = true + console.error(`FAIL EBU 3341-${caseNumber}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +process.exit(failed ? 1 : 0) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 2c38a05..1c77f16 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -19,6 +19,7 @@ const testScripts = [ 'test:spectrum-native', 'test:spectrogram-native', 'test:vectorscope-native', + 'test:lufsmeter-native', ] const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' diff --git a/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts b/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts index 069ee5a..40cae30 100644 --- a/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts +++ b/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts @@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render /** * Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview. * - * The loudness DSP (K-weighting, gated integration, fast VU/peak/correlation) runs + * The loudness DSP (K-weighting, gated integration, true peak, fast VU/correlation) runs * in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim * caches that snapshot and serves it through the interface `LUFSMeter` consumes. * `pushSamples` is a no-op (audio never flows through the webview); `reset` clears diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index ced0d7c..af4dd2b 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -275,16 +275,25 @@ export interface LUFSMeterFrame { vuRDb: number barLDb: number barRDb: number - peakLDb: number - peakRDb: number + truePeakLDb: number + truePeakRDb: number + maxTruePeakDb: number correlation: number } -function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null { +export function decodeLUFSMeterFrame( + payload: unknown, + previousMaxTruePeakDb = -60, +): LUFSMeterFrame | null { if (typeof payload !== 'object' || payload === null) return null const p = payload as Record const num = (key: string, fallback: number): number => typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback + const truePeakLDb = num('truePeakLDb', num('peakLDb', -60)) + const truePeakRDb = num('truePeakRDb', num('peakRDb', -60)) + const explicitMaximum = typeof p.maxTruePeakDb === 'number' && Number.isFinite(p.maxTruePeakDb) + ? p.maxTruePeakDb + : null return { sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000, momentaryLUFS: num('momentaryLUFS', -70), @@ -294,8 +303,13 @@ function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null { vuRDb: num('vuRDb', -60), barLDb: num('barLDb', -60), barRDb: num('barRDb', -60), - peakLDb: num('peakLDb', -60), - peakRDb: num('peakRDb', -60), + truePeakLDb, + truePeakRDb, + maxTruePeakDb: explicitMaximum ?? Math.max( + Number.isFinite(previousMaxTruePeakDb) ? previousMaxTruePeakDb : -60, + truePeakLDb, + truePeakRDb, + ), correlation: num('correlation', 0), } } @@ -309,6 +323,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => let disposed = false let listenerId: number | null = null let mockRaf: number | null = null + let maxTruePeakDb = -60 + let previousSampleRate = 0 const startMock = (): void => { handlers.onConnected?.(true) @@ -320,6 +336,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6 const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30 const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30 + const truePeakLDb = vuL + 3 + const truePeakRDb = vuR + 3 + maxTruePeakDb = Math.max(maxTruePeakDb, truePeakLDb, truePeakRDb) handlers.onFrame({ sampleRate: 48000, momentaryLUFS: lufs(0), @@ -329,8 +348,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => vuRDb: vuR, barLDb: vuL, barRDb: vuR, - peakLDb: vuL + 3, - peakRDb: vuR + 3, + truePeakLDb, + truePeakRDb, + maxTruePeakDb, correlation: Math.sin(phase * 0.3), }) mockRaf = requestAnimationFrame(tick) @@ -342,8 +362,15 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => if (disposed) return if (backend) { listenerId = backend.addEventListener('lufsmeterFrame', (payload) => { - const frame = decodeLUFSMeterFrame(payload) - if (frame) handlers.onFrame(frame) + let frame = decodeLUFSMeterFrame(payload, maxTruePeakDb) + if (frame && previousSampleRate > 0 && Math.abs(frame.sampleRate - previousSampleRate) > 1) { + frame = decodeLUFSMeterFrame(payload, -60) + } + if (frame) { + previousSampleRate = frame.sampleRate + maxTruePeakDb = frame.maxTruePeakDb + handlers.onFrame(frame) + } }) handlers.onConnected?.(false) console.log('[prism-plugin] connected to JUCE host (lufsmeter)') diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 34d26ae..f82707c 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -54,8 +54,9 @@ export interface LUFSMeterNativeSnapshot { vuRDb: number; barLDb: number; barRDb: number; - peakLDb: number; - peakRDb: number; + truePeakLDb: number; + truePeakRDb: number; + maxTruePeakDb: number; correlation: number; } diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 145ed89..b03bc9f 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -73,6 +73,7 @@ const TARGET_LUFS = -14 const METER_MIN_DB = -60 const MAX_METER_VIEWPORT_CSS_WIDTH = 240 const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS' +const TRUE_PEAK_EMPTY_DB = METER_MIN_DB const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = { momentaryLUFS: METER_MIN_LUFS, @@ -82,8 +83,9 @@ const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = { vuRDb: METER_MIN_DB, barLDb: METER_MIN_DB, barRDb: METER_MIN_DB, - peakLDb: METER_MIN_DB, - peakRDb: METER_MIN_DB, + truePeakLDb: METER_MIN_DB, + truePeakRDb: METER_MIN_DB, + maxTruePeakDb: TRUE_PEAK_EMPTY_DB, correlation: 0, } @@ -104,12 +106,20 @@ function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFS vuRDb: finiteNumber(snapshot.vuRDb, METER_MIN_DB), barLDb: finiteNumber(snapshot.barLDb, METER_MIN_DB), barRDb: finiteNumber(snapshot.barRDb, METER_MIN_DB), - peakLDb: finiteNumber(snapshot.peakLDb, METER_MIN_DB), - peakRDb: finiteNumber(snapshot.peakRDb, METER_MIN_DB), + truePeakLDb: finiteNumber(snapshot.truePeakLDb, METER_MIN_DB), + truePeakRDb: finiteNumber(snapshot.truePeakRDb, METER_MIN_DB), + maxTruePeakDb: finiteNumber(snapshot.maxTruePeakDb, TRUE_PEAK_EMPTY_DB), correlation: finiteNumber(snapshot.correlation, 0), } } +export function formatMaxTruePeakDb(maxTruePeakDb: number, compact = false): string { + const value = Number.isFinite(maxTruePeakDb) && maxTruePeakDb > TRUE_PEAK_EMPTY_DB + ? `${maxTruePeakDb >= 0 ? '+' : '−'}${Math.abs(maxTruePeakDb).toFixed(1)}` + : '−∞' + return compact ? `${value}dBTP` : `MAX TP ${value} dBTP` +} + // ---- Loudness meter class ---- export class LUFSMeter { @@ -383,8 +393,12 @@ export class LUFSMeter { const paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr) const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr) + const footerFontSizeCss = Math.max(7, Math.min(11, Math.floor(cssHeight * 0.052))) + const footerFontSize = Math.max(1, Math.round(footerFontSizeCss * dpr)) + const footerHeight = Math.max(1, Math.round((footerFontSizeCss + 4) * dpr)) + const footerGap = Math.max(1, Math.round(4 * dpr)) const meterTop = paddingY - const meterBottom = height - paddingY + const meterBottom = Math.max(meterTop + 1, height - paddingY - footerHeight - footerGap) const meterHeight = Math.max(1, meterBottom - meterTop) const availableViewportWidth = Math.max(1, width - paddingX * 2) const viewportWidth = Math.max(1, Math.min( @@ -448,7 +462,7 @@ export class LUFSMeter { barWidth, meterHeight, this.snapshot.barLDb, - this.snapshot.peakLDb, + this.snapshot.truePeakLDb, tint, dpr, ) @@ -458,7 +472,7 @@ export class LUFSMeter { barWidth, meterHeight, this.snapshot.barRDb, - this.snapshot.peakRDb, + this.snapshot.truePeakRDb, tint, dpr, ) @@ -509,6 +523,29 @@ export class LUFSMeter { ctx.textBaseline = 'middle' ctx.fillStyle = this.contrastForLevelColor() ctx.fillText(readoutLayout.text, tagX + tagPadding, tagY + tagHeight / 2) + + const fullTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb) + const compactTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb, true) + ctx.font = `700 ${footerFontSize}px "JetBrains Mono", "SF Mono", monospace` + const truePeakText = viewportCssWidth >= 160 + && ctx.measureText(fullTruePeakText).width <= viewportWidth + ? fullTruePeakText + : compactTruePeakText + const truePeakLayout = this.resolveReadoutTextLayout( + [truePeakText], + viewportWidth, + footerFontSize, + Math.max(1, Math.round(7 * dpr)), + ) + ctx.font = `700 ${truePeakLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = this.options.labelColor + ctx.fillText( + truePeakLayout.text, + Math.round(width / 2), + Math.round(meterBottom + footerGap + footerHeight / 2), + ) } dispose(): void { diff --git a/test/lufsmeter-native.test.mjs b/test/lufsmeter-native.test.mjs new file mode 100644 index 0000000..20cec82 --- /dev/null +++ b/test/lufsmeter-native.test.mjs @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node') + +const EBU_SAMPLE_RATE = 48000 +const EBU_TOLERANCE_BELOW_DB = 0.4 +const EBU_TOLERANCE_ABOVE_DB = 0.2 +const SILENCE_DB = -60 + +function assertEbuTolerance(actual, expected, label) { + assert.ok( + actual >= expected - EBU_TOLERANCE_BELOW_DB + && actual <= expected + EBU_TOLERANCE_ABOVE_DB, + `${label}: expected ${expected} dBTP (+${EBU_TOLERANCE_ABOVE_DB}/-${EBU_TOLERANCE_BELOW_DB}), got ${actual}`, + ) +} + +function createPhaseTone( + sampleRate, + frequency, + amplitude, + phaseDegrees, + durationSeconds = 0.1, +) { + const length = Math.max(1, Math.round(sampleRate * durationSeconds)) + const taperSamples = Math.max(1, Math.round(sampleRate * 0.01)) + const phaseRadians = phaseDegrees * Math.PI / 180 + const samples = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + const fadeIn = Math.min(1, index / taperSamples) + const fadeOut = Math.min(1, (length - 1 - index) / taperSamples) + const taper = Math.max(0, Math.min(fadeIn, fadeOut)) + samples[index] = amplitude + * Math.sin((2 * Math.PI * frequency * index) / sampleRate + phaseRadians) + * taper + } + return samples +} + +function pushStereo(left, right = left, chunkSizes = null) { + if (!chunkSizes) { + lufsmeter.pushSamples(left, right) + return + } + + let offset = 0 + let chunkIndex = 0 + while (offset < Math.min(left.length, right.length)) { + const requestedSize = chunkSizes[chunkIndex % chunkSizes.length] + const length = Math.min(requestedSize, left.length - offset, right.length - offset) + lufsmeter.pushSamples( + left.subarray(offset, offset + length), + right.subarray(offset, offset + length), + ) + offset += length + chunkIndex += 1 + } +} + +function measure(sampleRate, left, right = left, chunkSizes = null) { + lufsmeter.setSampleRate(sampleRate) + lufsmeter.reset() + pushStereo(left, right, chunkSizes) + return lufsmeter.getSnapshot() +} + +function besselI0(value) { + let sum = 1 + let term = 1 + const squaredQuarter = value * value / 4 + for (let index = 1; index < 40; index += 1) { + term *= squaredQuarter / (index * index) + sum += term + if (term < sum * 1e-15) break + } + return sum +} + +function createTransientReferenceAt4x(sampleRate = EBU_SAMPLE_RATE) { + const oversample = 4 + const highRate = sampleRate * oversample + const durationSeconds = 0.12 + const length = Math.round(highRate * durationSeconds) + const source = new Float64Array(length) + const taperSamples = Math.round(highRate * 0.01) + + for (let index = 0; index < length; index += 1) { + const taper = Math.max(0, Math.min( + 1, + index / taperSamples, + (length - 1 - index) / taperSamples, + )) + source[index] = 0.5 * Math.sin((2 * Math.PI * (sampleRate / 6) * index) / highRate) * taper + } + + const transientStart = Math.floor(length / 2) - 8 + for (let index = 0; index < 16; index += 1) { + source[transientStart + index] += Math.sin(Math.PI * index / 8) + } + + const tapCount = 129 + const half = (tapCount - 1) / 2 + const cutoff = 0.5 / oversample + const beta = 8.6 + const inverseI0Beta = 1 / besselI0(beta) + const coefficients = new Float64Array(tapCount) + let coefficientSum = 0 + for (let tap = 0; tap < tapCount; tap += 1) { + const distance = tap - half + const sincArgument = 2 * cutoff * distance + const sinc = Math.abs(sincArgument) < 1e-12 + ? 1 + : Math.sin(Math.PI * sincArgument) / (Math.PI * sincArgument) + const ratio = distance / half + const window = besselI0(beta * Math.sqrt(Math.max(0, 1 - ratio * ratio))) * inverseI0Beta + coefficients[tap] = 2 * cutoff * sinc * window + coefficientSum += coefficients[tap] + } + for (let tap = 0; tap < tapCount; tap += 1) coefficients[tap] /= coefficientSum + + const filtered = new Float64Array(length) + for (let index = half; index < length - half; index += 1) { + let value = 0 + for (let tap = 0; tap < tapCount; tap += 1) { + value += coefficients[tap] * source[index + tap - half] + } + filtered[index] = value + } + + let maximum = 0 + for (const value of filtered) maximum = Math.max(maximum, Math.abs(value)) + for (let index = 0; index < filtered.length; index += 1) filtered[index] /= maximum + return filtered +} + +function downsample4x(reference, offset) { + const length = Math.floor((reference.length - offset) / 4) + const samples = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + samples[index] = reference[index * 4 + offset] + } + return samples +} + +test('BS.1770 true peak passes EBU Tech 3341 phase-tone cases 15-19', () => { + const cases = [ + { number: 15, divisor: 4, amplitude: 0.5, phase: 0, expected: -6 }, + { number: 16, divisor: 4, amplitude: 0.5, phase: 45, expected: -6 }, + { number: 17, divisor: 6, amplitude: 0.5, phase: 60, expected: -6 }, + { number: 18, divisor: 8, amplitude: 0.5, phase: 67.5, expected: -6 }, + { number: 19, divisor: 4, amplitude: 1.41, phase: 45, expected: 3 }, + ] + + for (const vector of cases) { + const tone = createPhaseTone( + EBU_SAMPLE_RATE, + EBU_SAMPLE_RATE / vector.divisor, + vector.amplitude, + vector.phase, + ) + const snapshot = measure(EBU_SAMPLE_RATE, tone) + assertEbuTolerance(snapshot.maxTruePeakDb, vector.expected, `EBU case ${vector.number}`) + assertEbuTolerance(snapshot.truePeakLDb, vector.expected, `EBU case ${vector.number} left marker`) + assertEbuTolerance(snapshot.truePeakRDb, vector.expected, `EBU case ${vector.number} right marker`) + } +}) + +test('BS.1770 true peak passes four phase offsets of a filtered transient (EBU cases 20-23)', () => { + const reference = createTransientReferenceAt4x() + for (let offset = 0; offset < 4; offset += 1) { + const samples = downsample4x(reference, offset) + const snapshot = measure(EBU_SAMPLE_RATE, samples) + assertEbuTolerance(snapshot.maxTruePeakDb, 0, `EBU transient case ${20 + offset}`) + } +}) + +test('true-peak state is independent of arbitrary audio chunk boundaries', () => { + const tone = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45) + const contiguous = measure(EBU_SAMPLE_RATE, tone).maxTruePeakDb + const irregular = measure(EBU_SAMPLE_RATE, tone, tone, [1, 7, 113, 2, 509, 31]).maxTruePeakDb + assert.ok(Math.abs(contiguous - irregular) < 1e-6, `${contiguous} vs ${irregular}`) +}) + +test('true peak supports common host rates with rate-aware oversampling', () => { + for (const sampleRate of [44100, 48000, 96000, 192000]) { + const tone = createPhaseTone(sampleRate, sampleRate / 4, 1.41, 45) + const snapshot = measure(sampleRate, tone) + assertEbuTolerance(snapshot.maxTruePeakDb, 3, `${sampleRate} Hz`) + } +}) + +test('true-peak channels preserve polarity and remain independent', () => { + const positive = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45) + const negative = Float32Array.from(positive, (sample) => -sample) + const silence = new Float32Array(positive.length) + + const positiveSnapshot = measure(EBU_SAMPLE_RATE, positive, silence) + assertEbuTolerance(positiveSnapshot.truePeakLDb, 3, 'left channel') + assert.equal(positiveSnapshot.truePeakRDb, SILENCE_DB) + + const negativeSnapshot = measure(EBU_SAMPLE_RATE, negative, silence) + assert.ok(Math.abs(negativeSnapshot.truePeakLDb - positiveSnapshot.truePeakLDb) < 1e-6) + assert.equal(negativeSnapshot.truePeakRDb, SILENCE_DB) +}) + +test('maximum true peak persists across quieter input and clears on reset', () => { + const loud = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45) + const quiet = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 0.1, 45) + const silence = new Float32Array(1024) + + lufsmeter.setSampleRate(EBU_SAMPLE_RATE) + lufsmeter.reset() + pushStereo(loud) + const loudSnapshot = lufsmeter.getSnapshot() + assert.ok(loudSnapshot.maxTruePeakDb > 0) + + pushStereo(quiet) + assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, loudSnapshot.maxTruePeakDb) + + lufsmeter.reset() + assert.deepEqual( + { + truePeakLDb: lufsmeter.getSnapshot().truePeakLDb, + truePeakRDb: lufsmeter.getSnapshot().truePeakRDb, + maxTruePeakDb: lufsmeter.getSnapshot().maxTruePeakDb, + }, + { truePeakLDb: SILENCE_DB, truePeakRDb: SILENCE_DB, maxTruePeakDb: SILENCE_DB }, + ) + pushStereo(silence) + assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, SILENCE_DB) +}) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 05a5d62..bad6deb 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -115,11 +115,11 @@ import { HEAT_MIN_DB, normalizeHeatDb, } from '../src/renderer/visualizers/heatScale' -import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' +import { LUFSMeter, formatMaxTruePeakDb } from '../src/renderer/visualizers/LUFSMeter' import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer' -import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' +import { decodeLUFSMeterFrame, decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' @@ -6296,8 +6296,9 @@ function createFakeLUFSMeterNativeAnalyzer( vuRDb: -13, barLDb: -10, barRDb: -12, - peakLDb: -4, - peakRDb: -5, + truePeakLDb: -4, + truePeakRDb: -5, + maxTruePeakDb: -4, correlation: 0.5, ...snapshotOverrides, } satisfies LUFSMeterNativeSnapshot, @@ -6319,7 +6320,45 @@ function createFakeLUFSMeterNativeAnalyzer( return analyzer } -test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => { +test('LUFSMeter formats positive, negative, compact, and reset true-peak maxima', () => { + assert.equal(formatMaxTruePeakDb(3), 'MAX TP +3.0 dBTP') + assert.equal(formatMaxTruePeakDb(-4.25), 'MAX TP −4.3 dBTP') + assert.equal(formatMaxTruePeakDb(3, true), '+3.0dBTP') + assert.equal(formatMaxTruePeakDb(-60), 'MAX TP −∞ dBTP') + assert.equal(formatMaxTruePeakDb(Number.NaN, true), '−∞dBTP') +}) + +test('loudness bridge decodes true-peak fields and accumulates legacy peak maxima', () => { + const current = decodeLUFSMeterFrame({ + sampleRate: 96000, + truePeakLDb: 1.25, + truePeakRDb: -2, + maxTruePeakDb: 2.5, + }) + assert.ok(current) + assert.equal(current.sampleRate, 96000) + assert.equal(current.truePeakLDb, 1.25) + assert.equal(current.truePeakRDb, -2) + assert.equal(current.maxTruePeakDb, 2.5) + + const firstLegacy = decodeLUFSMeterFrame({ peakLDb: -3, peakRDb: -5 }, -60) + assert.ok(firstLegacy) + assert.equal(firstLegacy.truePeakLDb, -3) + assert.equal(firstLegacy.truePeakRDb, -5) + assert.equal(firstLegacy.maxTruePeakDb, -3) + + const quieterLegacy = decodeLUFSMeterFrame({ peakLDb: -9, peakRDb: -7 }, firstLegacy.maxTruePeakDb) + assert.equal(quieterLegacy?.maxTruePeakDb, -3) + + const explicitReset = decodeLUFSMeterFrame({ + truePeakLDb: -60, + truePeakRDb: -60, + maxTruePeakDb: -60, + }, 2.5) + assert.equal(explicitReset?.maxTruePeakDb, -60) +}) + +test('LUFSMeter draws fast bars, true-peak markers, LUFS readout, and maximum dBTP footer', () => { const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() const leftChunk = new Float32Array(4800) @@ -6343,7 +6382,12 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } - const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 }) + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ + momentaryLUFS: -18.4, + truePeakLDb: 3, + truePeakRDb: -5, + maxTruePeakDb: 3, + }) const meter = new LUFSMeter(createFakeCanvas(recorder), { dataSource, nativeAnalyzer, @@ -6392,6 +6436,10 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'), true, ) + assert.equal( + recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP' && text.fillStyle === 'rgb(7, 8, 9)'), + true, + ) } finally { meter.dispose() dom.restore() @@ -6419,7 +6467,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => { isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } - const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 }) + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1, maxTruePeakDb: 3 }) const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), { dataSource, nativeAnalyzer, @@ -6444,6 +6492,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => { const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0) const estimatedTextWidth = readout.text.length * fontSize * 0.62 assert.equal(estimatedTextWidth <= tagRect.width - 8, true) + assert.equal(recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP'), true) } finally { meter.dispose() dom.restore() @@ -6475,7 +6524,7 @@ test('LUFSMeter centers wide layouts in a bounded viewport', () => { const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111') const target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222') const tag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22) - const readout = recorder.fillTexts[recorder.fillTexts.length - 1] + const readout = recorder.fillTexts.find((text) => text.text.endsWith('LUFS')) const viewportLeft = (canvas.width - 240) / 2 const viewportRight = viewportLeft + 240 @@ -6503,7 +6552,7 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() const canvas = createFakeCssSizedCanvas(recorder, 112, 196) - const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 }) + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7, maxTruePeakDb: 3 }) const meter = new LUFSMeter(canvas, { dataSource: { getPendingLUFSMeterSamples: () => [], @@ -6527,11 +6576,17 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou && rect.height > 2 && rect.height <= 22 )) - const readout = recorder.fillTexts[recorder.fillTexts.length - 1] + const readout = recorder.fillTexts.find((text) => text.text === '-8.7') assert.deepEqual(tracks.map((rect) => rect.width), [6, 6, 12]) assert.ok(tag) assert.equal(readout?.text, '-8.7') + const truePeakReadout = recorder.fillTexts.find((text) => text.text.endsWith('dBTP')) + assert.equal(truePeakReadout?.text, '+3.0dBTP') + const truePeakFontSize = Number(truePeakReadout?.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0) + const truePeakWidth = (truePeakReadout?.text.length ?? 0) * truePeakFontSize * 0.62 + assert.ok((truePeakReadout?.x ?? 0) - truePeakWidth / 2 >= 0) + assert.ok((truePeakReadout?.x ?? 0) + truePeakWidth / 2 <= canvas.width) for (const rect of recorder.fillRects) { assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`)