LUFS Energy Calculation
In my previous blog post about LUFS, we delved into some details of the LUFS algorithm. One of the key elements of the algorithm is the calculation of the sum of all amplitudes within the audio waveform. For the purpose of this discussion, we’ll refer to this sum as the energy of the waveform.
When measuring LUFS, we typically focus on a continuous stream of measurements. This approach allows us to observe the loudness fluctuations over time, providing insights into the audio’s dynamic range. With the APU Loudness Compressor, these measurements are continuously channeled into the compressor’s input signal.
In the example waveform below, the highlighted rectangles represent the current window of samples. For LUFS momentary this window duration corresponds to 400ms, for short-term it’s 3.0s. Each time this window advances by even a single sample, the entire window must be summed again to calculate the window energy.
Notice how many samples overlap from one block to the next. This is where the concept of a running sum can help us. With this simple algorithm, we can dramatically reduce the amount of CPU required for each block.
Running Sum
Instead of recalculating the entire window every time, we can simply maintain a running sum. Whenever a new sample comes in, simply subtract the oldest sample and add the new sample. This will save us a tremendous amount of CPU time because we’ve reduced the number of arithmetic operations per block from N to just 2.
Let’s take another look at the example waveform from above. This time, we’ll use a running sum to skip all the redundant calculation. In the example below, only the filled rectangles need to be recalculated. The dotted rectangles are simply the previous sum, which can be reused.
Note that the relatively small number of samples in this example is for illustration purposes only. In practice, the window size is typically much larger (at 44khz sample rate, 400ms is 17640 samples per block, per channel!). This means that the running sum optimization can save a tremendous amount of CPU time.
That’s pretty much all there is to it. The C++ class below implements this simple algorithm with the help of a circular buffer from the Boost C++ library. The circular buffer is used to store the last N samples, where N is the window size. The buffer is initialized to all zeros, which is equivalent to the initial sum being zero. The buffer is then updated with each new sample, and the current sum is updated accordingly.
class RunningSum
{
public:
RunningSum(unsigned long interval, size_t numChannels);
//! add sample to the running sum for the specified channel
void addSample(double sample, size_t channel);
//! retrieve current running sum for the specified channel
double getRunningSum(size_t channel) const;
private:
//! single channel's running sum
struct Channel
{
Channel(unsigned long interval);
boost::circular_buffer<double> buffer;
double currentSum = 0.0;
};
std::vector<Channel> m_channels; //!< one running sum per channel
};
RunningSum::Channel::Channel(unsigned long interval) : buffer(interval)
{
jassert(interval > 0);
// initialize buffer to all zeros
for (unsigned long i = 0; i < interval; ++i)
buffer.push_back(0.0);
}
RunningSum::RunningSum(unsigned long interval, size_t numChannels)
{
// create each channel using the specified interval
for (size_t i = 0; i < numChannels; ++i)
m_channels.emplace_back(interval);
}
void RunningSum::addSample(double sample, size_t channel)
{
jassert(channel < m_channels.size());
Channel& channelData = m_channels[channel];
const double oldSample = channelData.buffer.front();
channelData.currentSum -= oldSample;
channelData.currentSum += sample;
channelData.buffer.push_back(sample);
}
double RunningSum::getRunningSum(size_t channel) const
{
jassert(channel < m_channels.size());
const Channel& channelData = m_channels[channel];
return channelData.currentSum;
}