From bc1d7967864b3a16a91488ff3a0a2e6d3662992a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 19 May 2026 15:17:32 -0400 Subject: [PATCH] rewrite LUFS meter to native --- native/binding.gyp | 1 + native/src/lufsmeter.cpp | 459 ++++++++++++++++++ native/src/lufsmeter.h | 105 ++++ native/src/main.cpp | 59 +++ src/preload/index.ts | 1 + src/renderer/audio/native/index.ts | 33 ++ src/renderer/audio/native/visualizer-dsp.d.ts | 21 + src/renderer/visualizers/LUFSMeter.ts | 407 +++++----------- test/renderer-helpers.test.ts | 168 ++++++- 9 files changed, 940 insertions(+), 314 deletions(-) create mode 100644 native/src/lufsmeter.cpp create mode 100644 native/src/lufsmeter.h diff --git a/native/binding.gyp b/native/binding.gyp index 2bb46bb..bc586f0 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -11,6 +11,7 @@ "src/spectrum.cpp", "src/spectrogram.cpp", "src/vectorscope.cpp", + "src/lufsmeter.cpp", "src/dsp_utils.cpp" ], "include_dirs": [ diff --git a/native/src/lufsmeter.cpp b/native/src/lufsmeter.cpp new file mode 100644 index 0000000..2f1274d --- /dev/null +++ b/native/src/lufsmeter.cpp @@ -0,0 +1,459 @@ +#include "lufsmeter.h" + +#include +#include +#include + +namespace Visualizer { + +namespace { +constexpr double PI = 3.14159265358979323846; +constexpr double METER_MIN_LUFS = -60.0; +constexpr double VU_METER_MIN_DB = -60.0; +constexpr double VU_METER_MAX_DB = 0.0; +constexpr double MOMENTARY_WINDOW_S = 0.4; +constexpr double SHORT_TERM_WINDOW_S = 3.0; +constexpr double INTEGRATED_BLOCK_S = 0.4; +constexpr double INTEGRATED_HOP_S = 0.1; +constexpr double ABSOLUTE_GATE_LUFS = -70.0; +constexpr double RELATIVE_GATE_OFFSET = -10.0; +constexpr double INTEGRATED_HISTOGRAM_MIN_LUFS = ABSOLUTE_GATE_LUFS; +constexpr double INTEGRATED_HISTOGRAM_MAX_LUFS = 10.0; +constexpr double INTEGRATED_HISTOGRAM_BIN_WIDTH = 0.1; +constexpr size_t INTEGRATED_HISTOGRAM_BIN_COUNT = + static_cast((INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS) + / INTEGRATED_HISTOGRAM_BIN_WIDTH + 0.5) + 1; + +constexpr double PRE_FILTER_F0_HZ = 1681.9744509555319; +constexpr double PRE_FILTER_GAIN_DB = 3.999843853973347; +constexpr double PRE_FILTER_Q = 0.7071752369554193; +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 BAR_ATTACK_MS = 5.0; +constexpr double BAR_RELEASE_MS = 180.0; + +LUFSMeterSnapshot makeInitialSnapshot() { + return { + static_cast(METER_MIN_LUFS), + static_cast(METER_MIN_LUFS), + static_cast(METER_MIN_LUFS), + static_cast(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), + 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, + }; +} + +float sanitizeSampleRate(float sampleRate) { + if (!std::isfinite(sampleRate) || sampleRate <= 0.0f) { + return 1.0f; + } + return std::max(1.0f, std::floor(sampleRate)); +} +} // namespace + +LUFSMeterAnalyzer::LUFSMeterAnalyzer() { + configureForSampleRate(sampleRate_); +} + +void LUFSMeterAnalyzer::setSampleRate(float sampleRate) { + configureForSampleRate(sampleRate); +} + +void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) { + sampleRate_ = sanitizeSampleRate(sampleRate); + configureKWeighting(); + + const size_t ringSize = std::max( + 1, + static_cast(std::ceil(static_cast(sampleRate_) * SHORT_TERM_WINDOW_S)) + ); + ringBufferL_.assign(ringSize, 0.0); + ringBufferR_.assign(ringSize, 0.0); + + integratedHistogramCounts_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0); + integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0); + + configureFastMeter(); + reset(); +} + +void LUFSMeterAnalyzer::configureKWeighting() { + preCoeffs_ = preFilterCoeffs(sampleRate_); + rlbCoeffs_ = rlbFilterCoeffs(sampleRate_); +} + +void LUFSMeterAnalyzer::configureFastMeter() { + integrationWindowSamples_ = std::max( + 1, + static_cast(std::round((static_cast(sampleRate_) * VU_INTEGRATION_WINDOW_MS) / 1000.0)) + ); + fastSqL_.assign(integrationWindowSamples_, 0.0); + fastSqR_.assign(integrationWindowSamples_, 0.0); + fastCross_.assign(integrationWindowSamples_, 0.0); + barAttackCoeff_ = std::exp(-1.0 / (static_cast(sampleRate_) * (BAR_ATTACK_MS / 1000.0))); + barReleaseCoeff_ = std::exp(-1.0 / (static_cast(sampleRate_) * (BAR_RELEASE_MS / 1000.0))); +} + +void LUFSMeterAnalyzer::reset() { + std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0); + std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0); + ringBufferPos_ = 0; + ringBufferFilled_ = 0; + integratedHopCounter_ = 0; + std::fill(integratedHistogramCounts_.begin(), integratedHistogramCounts_.end(), 0); + std::fill(integratedHistogramPowerSums_.begin(), integratedHistogramPowerSums_.end(), 0.0); + + preFilterL_ = {}; + preFilterR_ = {}; + rlbFilterL_ = {}; + rlbFilterR_ = {}; + + std::fill(fastSqL_.begin(), fastSqL_.end(), 0.0); + std::fill(fastSqR_.begin(), fastSqR_.end(), 0.0); + std::fill(fastCross_.begin(), fastCross_.end(), 0.0); + fastWriteIndex_ = 0; + fastSampleCount_ = 0; + fastSumSqL_ = 0.0; + fastSumSqR_ = 0.0; + fastSumCross_ = 0.0; + barEnvelopeL_ = 0.0; + barEnvelopeR_ = 0.0; + peakHoldUntilL_ = 0.0; + peakHoldUntilR_ = 0.0; + lastPeakUpdateMs_ = 0.0; + hasLastPeakUpdate_ = false; + + snapshot_ = makeInitialSnapshot(); +} + +void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* rightChannel, size_t length) { + if (!leftChannel || !rightChannel || length == 0) { + return; + } + + const double nowMs = currentTimeMs(); + advancePeaks(nowMs); + + double maxPeakL = 0.0; + double maxPeakR = 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); + } + + maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true); + maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false); + recomputeFastSnapshot(); + updateMomentaryShortTermLoudness(); + snapshot_.integratedLUFS = static_cast(computeGatedIntegratedLoudness()); +} + +LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() { + advancePeaks(currentTimeMs()); + recomputeFastSnapshot(); + return snapshot_; +} + +void LUFSMeterAnalyzer::processLoudnessSample(float left, float right) { + if (ringBufferL_.empty()) { + return; + } + + const double kwL = applyBiquad(rlbCoeffs_, rlbFilterL_, applyBiquad(preCoeffs_, preFilterL_, left)); + const double kwR = applyBiquad(rlbCoeffs_, rlbFilterR_, applyBiquad(preCoeffs_, preFilterR_, right)); + ringBufferL_[ringBufferPos_] = kwL * kwL; + ringBufferR_[ringBufferPos_] = kwR * kwR; + ringBufferPos_ = (ringBufferPos_ + 1) % ringBufferL_.size(); + if (ringBufferFilled_ < ringBufferL_.size()) { + ringBufferFilled_ += 1; + } + + integratedHopCounter_ += 1; + const size_t hopSamples = std::max( + 1, + static_cast(std::round(static_cast(sampleRate_) * INTEGRATED_HOP_S)) + ); + const size_t blockSamples = std::max( + 1, + static_cast(std::round(static_cast(sampleRate_) * INTEGRATED_BLOCK_S)) + ); + if (integratedHopCounter_ < hopSamples || ringBufferFilled_ < blockSamples) { + return; + } + + double sumL = 0.0; + double sumR = 0.0; + const size_t bufferLength = ringBufferL_.size(); + for (size_t index = 0; index < blockSamples; index += 1) { + const size_t bufferIndex = (ringBufferPos_ + bufferLength - 1 - index) % bufferLength; + sumL += ringBufferL_[bufferIndex]; + sumR += ringBufferR_[bufferIndex]; + } + + const double blockPower = std::max((sumL / blockSamples) + (sumR / blockSamples), 1e-10); + const double blockLUFS = -0.691 + 10.0 * std::log10(blockPower); + if (blockLUFS > ABSOLUTE_GATE_LUFS) { + const size_t histogramIndex = histogramIndexFromLufs(blockLUFS); + integratedHistogramCounts_[histogramIndex] += 1; + integratedHistogramPowerSums_[histogramIndex] += blockPower; + } + integratedHopCounter_ = 0; +} + +void LUFSMeterAnalyzer::updateMomentaryShortTermLoudness() { + if (ringBufferL_.empty() || ringBufferFilled_ == 0) { + snapshot_.momentaryLUFS = static_cast(METER_MIN_LUFS); + snapshot_.shortTermLUFS = static_cast(METER_MIN_LUFS); + return; + } + + const size_t bufferLength = ringBufferL_.size(); + const auto computeWindow = [&](double seconds) -> double { + const size_t samples = std::min( + static_cast(std::round(static_cast(sampleRate_) * seconds)), + ringBufferFilled_ + ); + if (samples == 0) { + return METER_MIN_LUFS; + } + + double sumL = 0.0; + double sumR = 0.0; + for (size_t index = 0; index < samples; index += 1) { + const size_t bufferIndex = (ringBufferPos_ + bufferLength - 1 - index) % bufferLength; + sumL += ringBufferL_[bufferIndex]; + sumR += ringBufferR_[bufferIndex]; + } + + const double power = std::max((sumL / samples) + (sumR / samples), 1e-10); + return std::max(METER_MIN_LUFS, -0.691 + 10.0 * std::log10(power)); + }; + + snapshot_.momentaryLUFS = static_cast(computeWindow(MOMENTARY_WINDOW_S)); + snapshot_.shortTermLUFS = static_cast(computeWindow(SHORT_TERM_WINDOW_S)); +} + +double LUFSMeterAnalyzer::computeGatedIntegratedLoudness() const { + uint64_t absoluteCount = 0; + double absolutePowerSum = 0.0; + for (size_t index = 0; index < integratedHistogramCounts_.size(); index += 1) { + const uint32_t count = integratedHistogramCounts_[index]; + if (count == 0) { + continue; + } + absoluteCount += count; + absolutePowerSum += integratedHistogramPowerSums_[index]; + } + if (absoluteCount == 0 || absolutePowerSum <= 0.0) { + return METER_MIN_LUFS; + } + + const double ungatedMean = -0.691 + 10.0 * std::log10(absolutePowerSum / absoluteCount); + const double relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET; + + uint64_t relativeCount = 0; + double relativePowerSum = 0.0; + for (size_t index = 0; index < integratedHistogramCounts_.size(); index += 1) { + const uint32_t count = integratedHistogramCounts_[index]; + if (count == 0 || histogramLufsAtIndex(index) <= relativeThreshold) { + continue; + } + relativeCount += count; + relativePowerSum += integratedHistogramPowerSums_[index]; + } + if (relativeCount == 0 || relativePowerSum <= 0.0) { + return METER_MIN_LUFS; + } + + return std::max(METER_MIN_LUFS, -0.691 + 10.0 * std::log10(relativePowerSum / relativeCount)); +} + +size_t LUFSMeterAnalyzer::histogramIndexFromLufs(double lufs) const { + const double normalized = (lufs - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH; + const long rounded = static_cast(std::llround(normalized)); + return static_cast(std::clamp( + rounded, + 0, + static_cast(integratedHistogramCounts_.size() - 1) + )); +} + +double LUFSMeterAnalyzer::histogramLufsAtIndex(size_t index) const { + return INTEGRATED_HISTOGRAM_MIN_LUFS + (static_cast(index) * INTEGRATED_HISTOGRAM_BIN_WIDTH); +} + +double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input) { + const double output = coeffs.b0 * input + coeffs.b1 * state.x1 + coeffs.b2 * state.x2 + - coeffs.a1 * state.y1 - coeffs.a2 * state.y2; + state.x2 = state.x1; + state.x1 = input; + state.y2 = state.y1; + state.y1 = output; + return output; +} + +void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) { + if (fastSqL_.empty()) { + return; + } + + const double sqL = static_cast(left) * left; + const double sqR = static_cast(right) * right; + const double cross = static_cast(left) * right; + + if (fastSampleCount_ == integrationWindowSamples_) { + fastSumSqL_ = std::max(0.0, fastSumSqL_ - fastSqL_[fastWriteIndex_]); + fastSumSqR_ = std::max(0.0, fastSumSqR_ - fastSqR_[fastWriteIndex_]); + fastSumCross_ -= fastCross_[fastWriteIndex_]; + } else { + fastSampleCount_ += 1; + } + + fastSqL_[fastWriteIndex_] = sqL; + fastSqR_[fastWriteIndex_] = sqR; + fastCross_[fastWriteIndex_] = cross; + fastSumSqL_ += sqL; + fastSumSqR_ += sqR; + fastSumCross_ += cross; + fastWriteIndex_ = (fastWriteIndex_ + 1) % integrationWindowSamples_; + + const double absL = std::abs(static_cast(left)); + const double absR = std::abs(static_cast(right)); + const double coeffL = absL > barEnvelopeL_ ? barAttackCoeff_ : barReleaseCoeff_; + 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) { + if (!std::isfinite(nowMs)) { + return; + } + + if (!hasLastPeakUpdate_) { + lastPeakUpdateMs_ = nowMs; + hasLastPeakUpdate_ = true; + return; + } + + if (nowMs <= lastPeakUpdateMs_) { + return; + } + + snapshot_.peakLDb = static_cast(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs)); + snapshot_.peakRDb = static_cast(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs)); + lastPeakUpdateMs_ = nowMs; +} + +void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) { + if (leftChannel) { + if (peakDb > snapshot_.peakLDb) { + snapshot_.peakLDb = static_cast(peakDb); + peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS; + } + return; + } + + if (peakDb > snapshot_.peakRDb) { + snapshot_.peakRDb = static_cast(peakDb); + peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS; + } +} + +double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const { + const double decayStartMs = std::max(lastPeakUpdateMs_, holdUntilMs); + if (nowMs <= decayStartMs) { + return currentDb; + } + + const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND; + return std::max(VU_METER_MIN_DB, currentDb - decayAmount); +} + +void LUFSMeterAnalyzer::recomputeFastSnapshot() { + if (fastSampleCount_ == 0) { + snapshot_.vuLDb = static_cast(VU_METER_MIN_DB); + snapshot_.vuRDb = static_cast(VU_METER_MIN_DB); + snapshot_.barLDb = static_cast(amplitudeToDb(barEnvelopeL_)); + snapshot_.barRDb = static_cast(amplitudeToDb(barEnvelopeR_)); + snapshot_.correlation = 0.0f; + return; + } + + const double meanSqL = std::max(0.0, fastSumSqL_) / fastSampleCount_; + const double meanSqR = std::max(0.0, fastSumSqR_) / fastSampleCount_; + const double denominator = std::sqrt(std::max(0.0, fastSumSqL_) * std::max(0.0, fastSumSqR_)); + + snapshot_.vuLDb = static_cast(amplitudeToDb(std::sqrt(meanSqL))); + snapshot_.vuRDb = static_cast(amplitudeToDb(std::sqrt(meanSqR))); + snapshot_.barLDb = static_cast(amplitudeToDb(barEnvelopeL_)); + snapshot_.barRDb = static_cast(amplitudeToDb(barEnvelopeR_)); + snapshot_.correlation = denominator > 1e-10 + ? static_cast(std::clamp(fastSumCross_ / denominator, -1.0, 1.0)) + : 0.0f; +} + +double LUFSMeterAnalyzer::currentTimeMs() { + using Clock = std::chrono::steady_clock; + const auto now = Clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); +} + +double LUFSMeterAnalyzer::amplitudeToDb(double amplitude) { + if (!std::isfinite(amplitude) || amplitude <= 0.0) { + return VU_METER_MIN_DB; + } + return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB); +} + +double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) { + return std::max(minDb, std::min(maxDb, db)); +} + +LUFSMeterAnalyzer::BiquadCoeffs LUFSMeterAnalyzer::preFilterCoeffs(double sampleRate) { + const double K = std::tan(PI * PRE_FILTER_F0_HZ / sampleRate); + const double Vh = std::pow(10.0, PRE_FILTER_GAIN_DB / 20.0); + const double Vb = std::pow(Vh, 0.499666774155997); + const double KK = K * K; + const double a0 = 1.0 + K / PRE_FILTER_Q + KK; + return { + (Vh + (Vb * K) / PRE_FILTER_Q + KK) / a0, + (2.0 * (KK - Vh)) / a0, + (Vh - (Vb * K) / PRE_FILTER_Q + KK) / a0, + (2.0 * (KK - 1.0)) / a0, + (1.0 - K / PRE_FILTER_Q + KK) / a0, + }; +} + +LUFSMeterAnalyzer::BiquadCoeffs LUFSMeterAnalyzer::rlbFilterCoeffs(double sampleRate) { + const double K = std::tan(PI * RLB_FILTER_F0_HZ / sampleRate); + const double KK = K * K; + const double a0 = 1.0 + K / RLB_FILTER_Q + KK; + return { + 1.0, + -2.0, + 1.0, + (2.0 * (KK - 1.0)) / a0, + (1.0 - K / RLB_FILTER_Q + KK) / a0, + }; +} + +} // namespace Visualizer diff --git a/native/src/lufsmeter.h b/native/src/lufsmeter.h new file mode 100644 index 0000000..2f45bd8 --- /dev/null +++ b/native/src/lufsmeter.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include + +namespace Visualizer { + +struct LUFSMeterSnapshot { + float momentaryLUFS; + float shortTermLUFS; + float integratedLUFS; + float vuLDb; + float vuRDb; + float barLDb; + float barRDb; + float peakLDb; + float peakRDb; + float correlation; +}; + +class LUFSMeterAnalyzer { +public: + LUFSMeterAnalyzer(); + + void setSampleRate(float sampleRate); + void pushSamples(const float* leftChannel, const float* rightChannel, size_t length); + LUFSMeterSnapshot getSnapshot(); + void reset(); + +private: + struct BiquadCoeffs { + double b0; + double b1; + double b2; + double a1; + double a2; + }; + + struct BiquadState { + double x1 = 0.0; + double x2 = 0.0; + double y1 = 0.0; + double y2 = 0.0; + }; + + void configureForSampleRate(float sampleRate); + void configureKWeighting(); + void configureFastMeter(); + void processLoudnessSample(float left, float right); + void updateMomentaryShortTermLoudness(); + double computeGatedIntegratedLoudness() const; + size_t histogramIndexFromLufs(double lufs) const; + 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); + double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const; + void recomputeFastSnapshot(); + static double currentTimeMs(); + static double amplitudeToDb(double amplitude); + static double clampDb(double db, double minDb, double maxDb); + static BiquadCoeffs preFilterCoeffs(double sampleRate); + static BiquadCoeffs rlbFilterCoeffs(double sampleRate); + + float sampleRate_ = 48000.0f; + BiquadCoeffs preCoeffs_{}; + BiquadCoeffs rlbCoeffs_{}; + BiquadState preFilterL_; + BiquadState preFilterR_; + BiquadState rlbFilterL_; + BiquadState rlbFilterR_; + + std::vector ringBufferL_; + std::vector ringBufferR_; + size_t ringBufferPos_ = 0; + size_t ringBufferFilled_ = 0; + size_t integratedHopCounter_ = 0; + std::vector integratedHistogramCounts_; + std::vector integratedHistogramPowerSums_; + + size_t integrationWindowSamples_ = 1; + std::vector fastSqL_; + std::vector fastSqR_; + std::vector fastCross_; + size_t fastWriteIndex_ = 0; + size_t fastSampleCount_ = 0; + double fastSumSqL_ = 0.0; + double fastSumSqR_ = 0.0; + double fastSumCross_ = 0.0; + double barEnvelopeL_ = 0.0; + double barEnvelopeR_ = 0.0; + double barAttackCoeff_ = 0.0; + double barReleaseCoeff_ = 0.0; + double peakHoldUntilL_ = 0.0; + double peakHoldUntilR_ = 0.0; + double lastPeakUpdateMs_ = 0.0; + bool hasLastPeakUpdate_ = false; + + LUFSMeterSnapshot snapshot_{}; +}; + +} // namespace Visualizer diff --git a/native/src/main.cpp b/native/src/main.cpp index b06f9d4..16b34af 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -8,12 +8,14 @@ #include "spectrum.h" #include "spectrogram.h" #include "vectorscope.h" +#include "lufsmeter.h" // Global instances static Visualizer::Oscilloscope oscilloscope; static Visualizer::Spectrum spectrum(2048); static Visualizer::SpectrogramAnalyzer spectrogramAnalyzer; static Visualizer::Vectorscope vectorscope; +static Visualizer::LUFSMeterAnalyzer lufsMeter; // ============== Oscilloscope ============== @@ -426,6 +428,55 @@ Napi::Value VectorscopeReset(const Napi::CallbackInfo& info) { return info.Env().Undefined(); } +// ============== LUFS Meter ============== + +Napi::Value LUFSMeterSetSampleRate(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) { + Napi::TypeError::New(env, "Expected sample rate").ThrowAsJavaScriptException(); + return env.Null(); + } + lufsMeter.setSampleRate(info[0].As().FloatValue()); + return env.Undefined(); +} + +Napi::Value LUFSMeterPushSamples(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) { + Napi::TypeError::New(env, "Expected two Float32Arrays (left, right)").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Float32Array leftData = info[0].As(); + Napi::Float32Array rightData = info[1].As(); + const size_t length = std::min(leftData.ElementLength(), rightData.ElementLength()); + lufsMeter.pushSamples(leftData.Data(), rightData.Data(), length); + return env.Undefined(); +} + +Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto snapshot = lufsMeter.getSnapshot(); + + Napi::Object obj = Napi::Object::New(env); + obj.Set("momentaryLUFS", Napi::Number::New(env, snapshot.momentaryLUFS)); + obj.Set("shortTermLUFS", Napi::Number::New(env, snapshot.shortTermLUFS)); + obj.Set("integratedLUFS", Napi::Number::New(env, snapshot.integratedLUFS)); + obj.Set("vuLDb", Napi::Number::New(env, snapshot.vuLDb)); + 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("correlation", Napi::Number::New(env, snapshot.correlation)); + return obj; +} + +Napi::Value LUFSMeterReset(const Napi::CallbackInfo& info) { + lufsMeter.reset(); + return info.Env().Undefined(); +} + // ============== Module Init ============== Napi::Object Init(Napi::Env env, Napi::Object exports) { @@ -478,6 +529,14 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset)); exports.Set("vectorscope", vecExports); + // LUFS Meter + Napi::Object lufsExports = Napi::Object::New(env); + lufsExports.Set("setSampleRate", Napi::Function::New(env, LUFSMeterSetSampleRate)); + lufsExports.Set("pushSamples", Napi::Function::New(env, LUFSMeterPushSamples)); + lufsExports.Set("getSnapshot", Napi::Function::New(env, LUFSMeterGetSnapshot)); + lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset)); + exports.Set("lufsmeter", lufsExports); + RegisterMacOSCapture(env, exports); RegisterWindowsCapture(env, exports); RegisterLinuxCapture(env, exports); diff --git a/src/preload/index.ts b/src/preload/index.ts index 9a3fb98..95038ee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule spectrum: nativeAddonModule.spectrum, spectrogram: nativeAddonModule.spectrogram, vectorscope: nativeAddonModule.vectorscope, + lufsmeter: nativeAddonModule.lufsmeter, } : null diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 99bf9b1..9f2969e 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -4,6 +4,7 @@ import type { VisualizerDSP, OscilloscopeResult, + LUFSMeterNativeSnapshot, SpectrogramNativeOptions, SpectrogramNativeResult, VectorscopeResult, @@ -167,6 +168,14 @@ export interface SpectrogramNativeAnalyzer { isAvailable?: () => boolean } +export interface LUFSMeterNativeAnalyzer { + setSampleRate(sampleRate: number): void + pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void + getSnapshot(): LUFSMeterNativeSnapshot | null + reset(): void + isAvailable?: () => boolean +} + export const spectrogram: SpectrogramNativeAnalyzer = { isAvailable: (): boolean => { return Boolean(nativeModule?.spectrogram) @@ -229,7 +238,31 @@ export const vectorscope = { } } +export const lufsmeter: LUFSMeterNativeAnalyzer = { + isAvailable: (): boolean => { + return Boolean(nativeModule?.lufsmeter) + }, + + setSampleRate: (sampleRate: number): void => { + nativeModule?.lufsmeter?.setSampleRate(sampleRate) + }, + + pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => { + nativeModule?.lufsmeter?.pushSamples(leftChannel, rightChannel) + }, + + getSnapshot: (): LUFSMeterNativeSnapshot | null => { + if (!nativeModule?.lufsmeter) return null + return nativeModule.lufsmeter.getSnapshot() + }, + + reset: (): void => { + nativeModule?.lufsmeter?.reset() + }, +} + export type { + LUFSMeterNativeSnapshot, OscilloscopeResult, SpectrogramNativeOptions, SpectrogramNativeResult, diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 72ff7aa..149a982 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -41,6 +41,19 @@ export interface SpectrogramNativeResult { rowCount: number; } +export interface LUFSMeterNativeSnapshot { + momentaryLUFS: number; + shortTermLUFS: number; + integratedLUFS: number; + vuLDb: number; + vuRDb: number; + barLDb: number; + barRDb: number; + peakLDb: number; + peakRDb: number; + correlation: number; +} + // Circular buffer size (must match native code) export const OSCILLOSCOPE_BUFFER_SIZE = 32768; @@ -102,11 +115,19 @@ export interface VectorscopeModule { reset(): void; } +export interface LUFSMeterModule { + setSampleRate(sampleRate: number): void; + pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void; + getSnapshot(): LUFSMeterNativeSnapshot; + reset(): void; +} + export interface VisualizerDSP { oscilloscope: OscilloscopeModule; spectrum: SpectrumModule; spectrogram: SpectrogramModule; vectorscope: VectorscopeModule; + lufsmeter: LUFSMeterModule; } declare const visualizerDSP: VisualizerDSP; diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 14f60ea..f70df5b 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -1,14 +1,14 @@ import { audioRouter } from '../audio/AudioRouter' +import { + lufsmeter as nativeLUFSMeter, + type LUFSMeterNativeAnalyzer, + type LUFSMeterNativeSnapshot, +} from '../audio/native' import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter' import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' -import { - VUMeterBallistics, - VU_METER_MIN_DB, - type VUMeterSnapshot, -} from './vuMeterBallistics' export interface LUFSMeterDataSource extends VisualizerSessionSource { getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }> @@ -25,9 +25,10 @@ export interface LUFSMeterOptions { labelColor?: string dataSource?: LUFSMeterDataSource frameScheduler?: FrameScheduler + nativeAnalyzer?: LUFSMeterNativeAnalyzer | null } -type ResolvedLUFSMeterOptions = Required> +type ResolvedLUFSMeterOptions = Required> const defaultOptions: ResolvedLUFSMeterOptions = { mode: 'bar', @@ -67,114 +68,45 @@ function contrastRatio(luminanceA: number, luminanceB: number): number { const METER_MIN_LUFS = -60 const COMPACT_METER_MIN_DB = -50 const COMPACT_METER_MAX_DB = 0 -const MOMENTARY_WINDOW_S = 0.4 -const SHORT_TERM_WINDOW_S = 3.0 -const INTEGRATED_BLOCK_S = 0.4 -const INTEGRATED_HOP_S = 0.1 -const ABSOLUTE_GATE_LUFS = -70 -const RELATIVE_GATE_OFFSET = -10 const TARGET_LUFS = -14 -const SMOOTHING = 0.7 -const INTEGRATED_HISTOGRAM_MIN_LUFS = ABSOLUTE_GATE_LUFS -const INTEGRATED_HISTOGRAM_MAX_LUFS = 10 -const INTEGRATED_HISTOGRAM_BIN_WIDTH = 0.1 -const INTEGRATED_HISTOGRAM_BIN_COUNT = Math.round( - (INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH -) + 1 +const METER_MIN_DB = -60 -const INITIAL_VU_SNAPSHOT: VUMeterSnapshot = { - vuLDb: VU_METER_MIN_DB, - vuRDb: VU_METER_MIN_DB, - barLDb: VU_METER_MIN_DB, - barRDb: VU_METER_MIN_DB, - peakLDb: VU_METER_MIN_DB, - peakRDb: VU_METER_MIN_DB, +const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = { + momentaryLUFS: METER_MIN_LUFS, + shortTermLUFS: METER_MIN_LUFS, + integratedLUFS: METER_MIN_LUFS, + vuLDb: METER_MIN_DB, + vuRDb: METER_MIN_DB, + barLDb: METER_MIN_DB, + barRDb: METER_MIN_DB, + peakLDb: METER_MIN_DB, + peakRDb: METER_MIN_DB, correlation: 0, } -// ---- K-weighting filter coefficients (ITU-R BS.1770-4) ---- - -interface BiquadCoeffs { - b0: number; b1: number; b2: number - a1: number; a2: number +function finiteNumber(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback } -// 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, +function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFSMeterNativeSnapshot { + if (!snapshot) { + return { ...INITIAL_NATIVE_SNAPSHOT } } -} -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, + momentaryLUFS: finiteNumber(snapshot.momentaryLUFS, METER_MIN_LUFS), + shortTermLUFS: finiteNumber(snapshot.shortTermLUFS, METER_MIN_LUFS), + integratedLUFS: finiteNumber(snapshot.integratedLUFS, METER_MIN_LUFS), + vuLDb: finiteNumber(snapshot.vuLDb, METER_MIN_DB), + 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), + correlation: finiteNumber(snapshot.correlation, 0), } } -function getKWeightingCoeffs(sampleRate: number): { pre: BiquadCoeffs; rlb: BiquadCoeffs } { - return { - pre: preFilterCoeffs(sampleRate), - rlb: rlbFilterCoeffs(sampleRate), - } -} - -// ---- Biquad filter state ---- - -interface BiquadState { - x1: number; x2: number - y1: number; y2: number -} - -function createBiquadState(): BiquadState { - return { x1: 0, x2: 0, y1: 0, y2: 0 } -} - -function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): number { - const output = coeffs.b0 * input + coeffs.b1 * state.x1 + coeffs.b2 * state.x2 - - coeffs.a1 * state.y1 - coeffs.a2 * state.y2 - state.x2 = state.x1 - state.x1 = input - state.y2 = state.y1 - state.y1 = output - return output -} - -function histogramIndexFromLufs(lufs: number): number { - const normalized = (lufs - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH - return Math.max(0, Math.min(INTEGRATED_HISTOGRAM_BIN_COUNT - 1, Math.round(normalized))) -} - -function histogramLufsAtIndex(index: number): number { - return INTEGRATED_HISTOGRAM_MIN_LUFS + (index * INTEGRATED_HISTOGRAM_BIN_WIDTH) -} - // ---- Loudness meter class ---- export class LUFSMeter { @@ -182,34 +114,12 @@ export class LUFSMeter { private ctx: CanvasRenderingContext2D private options: ResolvedLUFSMeterOptions private dataSource: LUFSMeterDataSource + private nativeAnalyzer: LUFSMeterNativeAnalyzer | null private frameLoop: VisualizerFrameLoop - private meterBallistics: VUMeterBallistics - - // K-weighting filter state (per channel, two stages) - private preFilterL = createBiquadState() - private preFilterR = createBiquadState() - private rlbFilterL = createBiquadState() - private rlbFilterR = createBiquadState() - private currentSampleRate = 48000 - private kWeightingCoeffs = getKWeightingCoeffs(48000) - - // Ring buffer for K-weighted squared samples (sized for SHORT_TERM_WINDOW_S) - private ringBufferL = new Float32Array(0) - private ringBufferR = new Float32Array(0) - private ringBufferPos = 0 - private ringBufferFilled = 0 // how many samples have been written total (capped at buffer size) - - // 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) - - // Smoothed display values - private momentaryLUFS = METER_MIN_LUFS - private shortTermLUFS = METER_MIN_LUFS - private integratedLUFS = METER_MIN_LUFS - private fastSnapshot: VUMeterSnapshot = { ...INITIAL_VU_SNAPSHOT } + private currentSampleRate = 0 + private snapshot: LUFSMeterNativeSnapshot = { ...INITIAL_NATIVE_SNAPSHOT } + private pushScratchL = new Float32Array(0) + private pushScratchR = new Float32Array(0) private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: LUFSMeterOptions = {}) { @@ -218,17 +128,17 @@ export class LUFSMeter { if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, frameScheduler, ...optionOverrides } = options + const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultLUFSMeterDataSource - this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate()) + this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeLUFSMeter : nativeAnalyzer this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), onFrame: this.drawFrame, }) - this.initRingBuffer(this.dataSource.getSampleRate()) + this.resetMeters() this.subscribeToSessionChanges() } @@ -241,47 +151,34 @@ export class LUFSMeter { }) } - private initRingBuffer(sampleRate: number): void { - this.currentSampleRate = Math.max(1, sampleRate) - this.kWeightingCoeffs = getKWeightingCoeffs(this.currentSampleRate) - this.meterBallistics.reinitialize(this.currentSampleRate) - const bufferSize = Math.ceil(this.currentSampleRate * SHORT_TERM_WINDOW_S) - this.ringBufferL = new Float32Array(bufferSize) - this.ringBufferR = new Float32Array(bufferSize) - this.ringBufferPos = 0 - this.ringBufferFilled = 0 - } - private resetMeters(): void { - this.momentaryLUFS = METER_MIN_LUFS - this.shortTermLUFS = METER_MIN_LUFS - this.integratedLUFS = METER_MIN_LUFS - this.meterBallistics.reset() - this.fastSnapshot = this.meterBallistics.getSnapshot() - this.ringBufferL.fill(0) - this.ringBufferR.fill(0) - this.ringBufferPos = 0 - this.ringBufferFilled = 0 - this.integratedHopCounter = 0 - this.integratedHistogramCounts.fill(0) - this.integratedHistogramPowerSums.fill(0) - this.preFilterL = createBiquadState() - this.preFilterR = createBiquadState() - this.rlbFilterL = createBiquadState() - this.rlbFilterR = createBiquadState() + this.currentSampleRate = Math.max(1, this.dataSource.getSampleRate()) + this.snapshot = { ...INITIAL_NATIVE_SNAPSHOT } + if (this.isNativeAnalyzerReady()) { + this.nativeAnalyzer?.setSampleRate(this.currentSampleRate) + this.nativeAnalyzer?.reset() + } this.invalidate() } setOptions(options: Partial): void { - const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } + let didReset = false + if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { + this.nativeAnalyzer = nativeAnalyzer + this.resetMeters() + didReset = true + } if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() - this.initRingBuffer(this.dataSource.getSampleRate()) this.resetMeters() + didReset = true + } + if (!didReset) { + this.invalidate() } - this.invalidate() } start(): void { @@ -303,149 +200,72 @@ export class LUFSMeter { private processAudio(): void { const chunks = this.dataSource.getPendingLUFSMeterSamples() + const sampleRate = Math.max(1, this.dataSource.getSampleRate()) - // Check if sample rate changed - const sr = this.dataSource.getSampleRate() - if (Math.abs(sr - this.currentSampleRate) > 100) { - this.initRingBuffer(sr) + if (Math.abs(sampleRate - this.currentSampleRate) > 100) { this.resetMeters() } - const playing = this.dataSource.isPlaying() - - if (!playing && chunks.length === 0) { - this.meterBallistics.reset() - this.fastSnapshot = this.meterBallistics.getSnapshot() - // Decay toward silence only when truly stopped - this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING) - this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING) + if (!this.isNativeAnalyzerReady() || !this.dataSource.isPlaying()) { + this.nativeAnalyzer?.reset() + this.snapshot = { ...INITIAL_NATIVE_SNAPSHOT } return } - this.fastSnapshot = this.meterBallistics.process(chunks, performance.now()) - - // Process any new audio chunks into the ring buffer if (chunks.length > 0) { - const { pre, rlb } = this.kWeightingCoeffs - const bufLen = this.ringBufferL.length - const hopSamples = Math.round(this.currentSampleRate * INTEGRATED_HOP_S) - const blockSamples = Math.round(this.currentSampleRate * INTEGRATED_BLOCK_S) - - for (const chunk of chunks) { - const len = Math.min(chunk.left.length, chunk.right.length) - for (let i = 0; i < len; i++) { - // Apply K-weighting: pre-filter then RLB, per channel - const kwL = applyBiquad(rlb, this.rlbFilterL, applyBiquad(pre, this.preFilterL, chunk.left[i])) - const kwR = applyBiquad(rlb, this.rlbFilterR, applyBiquad(pre, this.preFilterR, chunk.right[i])) - - // Store squared K-weighted samples in ring buffer - const sqL = kwL * kwL - const sqR = kwR * kwR - this.ringBufferL[this.ringBufferPos] = sqL - this.ringBufferR[this.ringBufferPos] = sqR - this.ringBufferPos = (this.ringBufferPos + 1) % bufLen - if (this.ringBufferFilled < bufLen) this.ringBufferFilled++ - - this.integratedHopCounter++ - - // 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 - } - this.integratedHopCounter = 0 - } - } + const batch = this.concatStereoChunks(chunks) + if (batch.left.length > 0 && batch.right.length > 0) { + this.nativeAnalyzer?.pushSamples(batch.left, batch.right) } } - // Always compute M/S from the ring buffer (it persists across frames) - const bufLen = this.ringBufferL.length - - // Compute momentary loudness (last 400ms) - const momentarySamples = Math.min( - Math.round(this.currentSampleRate * MOMENTARY_WINDOW_S), - this.ringBufferFilled - ) - if (momentarySamples > 0) { - let sumL = 0, sumR = 0 - for (let i = 0; i < momentarySamples; i++) { - const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen - sumL += this.ringBufferL[idx] - sumR += this.ringBufferR[idx] - } - const rawM = -0.691 + 10 * Math.log10(Math.max(sumL / momentarySamples + sumR / momentarySamples, 1e-10)) - this.momentaryLUFS = Math.max(METER_MIN_LUFS, rawM) - } - - // Compute short-term loudness (last 3s) - const shortTermSamples = Math.min( - Math.round(this.currentSampleRate * SHORT_TERM_WINDOW_S), - this.ringBufferFilled - ) - if (shortTermSamples > 0) { - let sumL = 0, sumR = 0 - for (let i = 0; i < shortTermSamples; i++) { - const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen - sumL += this.ringBufferL[idx] - sumR += this.ringBufferR[idx] - } - const rawS = -0.691 + 10 * Math.log10(Math.max(sumL / shortTermSamples + sumR / shortTermSamples, 1e-10)) - this.shortTermLUFS = Math.max(METER_MIN_LUFS, rawS) - } - - // Compute integrated loudness with gating - this.integratedLUFS = this.computeGatedIntegratedLoudness() + this.snapshot = normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null) } - private computeGatedIntegratedLoudness(): number { - let absoluteCount = 0 - let absolutePowerSum = 0 - for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) { - const count = this.integratedHistogramCounts[index] - if (count === 0) { - continue - } - absoluteCount += count - absolutePowerSum += this.integratedHistogramPowerSums[index] + private isNativeAnalyzerReady(): boolean { + if (!this.nativeAnalyzer) { + return false } - if (absoluteCount === 0 || absolutePowerSum <= 0) { - return METER_MIN_LUFS + return this.nativeAnalyzer.isAvailable?.() ?? true + } + + private concatStereoChunks(chunks: Array<{ left: Float32Array; right: Float32Array }>): { left: Float32Array; right: Float32Array } { + if (chunks.length === 1) { + const chunk = chunks[0] + const length = Math.min(chunk.left.length, chunk.right.length) + return { + left: chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length), + right: chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length), + } } - const ungatedMean = -0.691 + 10 * Math.log10(absolutePowerSum / absoluteCount) - const relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET - - let relativeCount = 0 - let relativePowerSum = 0 - for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) { - const count = this.integratedHistogramCounts[index] - if (count === 0) { - continue - } - if (histogramLufsAtIndex(index) <= relativeThreshold) { - continue - } - relativeCount += count - relativePowerSum += this.integratedHistogramPowerSums[index] + let totalLength = 0 + for (const chunk of chunks) { + totalLength += Math.min(chunk.left.length, chunk.right.length) } - if (relativeCount === 0 || relativePowerSum <= 0) { - return METER_MIN_LUFS + if (totalLength === 0) { + return { left: new Float32Array(0), right: new Float32Array(0) } } - return Math.max(METER_MIN_LUFS, -0.691 + 10 * Math.log10(relativePowerSum / relativeCount)) + if (this.pushScratchL.length < totalLength) { + this.pushScratchL = new Float32Array(totalLength) + this.pushScratchR = new Float32Array(totalLength) + } + + const left = this.pushScratchL.subarray(0, totalLength) + const right = this.pushScratchR.subarray(0, totalLength) + let offset = 0 + for (const chunk of chunks) { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length <= 0) { + continue + } + left.set(chunk.left.subarray(0, length), offset) + right.set(chunk.right.subarray(0, length), offset) + offset += length + } + + return { left, right } } private drawFrame = (): void => { @@ -471,12 +291,12 @@ export class LUFSMeter { private selectedLufs(): number { switch (this.options.readout) { case 'momentary': - return this.momentaryLUFS + return this.snapshot.momentaryLUFS case 'shortTerm': - return this.shortTermLUFS + return this.snapshot.shortTermLUFS case 'integrated': default: - return this.integratedLUFS + return this.snapshot.integratedLUFS } } @@ -578,8 +398,8 @@ export class LUFSMeter { meterTop, barWidth, meterHeight, - this.fastSnapshot.barLDb, - this.fastSnapshot.peakLDb, + this.snapshot.barLDb, + this.snapshot.peakLDb, tint, dpr, ) @@ -588,8 +408,8 @@ export class LUFSMeter { meterTop, barWidth, meterHeight, - this.fastSnapshot.barRDb, - this.fastSnapshot.peakRDb, + this.snapshot.barRDb, + this.snapshot.peakRDb, tint, dpr, ) @@ -671,5 +491,8 @@ export class LUFSMeter { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } + if (this.isNativeAnalyzerReady()) { + this.nativeAnalyzer?.reset() + } } } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 52dc139..12f6730 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -76,6 +76,8 @@ import { type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' import type { + LUFSMeterNativeAnalyzer, + LUFSMeterNativeSnapshot, SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult, @@ -4024,6 +4026,49 @@ test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => { assert.notEqual(pointTarget.low.left[0], 0) }) +function createFakeLUFSMeterNativeAnalyzer( + snapshotOverrides: Partial = {}, + available = true, +): LUFSMeterNativeAnalyzer & { + pushed: Array<{ left: Float32Array; right: Float32Array }> + resetCount: number + sampleRates: number[] +} { + const analyzer = { + pushed: [] as Array<{ left: Float32Array; right: Float32Array }>, + resetCount: 0, + sampleRates: [] as number[], + snapshot: { + momentaryLUFS: -18.4, + shortTermLUFS: -19.1, + integratedLUFS: -20.2, + vuLDb: -12, + vuRDb: -13, + barLDb: -10, + barRDb: -12, + peakLDb: -4, + peakRDb: -5, + correlation: 0.5, + ...snapshotOverrides, + } satisfies LUFSMeterNativeSnapshot, + isAvailable: () => available, + setSampleRate(sampleRate: number): void { + this.sampleRates.push(sampleRate) + }, + pushSamples(left: Float32Array, right: Float32Array): void { + this.pushed.push({ left: new Float32Array(left), right: new Float32Array(right) }) + }, + getSnapshot(): LUFSMeterNativeSnapshot { + return this.snapshot + }, + reset(): void { + this.resetCount += 1 + }, + } + + return analyzer +} + test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => { const dom = installFakeCanvasDom() const recorder = createFakeCanvasRecorder() @@ -4048,8 +4093,10 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 }) const meter = new LUFSMeter(createFakeCanvas(recorder), { dataSource, + nativeAnalyzer, readout: 'momentary', lineColor: 'rgb(255, 0, 96)', trackColor: 'rgba(255, 0, 96, 0.08)', @@ -4061,6 +4108,8 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a try { ;(meter as unknown as { drawFrame: () => void }).drawFrame() + assert.equal(nativeAnalyzer.pushed.length, 1) + assert.equal(nativeAnalyzer.pushed[0]?.left.length, leftChunk.length) const trackRects = recorder.fillRects.filter((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.08)') assert.equal(trackRects.length >= 3, true) assert.equal(trackRects.some((rect) => rect.width > 12), true) @@ -4120,8 +4169,10 @@ test('LUFSMeter fits readout text inside narrow tags', () => { isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 }) const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), { dataSource, + nativeAnalyzer, readout: 'momentary', lineColor: 'rgb(255, 0, 96)', trackColor: 'rgba(255, 0, 96, 0.08)', @@ -4149,8 +4200,17 @@ test('LUFSMeter fits readout text inside narrow tags', () => { } }) -test('LUFSMeter keeps integrated history bounded over long runs', () => { - const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [] +test('LUFSMeter concatenates queued chunks before pushing them to native DSP', () => { + const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [ + { + left: new Float32Array([1, 2, 3]), + right: new Float32Array([4, 5, 6]), + }, + { + left: new Float32Array([7, 8]), + right: new Float32Array([9, 10]), + }, + ] const dataSource = { getPendingLUFSMeterSamples: () => { const drained = chunkQueue.slice() @@ -4161,30 +4221,94 @@ test('LUFSMeter keeps integrated history bounded over long runs', () => { isPlaying: () => true, subscribeToSessionChanges: () => () => {}, } - const meter = new LUFSMeter(createFakeCanvas(), { dataSource }) + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer() + const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) + + try { + ;(meter as unknown as { processAudio: () => void }).processAudio() + + assert.equal(nativeAnalyzer.pushed.length, 1) + assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.left ?? []), [1, 2, 3, 7, 8]) + assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.right ?? []), [4, 5, 6, 9, 10]) + } finally { + meter.dispose() + } +}) + +test('LUFSMeter resets native DSP when the sample rate or session changes', () => { + let sampleRate = 48000 + let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ + { left: new Float32Array([0.1]), right: new Float32Array([0.2]) }, + ] + let sessionListener: (() => void) | null = null + const dataSource = { + getPendingLUFSMeterSamples: () => { + const drained = pendingChunks + pendingChunks = [] + return drained + }, + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: (listener: () => void) => { + sessionListener = listener + return () => {} + }, + } + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer() + const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer }) const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter) - const leftChunk = new Float32Array(4800) - const rightChunk = new Float32Array(4800) - for (let index = 0; index < leftChunk.length; index += 1) { - const sample = index % 2 === 0 ? 0.35 : -0.35 - leftChunk[index] = sample - rightChunk[index] = sample - } - for (let iteration = 0; iteration < 400; iteration += 1) { - chunkQueue.push({ - left: leftChunk, - right: rightChunk, - }) + try { processAudio() - } + sampleRate = 96000 + pendingChunks = [{ left: new Float32Array([0.3]), right: new Float32Array([0.4]) }] + processAudio() + sessionListener?.() - const histogramCounts = (meter as unknown as { integratedHistogramCounts: Uint32Array }).integratedHistogramCounts - const storedBlocks = histogramCounts.reduce((total, count) => total + count, 0) - assert.equal(Object.prototype.hasOwnProperty.call(meter, 'integratedBlockLoudness'), false) - assert.equal(histogramCounts.length > 0, true) - assert.equal(storedBlocks > 100, true) - assert.equal(Number.isFinite((meter as unknown as { integratedLUFS: number }).integratedLUFS), true) + assert.deepEqual(nativeAnalyzer.sampleRates, [48000, 96000, 96000]) + assert.equal(nativeAnalyzer.resetCount, 3) + } finally { + meter.dispose() + } +}) + +test('LUFSMeter drains audio and renders silence when native DSP is unavailable', () => { + const dom = installFakeCanvasDom() + const recorder = createFakeCanvasRecorder() + let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [ + { left: new Float32Array([0.9, 0.9]), right: new Float32Array([0.9, 0.9]) }, + ] + const dataSource = { + getPendingLUFSMeterSamples: () => { + const drained = pendingChunks + pendingChunks = [] + return drained + }, + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -5 }, false) + const meter = new LUFSMeter(createFakeCanvas(recorder), { + dataSource, + nativeAnalyzer, + readout: 'momentary', + lineColor: 'rgb(255, 0, 96)', + }) + + try { + ;(meter as unknown as { drawFrame: () => void }).drawFrame() + + assert.equal(nativeAnalyzer.pushed.length, 0) + assert.equal(pendingChunks.length, 0) + assert.equal( + recorder.fillTexts.some((text) => text.text === '-∞LUFS'), + true, + ) + } finally { + meter.dispose() + dom.restore() + } }) test('NativePolledCaptureBackend forwards all drained chunks, respects hidden-document backoff, and cancels on stop', async () => {