DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
LoudnessMeter.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
28#include "../Core/DspMath.h"
29#include "../Core/AudioSpec.h"
30#include "../Core/AudioBuffer.h"
31#include "../Core/TruePeakDetector.h"
32
33#include <algorithm>
34#include <atomic>
35#include <cmath>
36#include <cstdint>
37#include <numbers>
38#include <array>
39
40namespace dspark {
41
55template <FloatType T>
57{
58public:
70 void prepare(double sampleRate, int numChannels = 2) noexcept
71 {
72 (void)numChannels;
73 if (!std::isfinite(sampleRate) || sampleRate <= 0.0) return;
74
75 sampleRate_ = sampleRate;
76
77 computeKWeighting(sampleRate);
78
79 // 100 ms block length (clamped in double before the cast: an absurd
80 // finite rate must not overflow the int conversion)
81 blockSamples_ = static_cast<int>(std::clamp(sampleRate * 0.1, 1.0, 1.0e9));
82
83 reset();
84 prepared_.store(true, std::memory_order_relaxed);
85 }
86
88 void prepare(const AudioSpec& spec) noexcept
89 {
90 prepare(spec.sampleRate, spec.numChannels);
91 }
92
102 {
103 const int nCh = buffer.getNumChannels();
104 const int nS = buffer.getNumSamples();
105 if (nCh >= 2)
106 process(buffer.getChannel(0), buffer.getChannel(1), nS);
107 else if (nCh == 1)
108 process(buffer.getChannel(0), nS);
109 }
110
116 void process(const T* data, int numSamples) noexcept
117 {
118 if (!prepared_.load(std::memory_order_relaxed) || data == nullptr || numSamples <= 0)
119 return;
120
121 T tpMax = truePeakMax_.load(std::memory_order_relaxed);
122 for (int i = 0; i < numSamples; ++i)
123 {
124 // Only fold FINITE estimates into the max-hold: a single Inf/NaN
125 // sample would otherwise pin the peak at +Inf forever (std::max
126 // with +Inf latches, and the raw |NaN| leaks through the ring),
127 // leaving getTruePeakDb() stuck at +Inf dBTP until reset() -- the
128 // K-filter / histogram path already self-recovers, so the true
129 // peak must too (same non-finite robustness contract).
130 const T tp0 = truePeak_.processSample(data[i], 0);
131 if (std::isfinite(tp0)) tpMax = std::max(tpMax, tp0);
132
133 double filtered = applyKWeighting(static_cast<double>(data[i]), 0);
134 currentBlockPower_ += filtered * filtered;
135
136 if (++currentBlockSamples_ >= blockSamples_)
137 commitBlock();
138 }
139 truePeakMax_.store(tpMax, std::memory_order_relaxed);
140 }
141
148 void process(const T* left, const T* right, int numSamples) noexcept
149 {
150 if (!prepared_.load(std::memory_order_relaxed)
151 || left == nullptr || right == nullptr || numSamples <= 0)
152 return;
153
154 T tpMax = truePeakMax_.load(std::memory_order_relaxed);
155 for (int i = 0; i < numSamples; ++i)
156 {
157 // Fold only FINITE true-peak estimates (see the mono path): a lone
158 // Inf/NaN sample must not latch the max-hold at +Inf forever.
159 const T tpL = truePeak_.processSample(left[i], 0);
160 const T tpR = truePeak_.processSample(right[i], 1);
161 if (std::isfinite(tpL)) tpMax = std::max(tpMax, tpL);
162 if (std::isfinite(tpR)) tpMax = std::max(tpMax, tpR);
163
164 double filtL = applyKWeighting(static_cast<double>(left[i]), 0);
165 double filtR = applyKWeighting(static_cast<double>(right[i]), 1);
166
167 currentBlockPower_ += (filtL * filtL + filtR * filtR);
168
169 if (++currentBlockSamples_ >= blockSamples_)
170 commitBlock();
171 }
172 truePeakMax_.store(tpMax, std::memory_order_relaxed);
173 }
174
179 [[nodiscard]] T getMomentaryLUFS() const noexcept
180 {
181 return calculateLUFSFromBlocks(4);
182 }
183
188 [[nodiscard]] T getShortTermLUFS() const noexcept
189 {
190 return calculateLUFSFromBlocks(30);
191 }
192
199 [[nodiscard]] T getIntegratedLUFS() const noexcept
200 {
201 // Pass 1: Absolute Gate (-70 LUFS)
202 double sumPowerUngated = 0.0;
203 uint32_t countUngated = 0;
204
205 for (int i = 0; i < kNumBins; ++i)
206 {
207 uint32_t binCount = histogram_[i].load(std::memory_order_relaxed);
208 if (binCount > 0)
209 {
210 double binPower = lufsToPower(kMinHistogramLUFS + static_cast<double>(i) * kBinWidth);
211 sumPowerUngated += binPower * binCount;
212 countUngated += binCount;
213 }
214 }
215
216 if (countUngated == 0) return T(-100);
217
218 double meanPowerUngated = sumPowerUngated / countUngated;
219 double ungatedLUFS = powerToLUFS(meanPowerUngated);
220
221 // Pass 2: Relative Gate (-10 LU below ungated mean)
222 double relativeGateLUFS = ungatedLUFS - 10.0;
223 int relativeGateBin = static_cast<int>(std::floor((relativeGateLUFS - kMinHistogramLUFS) / kBinWidth));
224 relativeGateBin = std::clamp(relativeGateBin, 0, kNumBins - 1);
225
226 double sumPowerGated = 0.0;
227 uint32_t countGated = 0;
228
229 for (int i = relativeGateBin; i < kNumBins; ++i)
230 {
231 uint32_t binCount = histogram_[i].load(std::memory_order_relaxed);
232 if (binCount > 0)
233 {
234 double binPower = lufsToPower(kMinHistogramLUFS + static_cast<double>(i) * kBinWidth);
235 sumPowerGated += binPower * binCount;
236 countGated += binCount;
237 }
238 }
239
240 if (countGated == 0) return T(-100);
241
242 return static_cast<T>(powerToLUFS(sumPowerGated / countGated));
243 }
244
249 [[nodiscard]] T getTruePeakDb() const noexcept
250 {
251 return gainToDecibels(truePeakMax_.load(std::memory_order_relaxed));
252 }
253
263 [[nodiscard]] T getLoudnessRange() const noexcept
264 {
265 // Pass 1: relative gate from the mean of absolute-gated ST values.
266 double sumPower = 0.0;
267 uint32_t count = 0;
268 for (int i = 0; i < kNumBins; ++i)
269 {
270 const uint32_t c = lraHistogram_[i].load(std::memory_order_relaxed);
271 if (c > 0)
272 {
273 sumPower += lufsToPower(kMinHistogramLUFS + i * kBinWidth) * c;
274 count += c;
275 }
276 }
277 if (count == 0) return T(0);
278
279 const double relGateLUFS = powerToLUFS(sumPower / count) - 20.0;
280 int gateBin = static_cast<int>(std::floor((relGateLUFS - kMinHistogramLUFS) / kBinWidth));
281 gateBin = std::clamp(gateBin, 0, kNumBins - 1);
282
283 // Pass 2: 10th / 95th percentiles above the relative gate.
284 uint32_t gatedCount = 0;
285 for (int i = gateBin; i < kNumBins; ++i)
286 gatedCount += lraHistogram_[i].load(std::memory_order_relaxed);
287 if (gatedCount == 0) return T(0);
288
289 const auto target10 = static_cast<uint32_t>(0.10 * gatedCount);
290 const auto target95 = static_cast<uint32_t>(0.95 * gatedCount);
291
292 double p10 = kMinHistogramLUFS, p95 = kMaxHistogramLUFS;
293 uint32_t running = 0;
294 bool have10 = false;
295 for (int i = gateBin; i < kNumBins; ++i)
296 {
297 running += lraHistogram_[i].load(std::memory_order_relaxed);
298 if (!have10 && running >= target10)
299 {
300 p10 = kMinHistogramLUFS + i * kBinWidth;
301 have10 = true;
302 }
303 if (running >= target95)
304 {
305 p95 = kMinHistogramLUFS + i * kBinWidth;
306 break;
307 }
308 }
309 return static_cast<T>(std::max(0.0, p95 - p10));
310 }
311
319 void reset() noexcept
320 {
321 for (int ch = 0; ch < kMaxChannels; ++ch)
322 {
323 preState_[ch] = {};
324 rlbState_[ch] = {};
325 }
326
327 for (auto& power : blockPowers_)
328 power.store(0.0, std::memory_order_relaxed);
329
330 for (auto& bin : histogram_)
331 bin.store(0, std::memory_order_relaxed);
332 for (auto& bin : lraHistogram_)
333 bin.store(0, std::memory_order_relaxed);
334
335 truePeak_.reset();
336 truePeakMax_.store(T(0), std::memory_order_relaxed);
337
338 blockWritePos_.store(0, std::memory_order_relaxed);
339 totalCommittedBlocks_ = 0;
340 currentBlockPower_ = 0.0;
341 currentBlockSamples_ = 0;
342 }
343
344private:
345 static constexpr int kMaxChannels = 2; // Expandable to 8 with proper spatial weighting
346
347 // Histogram specs: -70 LUFS to +30 LUFS with 0.1 resolution = 1000 bins
348 static constexpr double kMinHistogramLUFS = -70.0;
349 static constexpr double kMaxHistogramLUFS = 30.0;
350 static constexpr double kBinWidth = 0.1;
351 static constexpr int kNumBins = 1000;
352
353 // Filters state (Forced to double to prevent DF2T precision issues)
354 struct BiquadState { double z1 = 0.0, z2 = 0.0; };
355 struct BiquadCoeff { double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0; };
356
357 void computeKWeighting(double sr)
358 {
359 // Pre-warped analog parameterization that reproduces the official
360 // ITU-R BS.1770-5 table 1/2 coefficients at 48 kHz to machine
361 // precision (verified: max error 8.9e-16) and generalizes to any
362 // sample rate with the same frequency response, as the spec requires.
363 // The -0.691 constant in powerToLUFS assumes this exact cascade gain
364 // (+0.691 dB at 997 Hz); an RBJ shelf or a gain-normalized RLB
365 // high-pass reads ~0.26 LU low on the EBU conformance vectors.
366 {
367 const double G = 3.999843853973347; // dB, stage 1 shelf
368 const double Q = 0.7071752369554196;
369 const double fc = 1681.9744509555319; // Hz
370 const double K = std::tan(std::numbers::pi * fc / sr);
371 const double Vh = std::pow(10.0, G / 20.0);
372 const double Vb = std::pow(Vh, 0.4996667741545416);
373 const double a0 = 1.0 + K / Q + K * K;
374 pre_.b0 = (Vh + Vb * K / Q + K * K) / a0;
375 pre_.b1 = 2.0 * (K * K - Vh) / a0;
376 pre_.b2 = (Vh - Vb * K / Q + K * K) / a0;
377 pre_.a1 = 2.0 * (K * K - 1.0) / a0;
378 pre_.a2 = (1.0 - K / Q + K * K) / a0;
379 }
380 {
381 const double Q = 0.5003270373238773; // stage 2 RLB high-pass
382 const double fc = 38.13547087602444; // Hz
383 const double K = std::tan(std::numbers::pi * fc / sr);
384 const double a0 = 1.0 + K / Q + K * K;
385 // The official table 2 numerator is exactly [1, -2, 1] - NOT
386 // normalized to unity passband gain (it passes ~+0.04 dB).
387 rlb_.b0 = 1.0;
388 rlb_.b1 = -2.0;
389 rlb_.b2 = 1.0;
390 rlb_.a1 = 2.0 * (K * K - 1.0) / a0;
391 rlb_.a2 = (1.0 - K / Q + K * K) / a0;
392 }
393 }
394
395 double applyBiquad(double input, const BiquadCoeff& c, BiquadState& s) noexcept
396 {
397 // Adding 1e-18 protects against denormal numbers (Flush-To-Zero fallback)
398 double output = c.b0 * input + s.z1;
399 s.z1 = c.b1 * input - c.a1 * output + s.z2;
400 s.z2 = c.b2 * input - c.a2 * output + 1e-18;
401 return output;
402 }
403
404 double applyKWeighting(double input, int channel) noexcept
405 {
406 double x = applyBiquad(input, pre_, preState_[channel]);
407 return applyBiquad(x, rlb_, rlbState_[channel]);
408 }
409
410 void commitBlock() noexcept
411 {
412 const double meanPower = currentBlockPower_ / currentBlockSamples_;
413 currentBlockPower_ = 0.0;
414 currentBlockSamples_ = 0;
415
416 // A non-finite block (NaN/Inf fed by the caller) would poison the
417 // sliding window and freeze the histograms forever: the K-filter
418 // recursion never drains a NaN. A meter must report the signal as it
419 // is NOW, so drop the block, clear the filter states and resume
420 // measuring clean on the next one.
421 if (!std::isfinite(meanPower))
422 {
423 for (int ch = 0; ch < kMaxChannels; ++ch)
424 {
425 preState_[ch] = {};
426 rlbState_[ch] = {};
427 }
428 return;
429 }
430
431 // Update sliding window ring buffer
432 int currentPos = blockWritePos_.load(std::memory_order_relaxed);
433 blockPowers_[currentPos].store(meanPower, std::memory_order_relaxed);
434
435 int nextPos = (currentPos + 1) % 30; // 30 blocks = 3s max window
436 blockWritePos_.store(nextPos, std::memory_order_release);
437 ++totalCommittedBlocks_;
438
439 // BS.1770-4 integrated gating uses 400 ms blocks with 75% overlap.
440 // With a 100 ms sub-block hop, that is EXACTLY the mean of the last
441 // four sub-blocks committed at every 100 ms step. (Gating raw 100 ms
442 // blocks has higher variance and fails the EBU conformance vectors.)
443 if (totalCommittedBlocks_ >= 4)
444 {
445 double gating400 = meanPower;
446 for (int back = 1; back < 4; ++back)
447 {
448 int idx = (currentPos - back + 30) % 30;
449 gating400 += blockPowers_[idx].load(std::memory_order_relaxed);
450 }
451 gating400 *= 0.25;
452
453 const double lufs = powerToLUFS(gating400);
454 if (lufs >= kMinHistogramLUFS)
455 {
456 int binIndex = static_cast<int>(std::round((lufs - kMinHistogramLUFS) / kBinWidth));
457 binIndex = std::clamp(binIndex, 0, kNumBins - 1);
458 histogram_[binIndex].fetch_add(1, std::memory_order_relaxed);
459 }
460 }
461
462 // EBU Tech 3342 loudness range: short-term (3 s) values sampled every
463 // 1 s (10 sub-blocks), absolute-gated at -70 LUFS into a histogram.
464 if (totalCommittedBlocks_ >= 30 && (totalCommittedBlocks_ % 10) == 0)
465 {
466 const double stLufs = static_cast<double>(calculateLUFSFromBlocks(30));
467 if (stLufs >= kMinHistogramLUFS)
468 {
469 int binIndex = static_cast<int>(std::round((stLufs - kMinHistogramLUFS) / kBinWidth));
470 binIndex = std::clamp(binIndex, 0, kNumBins - 1);
471 lraHistogram_[binIndex].fetch_add(1, std::memory_order_relaxed);
472 }
473 }
474 }
475
476 T calculateLUFSFromBlocks(int numBlocks) const noexcept
477 {
478 double sum = 0.0;
479 int currentPos = blockWritePos_.load(std::memory_order_acquire);
480
481 for (int i = 0; i < numBlocks; ++i)
482 {
483 int idx = (currentPos - 1 - i + 30) % 30;
484 sum += blockPowers_[idx].load(std::memory_order_relaxed);
485 }
486
487 return static_cast<T>(powerToLUFS(sum / numBlocks));
488 }
489
490 [[nodiscard]] static double powerToLUFS(double meanPower) noexcept
491 {
492 if (meanPower <= 1e-10) return -100.0; // Prevent log10(0)
493 return -0.691 + 10.0 * std::log10(meanPower);
494 }
495
496 [[nodiscard]] static double lufsToPower(double lufs) noexcept
497 {
498 return std::pow(10.0, (lufs + 0.691) / 10.0);
499 }
500
501 double sampleRate_ = 48000.0;
502 std::atomic<bool> prepared_{ false };
503
504 BiquadCoeff pre_, rlb_;
505 BiquadState preState_[kMaxChannels], rlbState_[kMaxChannels];
506
507 int blockSamples_ = 4800;
508 double currentBlockPower_ = 0.0;
509 int currentBlockSamples_ = 0;
510
511 // Concurrency-safe components
512 std::array<std::atomic<double>, 30> blockPowers_;
513 std::atomic<int> blockWritePos_{0};
514 int64_t totalCommittedBlocks_ = 0;
515
516 // O(1) Histogram for Integrated Loudness
517 std::array<std::atomic<uint32_t>, kNumBins> histogram_;
518
519 // EBU Tech 3342 loudness-range histogram (short-term values, 1 s hop)
520 std::array<std::atomic<uint32_t>, kNumBins> lraHistogram_;
521
522 // ITU-R BS.1770-4 true-peak (dBTP) tracking
523 TruePeakDetector<T, kMaxChannels> truePeak_;
524 std::atomic<T> truePeakMax_ { T(0) };
525};
526
527} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Real-time safe EBU R128 loudness meter.
void prepare(const AudioSpec &spec) noexcept
Unified API preparation.
T getIntegratedLUFS() const noexcept
Computes the Integrated loudness using standard two-pass gating.
T getLoudnessRange() const noexcept
Computes the EBU R128 Loudness Range (EBU Tech 3342).
void process(const T *data, int numSamples) noexcept
Processes a mono block of samples.
void reset() noexcept
Clears all measurements and resets filters.
void process(const T *left, const T *right, int numSamples) noexcept
Processes a stereo block of samples.
T getMomentaryLUFS() const noexcept
Reads the Momentary loudness (400 ms window).
void prepare(double sampleRate, int numChannels=2) noexcept
Prepares the meter and pre-calculates filter coefficients.
T getTruePeakDb() const noexcept
Returns the maximum true peak observed since the last reset.
T getShortTermLUFS() const noexcept
Reads the Short-term loudness (3 second window).
void processBlock(AudioBufferView< const T > buffer) noexcept
Processes a non-interleaved buffer view (read-only).
T processSample(T sample, int channel) noexcept
Feeds one sample and returns the local true-peak estimate.
void reset() noexcept
Clears all channel histories. Safe on the audio thread.
Main namespace for the DSPark framework.
T gainToDecibels(T gain, T minusInfinityDb=T(-100)) noexcept
Converts a linear gain value to decibels.
Definition DspMath.h:78
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35