diff --git a/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.cpp b/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.cpp index a56989a7..64b7c275 100644 --- a/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.cpp +++ b/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.cpp @@ -19,6 +19,8 @@ #include +#include "TruePeak.h" + MeasureEBU128::MeasureEBU128(const double sampleRate, const juce::AudioChannelSet& channelSet) : loudnessMeter_(), kSampleRate_(sampleRate), playbackLayout_(channelSet) { @@ -28,12 +30,10 @@ MeasureEBU128::MeasureEBU128(const double sampleRate, MeasureEBU128::LoudnessStats MeasureEBU128::measureLoudness( const juce::AudioChannelSet& currPlaybackLayout, const juce::AudioBuffer& buffer) { - // If the playback layout has changed or the buffer isn't sized as expected - // reconfigure, reset internal loudness stats. + // If the playback layout has changed, reconfigure and reset internal loudness + // stats. if (buffer.getNumChannels() != currPlaybackLayout.size() || - playbackLayout_ != currPlaybackLayout || - upsampledBuffer_.getNumSamples() < - buffer.getNumSamples() * upsampleRatio_) { + playbackLayout_ != currPlaybackLayout) { reset(currPlaybackLayout, buffer); LOG_INFO( 0, "measureLoudness: Mismatch between provided layout and buffer size"); @@ -60,28 +60,11 @@ MeasureEBU128::LoudnessStats MeasureEBU128::measureLoudness( void MeasureEBU128::reset(const juce::AudioChannelSet& currPlaybackLayout, const juce::AudioBuffer& buffer) { playbackLayout_ = currPlaybackLayout; - upsampleRatio_ = 192e3 / kSampleRate_; // ITU 1770-5 Annex 2. loudnessMeter_.prepareToPlay(kSampleRate_, playbackLayout_.size(), buffer.getNumSamples(), 1); - int numChannels = playbackLayout_.size(); - perChannelResamplers_.clear(); - for (int i = 0; i < numChannels; ++i) { - perChannelResamplers_.emplace_back(juce::Interpolators::Lagrange()); - } - upsampledBuffer_.setSize(numChannels, - buffer.getNumSamples() * upsampleRatio_); - - lpf_.block = juce::dsp::AudioBlock(upsampledBuffer_); - lpf_.filter.state = - juce::dsp::FilterDesign::designFIRLowpassWindowMethod( - 20e3, upsampleRatio_ * kSampleRate_, 49, - juce::dsp::WindowingFunction::hann); - juce::dsp::ProcessSpec spec{upsampleRatio_ * kSampleRate_, - (unsigned)upsampledBuffer_.getNumSamples(), - (unsigned)upsampledBuffer_.getNumChannels()}; - lpf_.filter.prepare(spec); - lpf_.filter.reset(); + // Initialize true peak meter + truePeakMeter_.reset(kSampleRate_, currPlaybackLayout); loudnessStats_ = {-std::numeric_limits::infinity(), -std::numeric_limits::infinity(), @@ -91,38 +74,10 @@ void MeasureEBU128::reset(const juce::AudioChannelSet& currPlaybackLayout, -std::numeric_limits::infinity()}; } -// ITU 1770-5 Annex 2. +// ITU-R BS.1770-4 True Peak using polyphase FIR filter. float MeasureEBU128::calculateTruePeakLevel( const juce::AudioBuffer& buffer) { - // Upsample buffer. LPF. - for (int i = 0; i < buffer.getNumChannels(); ++i) { - // Skip LFE channel. - if (playbackLayout_.getTypeOfChannel(i) == juce::AudioChannelSet::LFE) { - upsampledBuffer_.clear(i, 0, upsampledBuffer_.getNumSamples()); - continue; - } - perChannelResamplers_[i].process( - 1.0f / upsampleRatio_, buffer.getReadPointer(i), - upsampledBuffer_.getWritePointer(i), upsampledBuffer_.getNumSamples()); - } - - // LPF. - juce::dsp::ProcessContextReplacing ctx(lpf_.block); - lpf_.filter.process(ctx); - - // Max absolute value over all channels. - float truePeak = - upsampledBuffer_.getMagnitude(0, upsampledBuffer_.getNumSamples()); - - // Convert to dB TP - float truePeakdB = 20.0f * std::log10(truePeak); - - // Do some sanitation here as resampling can introduce unreasonably high - // values. - if (truePeakdB > 15.f) { - return std::numeric_limits::quiet_NaN(); - } - return truePeakdB; + return truePeakMeter_.compute(buffer); } // digital_peak specifies the the digital (sampled) peak of the audio signal. diff --git a/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.h b/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.h index 967f94c0..660cb1d9 100644 --- a/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.h +++ b/common/processors/mix_monitoring/loudness_standards/MeasureEBU128.h @@ -20,6 +20,7 @@ #include #include "EBU128LoudnessMeter.h" +#include "TruePeak.h" class MeasureEBU128 { public: @@ -32,10 +33,6 @@ class MeasureEBU128 { /** * @brief Create a loudness measurement object for a given sample rate and * rendering layout. - * NOTE: - * Filter coefficients are hard-coded from ITU 1770 for a sample rate of - * 48kHz, so calculations at other sample rates are currently expected to be - * inaccurate. * @param sampleRate * @param chData Playback layout for which to measure loudness. */ @@ -65,12 +62,6 @@ class MeasureEBU128 { float calculateDigitalPeak(const juce::AudioBuffer& buffer); - struct LPF { - juce::dsp::AudioBlock block; - juce::dsp::ProcessorDuplicator, - juce::dsp::FIR::Coefficients> - filter; - }; // Playback information const double kSampleRate_; juce::AudioChannelSet playbackLayout_; @@ -78,11 +69,8 @@ class MeasureEBU128 { // Library for calculating loudness and range values Ebu128LoudnessMeter loudnessMeter_; - // Resamplers for true peak calculation. - int upsampleRatio_ = 4; // Upsampling ratio for true peak calculation. - juce::AudioBuffer upsampledBuffer_; // Larger buffer to upsample into. - std::vector perChannelResamplers_; - LPF lpf_; + // True peak calculator + TruePeak truePeakMeter_; // Internal copy of calculated loudness statistics to return when // loudnesses' are queried between measurement periods. diff --git a/common/processors/mix_monitoring/loudness_standards/TruePeak.cpp b/common/processors/mix_monitoring/loudness_standards/TruePeak.cpp new file mode 100644 index 00000000..6a540812 --- /dev/null +++ b/common/processors/mix_monitoring/loudness_standards/TruePeak.cpp @@ -0,0 +1,134 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "TruePeak.h" + +void TruePeak::reset(const double sampleRate, + const juce::AudioChannelSet& channelSet) { + channelSet_ = channelSet; + // Initialize history arrays for each channel + channelHistory_.clear(); + channelHistory_.resize(channelSet.size()); + for (auto& history : channelHistory_) { + history.fill(0.0f); + } + currentTruePeak_ = -std::numeric_limits::infinity(); +} + +float TruePeak::compute(const juce::AudioBuffer& buffer) { + if (channelSet_.isDisabled()) { + return -1000; + } + if (buffer.getNumChannels() != channelSet_.size()) { + return -1000; + } + + currentTruePeak_ = -std::numeric_limits::infinity(); + + // Process each channel + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + // Skip LFE channel + if (channelSet_.getTypeOfChannel(ch) == juce::AudioChannelSet::LFE) { + continue; + } + + const float* inputData = buffer.getReadPointer(ch); + int numSamples = buffer.getNumSamples(); + auto& history = channelHistory_[ch]; + + // Phase 1: Process the first 11 samples (requires history) + int overlapCount = std::min(numSamples, kHistorySize); + for (int i = 0; i < overlapCount; ++i) { + processSingleSampleWithHistory(inputData, i, history); + } + + // Phase 2: Process the rest of the block + for (int i = kHistorySize; i < numSamples; ++i) { + processSingleSampleLinear(inputData, i); + } + + // Phase 3: Save the last 11 samples for the next block's history + if (numSamples >= kHistorySize) { + for (int i = 0; i < kHistorySize; ++i) { + history[i] = inputData[numSamples - kHistorySize + i]; + } + } else { + // Edge case: Block size is smaller than 11 + // Shift history left and append new samples + int shift = kHistorySize - numSamples; + for (int i = 0; i < shift; ++i) { + history[i] = history[i + numSamples]; + } + for (int i = 0; i < numSamples; ++i) { + history[shift + i] = inputData[i]; + } + } + } + + // Convert to dB and sanitize outputs + if (currentTruePeak_ > 0.0f) { + float truePeakdB = 20.0f * std::log10(currentTruePeak_); + if (truePeakdB > 15.0f) { + return std::numeric_limits::quiet_NaN(); + } + return truePeakdB; + } + + return -std::numeric_limits::infinity(); +} + +void TruePeak::processSingleSampleLinear(const float* data, int currentIndex) { + // Calculate the 4 upsampled points directly from the linear buffer + float out0 = 0.0f, out1 = 0.0f, out2 = 0.0f, out3 = 0.0f; + + for (int m = 0; m < kTapsPerPhase; ++m) { + float sample = data[currentIndex - m]; + out0 += kPhase0[m] * sample; + out1 += kPhase1[m] * sample; + out2 += kPhase2[m] * sample; + out3 += kPhase3[m] * sample; + } + + updatePeak(out0, out1, out2, out3); +} + +void TruePeak::processSingleSampleWithHistory( + const float* data, int currentIndex, + const std::array& history) { + // Calculate the 4 upsampled points using history when necessary + float out0 = 0.0f, out1 = 0.0f, out2 = 0.0f, out3 = 0.0f; + + for (int m = 0; m < kTapsPerPhase; ++m) { + float sample; + int index = currentIndex - m; + if (index >= 0) { + sample = data[index]; + } else { + // Read from history array + sample = history[kHistorySize + index]; + } + + out0 += kPhase0[m] * sample; + out1 += kPhase1[m] * sample; + out2 += kPhase2[m] * sample; + out3 += kPhase3[m] * sample; + } + + updatePeak(out0, out1, out2, out3); +} + +void TruePeak::updatePeak(float o0, float o1, float o2, float o3) { + currentTruePeak_ = std::max({currentTruePeak_, std::abs(o0), std::abs(o1), + std::abs(o2), std::abs(o3)}); +} \ No newline at end of file diff --git a/common/processors/mix_monitoring/loudness_standards/TruePeak.h b/common/processors/mix_monitoring/loudness_standards/TruePeak.h new file mode 100644 index 00000000..4a125073 --- /dev/null +++ b/common/processors/mix_monitoring/loudness_standards/TruePeak.h @@ -0,0 +1,73 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +#include + +/** + * @brief Computes the `true peak` measurement described in ITU-R BS.1770-4. + * Uses a polyphase filter approach similar to the FFmpeg. + * + */ + +class TruePeak { + public: + float compute(const juce::AudioBuffer& buffer); + void reset(const double sampleRate, const juce::AudioChannelSet& channelSet); + + private: + // ITU-R BS.1770-4 48-tap FIR filter coefficients split into 4 phases of 12 + // taps + static constexpr int kHistorySize = 11; + static constexpr int kTapsPerPhase = 12; + static constexpr int kNumPhases = 4; + + // The 4 polyphase filter coefficient arrays (12 taps each) + static constexpr float kPhase0[kTapsPerPhase] = { + 0.0017089843750f, 0.0109863281250f, -0.0196533203125f, + 0.0332031250000f, -0.0594482421875f, 0.1373291015625f, + 0.9721679687500f, -0.1022949218750f, 0.0476074218750f, + -0.0266113281250f, 0.0148925781250f, -0.0083007812500f}; + + static constexpr float kPhase1[kTapsPerPhase] = { + -0.0291748046875f, 0.0292968750000f, -0.0517578125000f, + 0.0891113281250f, -0.1665039062500f, 0.4650878906250f, + 0.7797851562500f, -0.2003173828125f, 0.1015625000000f, + -0.0582275390625f, 0.0330810546875f, -0.0189208984375f}; + + static constexpr float kPhase2[kTapsPerPhase] = { + -0.0189208984375f, 0.0330810546875f, -0.0582275390625f, + 0.1015625000000f, -0.2003173828125f, 0.7797851562500f, + 0.4650878906250f, -0.1665039062500f, 0.0891113281250f, + -0.0517578125000f, 0.0292968750000f, -0.0291748046875f}; + + static constexpr float kPhase3[kTapsPerPhase] = { + -0.0083007812500f, 0.0148925781250f, -0.0266113281250f, + 0.0476074218750f, -0.1022949218750f, 0.9721679687500f, + 0.1373291015625f, -0.0594482421875f, 0.0332031250000f, + -0.0196533203125f, 0.0109863281250f, 0.0017089843750f}; + + void processSingleSampleLinear(const float* data, int currentIndex); + void processSingleSampleWithHistory( + const float* data, int currentIndex, + const std::array& history); + void updatePeak(float o0, float o1, float o2, float o3); + + juce::AudioChannelSet channelSet_ = juce::AudioChannelSet::disabled(); + std::vector> channelHistory_; + float currentTruePeak_ = -std::numeric_limits::infinity(); +}; \ No newline at end of file diff --git a/common/processors/processors.cpp b/common/processors/processors.cpp index 183eac81..1ff600c0 100644 --- a/common/processors/processors.cpp +++ b/common/processors/processors.cpp @@ -34,6 +34,7 @@ #include "mix_monitoring/MixMonitorProcessor.cpp" #include "mix_monitoring/TrackMonitorProcessor.cpp" #include "mix_monitoring/loudness_standards/MeasureEBU128.cpp" +#include "mix_monitoring/loudness_standards/TruePeak.cpp" #include "panner/Panner3DProcessor.cpp" #include "remapping/RemappingProcessor.cpp" #include "render/RenderProcessor.cpp" diff --git a/third_party/LUFSMeter/src/EBU128LoudnessMeter.cpp b/third_party/LUFSMeter/src/EBU128LoudnessMeter.cpp index 164d8aeb..f906cea1 100644 --- a/third_party/LUFSMeter/src/EBU128LoudnessMeter.cpp +++ b/third_party/LUFSMeter/src/EBU128LoudnessMeter.cpp @@ -89,9 +89,6 @@ Ebu128LoudnessMeter::Ebu128LoudnessMeter() loudnessRangeEnd(minimalReturnValue), freezeLoudnessRangeOnSilence(false), currentBlockIsSilent(false) { - DBG("The longest possible measurement until a buffer overflow = " + - juce::String(INT_MAX / 10. / 3600. / 365.) + " years"); - // If this class is used without caution and processBlock // is called before prepareToPlay, divisions by zero // might occure. E.g. if numberOfSamplesInAllBins = 0. @@ -143,8 +140,6 @@ void Ebu128LoudnessMeter::prepareToPlay(double sampleRate, } } - DBG("expectedRequestRate = " + juce::String(expectedRequestRate)); - // Figure out how many bins are needed. const int timeOfAccumulationForShortTerm = 3; // seconds. @@ -154,9 +149,7 @@ void Ebu128LoudnessMeter::prepareToPlay(double sampleRate, numberOfSamplesInAllBins = numberOfBins * numberOfSamplesPerBin; numberOfBinsToCover100ms = int(0.1 * expectedRequestRate); - DBG("numberOfBinsToCover100ms = " + juce::String(numberOfBinsToCover100ms)); numberOfBinsToCover400ms = int(0.4 * expectedRequestRate); - DBG("numberOfBinsToCover400ms = " + juce::String(numberOfBinsToCover400ms)); numberOfSamplesIn400ms = numberOfBinsToCover400ms * numberOfSamplesPerBin; currentBin = 0; @@ -205,7 +198,6 @@ void Ebu128LoudnessMeter::processBlock(const juce::AudioSampleBuffer& buffer) { const float magnitude = buffer.getMagnitude(0, buffer.getNumSamples()); if (magnitude < silenceThreshold) { currentBlockIsSilent = true; - DBG("Silence detected."); } else currentBlockIsSilent = false; }