DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
LoudnessMeter.h
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/Biquad.h"
32#include "../Core/TruePeakDetector.h"
33
34#include <algorithm>
35#include <atomic>
36#include <cmath>
37#include <cstdint>
38#include <limits>
39#include <numbers>
40#include <array>
41
42namespace dspark {
43
58template <FloatType T>
60{
61public:
69 {
70 std::int64_t committedFrames = 0;
72 int hopFrames = 0;
73 std::uint64_t integratedBlocks = 0;
74 std::uint64_t rangeBlocks = 0;
75 };
76
83 [[nodiscard]] MeasurementInfo getMeasurementInfo() const noexcept
84 {
85 MeasurementInfo info;
86 if (!prepared_.load(std::memory_order_relaxed)) return info;
87 info.committedFrames = totalCommittedBlocks_ * blockSamples_;
88 info.pendingFrames = currentBlockSamples_;
89 info.hopFrames = blockSamples_;
90 for (int i = 0; i < kNumBins; ++i)
91 {
92 info.integratedBlocks += histogram_[i].load(std::memory_order_relaxed);
93 info.rangeBlocks += lraHistogram_[i].load(std::memory_order_relaxed);
94 }
95 return info;
96 }
97
109 void prepare(double sampleRate, int numChannels = 2) noexcept
110 {
111 (void)numChannels;
112 if (!std::isfinite(sampleRate) || sampleRate <= 0.0) return;
113
114 sampleRate_ = sampleRate;
115
116 computeKWeighting(sampleRate);
117
118 // 100 ms block length (clamped in double before the cast: an absurd
119 // finite rate must not overflow the int conversion)
120 blockSamples_ = static_cast<int>(std::clamp(sampleRate * 0.1, 1.0, 1.0e9));
121
122 reset();
123 prepared_.store(true, std::memory_order_relaxed);
124 }
125
127 void prepare(const AudioSpec& spec) noexcept
128 {
129 prepare(spec.sampleRate, spec.numChannels);
130 }
131
141 {
142 const int nCh = buffer.getNumChannels();
143 const int nS = buffer.getNumSamples();
144 if (nCh >= 2)
145 process(buffer.getChannel(0), buffer.getChannel(1), nS);
146 else if (nCh == 1)
147 process(buffer.getChannel(0), nS);
148 }
149
155 void process(const T* data, int numSamples) noexcept
156 {
157 if (!prepared_.load(std::memory_order_relaxed) || data == nullptr || numSamples <= 0)
158 return;
159
160 T tpMax = truePeakMax_.load(std::memory_order_relaxed);
161 for (int i = 0; i < numSamples; ++i)
162 {
163 // Only fold FINITE estimates into the max-hold: a single Inf/NaN
164 // sample would otherwise pin the peak at +Inf forever (std::max
165 // with +Inf latches, and the raw |NaN| leaks through the ring),
166 // leaving getTruePeakDb() stuck at +Inf dBTP until reset() -- the
167 // K-filter / histogram path already self-recovers, so the true
168 // peak must too (same non-finite robustness contract).
169 const T tp0 = truePeak_.processSample(data[i], 0);
170 if (std::isfinite(tp0))
171 tpMax = std::max(tpMax, tp0);
172 else
173 invalidateMeasurement();
174
175 double filtered = applyKWeighting(static_cast<double>(data[i]), 0);
176 currentBlockPower_ += filtered * filtered;
177 if (!std::isfinite(currentBlockPower_))
178 invalidateMeasurement();
179
180 if (++currentBlockSamples_ >= blockSamples_)
181 commitBlock();
182 }
183 truePeakMax_.store(tpMax, std::memory_order_relaxed);
184 }
185
192 void process(const T* left, const T* right, int numSamples) noexcept
193 {
194 if (!prepared_.load(std::memory_order_relaxed)
195 || left == nullptr || right == nullptr || numSamples <= 0)
196 return;
197
198 T tpMax = truePeakMax_.load(std::memory_order_relaxed);
199 for (int i = 0; i < numSamples; ++i)
200 {
201 // Fold only FINITE true-peak estimates (see the mono path): a lone
202 // Inf/NaN sample must not latch the max-hold at +Inf forever.
203 const T tpL = truePeak_.processSample(left[i], 0);
204 const T tpR = truePeak_.processSample(right[i], 1);
205 if (std::isfinite(tpL))
206 tpMax = std::max(tpMax, tpL);
207 else
208 invalidateMeasurement();
209 if (std::isfinite(tpR))
210 tpMax = std::max(tpMax, tpR);
211 else
212 invalidateMeasurement();
213
214 double filtL = applyKWeighting(static_cast<double>(left[i]), 0);
215 double filtR = applyKWeighting(static_cast<double>(right[i]), 1);
216
217 currentBlockPower_ += (filtL * filtL + filtR * filtR);
218 if (!std::isfinite(currentBlockPower_))
219 invalidateMeasurement();
220
221 if (++currentBlockSamples_ >= blockSamples_)
222 commitBlock();
223 }
224 truePeakMax_.store(tpMax, std::memory_order_relaxed);
225 }
226
231 [[nodiscard]] T getMomentaryLUFS() const noexcept
232 {
233 return calculateLUFSFromBlocks(4);
234 }
235
240 [[nodiscard]] T getShortTermLUFS() const noexcept
241 {
242 return calculateLUFSFromBlocks(30);
243 }
244
251 [[nodiscard]] T getIntegratedLUFS() const noexcept
252 {
253 // Pass 1: Absolute Gate (-70 LUFS)
254 double sumPowerUngated = 0.0;
255 uint64_t countUngated = 0;
256
257 for (int i = 0; i < kNumBins; ++i)
258 {
259 uint32_t binCount = histogram_[i].load(std::memory_order_relaxed);
260 if (binCount > 0)
261 {
262 double binPower = lufsToPower(kMinHistogramLUFS + static_cast<double>(i) * kBinWidth);
263 sumPowerUngated += binPower * binCount;
264 countUngated += binCount;
265 }
266 }
267
268 if (countUngated == 0) return T(-100);
269
270 double meanPowerUngated = sumPowerUngated / countUngated;
271 double ungatedLUFS = powerToLUFS(meanPowerUngated);
272
273 // Pass 2: Relative Gate (-10 LU below ungated mean)
274 double relativeGateLUFS = ungatedLUFS - 10.0;
275 int relativeGateBin = static_cast<int>(std::floor((relativeGateLUFS - kMinHistogramLUFS) / kBinWidth));
276 relativeGateBin = std::clamp(relativeGateBin, 0, kNumBins - 1);
277
278 double sumPowerGated = 0.0;
279 uint64_t countGated = 0;
280
281 for (int i = relativeGateBin; i < kNumBins; ++i)
282 {
283 uint32_t binCount = histogram_[i].load(std::memory_order_relaxed);
284 if (binCount > 0)
285 {
286 double binPower = lufsToPower(kMinHistogramLUFS + static_cast<double>(i) * kBinWidth);
287 sumPowerGated += binPower * binCount;
288 countGated += binCount;
289 }
290 }
291
292 if (countGated == 0) return T(-100);
293
294 return static_cast<T>(powerToLUFS(sumPowerGated / countGated));
295 }
296
306 [[nodiscard]] bool isMeasurementValid() const noexcept
307 {
308 return measurementValid_.load(std::memory_order_relaxed);
309 }
310
315 [[nodiscard]] T getTruePeakDb() const noexcept
316 {
317 return gainToDecibels(truePeakMax_.load(std::memory_order_relaxed));
318 }
319
329 void finalizeTruePeak() noexcept
330 {
331 if (!prepared_.load(std::memory_order_relaxed)) return;
332 T peak = truePeakMax_.load(std::memory_order_relaxed);
333 for (int channel = 0; channel < kMaxChannels; ++channel)
334 {
335 const T tail = truePeak_.getTailPeak(channel);
336 if (std::isfinite(tail))
337 peak = std::max(peak, tail);
338 else
339 invalidateMeasurement();
340 }
341 truePeakMax_.store(peak, std::memory_order_relaxed);
342 }
343
360 [[nodiscard]] T getLoudnessRange() const noexcept
361 {
362 // Pass 1: relative gate from the mean of absolute-gated ST values.
363 double sumPower = 0.0;
364 uint64_t count = 0;
365 for (int i = 0; i < kNumBins; ++i)
366 {
367 const uint32_t c = lraHistogram_[i].load(std::memory_order_relaxed);
368 if (c > 0)
369 {
370 sumPower += lufsToPower(kMinHistogramLUFS + i * kBinWidth) * c;
371 count += c;
372 }
373 }
374 if (count == 0) return T(0);
375
376 const double relGateLUFS = powerToLUFS(sumPower / count) - 20.0;
377 int gateBin = static_cast<int>(std::floor((relGateLUFS - kMinHistogramLUFS) / kBinWidth));
378 gateBin = std::clamp(gateBin, 0, kNumBins - 1);
379
380 // Pass 2: 10th / 95th percentiles above the relative gate.
381 uint64_t gatedCount = 0;
382 for (int i = gateBin; i < kNumBins; ++i)
383 gatedCount += lraHistogram_[i].load(std::memory_order_relaxed);
384 if (gatedCount == 0) return T(0);
385
386 // Percentiles by the Tech 3342 reference rule: the value of 0-based rank
387 // round((n - 1) * p) among the n gated values. Both ranks are always
388 // measured values - never the gate threshold - and every n is
389 // covered: with one value, or with n equal values, the range is 0.
390 const auto rank10 = static_cast<uint64_t>(std::llround(0.10 * static_cast<double>(gatedCount - 1)));
391 const auto rank95 = static_cast<uint64_t>(std::llround(0.95 * static_cast<double>(gatedCount - 1)));
392
393 double p10 = 0.0, p95 = 0.0;
394 uint64_t running = 0;
395 bool have10 = false;
396 for (int i = gateBin; i < kNumBins; ++i)
397 {
398 const uint32_t c = lraHistogram_[i].load(std::memory_order_relaxed);
399 if (c == 0) continue;
400 running += c;
401 // `running` values sit at or below this bin: ranks 0 .. running-1.
402 if (!have10 && running > rank10)
403 {
404 p10 = kMinHistogramLUFS + i * kBinWidth;
405 have10 = true;
406 }
407 if (running > rank95)
408 {
409 p95 = kMinHistogramLUFS + i * kBinWidth;
410 break;
411 }
412 }
413 return static_cast<T>(std::max(0.0, p95 - p10));
414 }
415
423 void reset() noexcept
424 {
425 for (int ch = 0; ch < kMaxChannels; ++ch)
426 {
427 preState_[ch] = {};
428 rlbState_[ch] = {};
429 }
430
431 for (auto& power : blockPowers_)
432 power.store(0.0, std::memory_order_relaxed);
433
434 for (auto& bin : histogram_)
435 bin.store(0, std::memory_order_relaxed);
436 for (auto& bin : lraHistogram_)
437 bin.store(0, std::memory_order_relaxed);
438
439 truePeak_.reset();
440 truePeakMax_.store(T(0), std::memory_order_relaxed);
441
442 blockWritePos_.store(0, std::memory_order_relaxed);
443 totalCommittedBlocks_ = 0;
444 currentBlockPower_ = 0.0;
445 currentBlockSamples_ = 0;
446 measurementValid_.store(true, std::memory_order_relaxed);
447 }
448
449private:
450 static constexpr int kMaxChannels = 2; // Expandable to 8 with proper spatial weighting
451
452 // Histogram specs: -70 LUFS to +30 LUFS with 0.1 resolution = 1000 bins
453 static constexpr double kMinHistogramLUFS = -70.0;
454 static constexpr double kMaxHistogramLUFS = 30.0;
455 static constexpr double kBinWidth = 0.1;
456 static constexpr int kNumBins = 1000;
457
458 // Filters state (Forced to double to prevent DF2T precision issues)
459 struct BiquadState { double z1 = 0.0, z2 = 0.0; };
460 struct BiquadCoeff { double b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0; };
461
462 void computeKWeighting(double sr)
463 {
464 // Pre-warped analog parameterization that reproduces the official
465 // ITU-R BS.1770-5 table 1/2 coefficients at 48 kHz to machine
466 // precision (verified: max error 8.9e-16). This is an exact 48 kHz
467 // parameterization, not a claim of an identical response at every
468 // rate: the retained 997 Hz cascade gains are +0.691014 dB at 48 kHz,
469 // +0.693776 dB at 44.1 kHz and +0.663943 dB at 192 kHz. Residual
470 // full-band deviation stays below 0.05 dB through 384 kHz and inside
471 // the standard tolerance. The -0.691 constant in powerToLUFS remains
472 // tied to this cascade; an RBJ shelf or a gain-normalized RLB high-pass
473 // reads ~0.26 LU low on the EBU conformance vectors. The design lives
474 // in BiquadCoeffs so every K-weighted measurement shares it.
475 const auto shelf = BiquadCoeffs::makeKWeightingShelf(sr);
476 pre_ = { shelf.b0, shelf.b1, shelf.b2, shelf.a1, shelf.a2 };
477 const auto rlb = BiquadCoeffs::makeKWeightingHighPass(sr);
478 rlb_ = { rlb.b0, rlb.b1, rlb.b2, rlb.a1, rlb.a2 };
479 }
480
481 double applyBiquad(double input, const BiquadCoeff& c, BiquadState& s) noexcept
482 {
483 // Adding 1e-18 protects against denormal numbers (Flush-To-Zero fallback)
484 double output = c.b0 * input + s.z1;
485 s.z1 = c.b1 * input - c.a1 * output + s.z2;
486 s.z2 = c.b2 * input - c.a2 * output + 1e-18;
487 return output;
488 }
489
490 double applyKWeighting(double input, int channel) noexcept
491 {
492 double x = applyBiquad(input, pre_, preState_[channel]);
493 return applyBiquad(x, rlb_, rlbState_[channel]);
494 }
495
496 void commitBlock() noexcept
497 {
498 const double meanPower = currentBlockPower_ / currentBlockSamples_;
499 currentBlockPower_ = 0.0;
500 currentBlockSamples_ = 0;
501
502 // A non-finite block (NaN/Inf fed by the caller) would poison the
503 // sliding window and freeze the histograms forever: the K-filter
504 // recursion never drains a NaN. A meter must report the signal as it
505 // is NOW, so drop the block, clear the filter states and resume
506 // measuring clean on the next one.
507 if (!std::isfinite(meanPower))
508 {
509 invalidateMeasurement();
510 for (int ch = 0; ch < kMaxChannels; ++ch)
511 {
512 preState_[ch] = {};
513 rlbState_[ch] = {};
514 }
515 return;
516 }
517
518 // Update sliding window ring buffer
519 int currentPos = blockWritePos_.load(std::memory_order_relaxed);
520 blockPowers_[currentPos].store(meanPower, std::memory_order_relaxed);
521
522 int nextPos = (currentPos + 1) % 30; // 30 blocks = 3s max window
523 blockWritePos_.store(nextPos, std::memory_order_release);
524 ++totalCommittedBlocks_;
525
526 // BS.1770-4 integrated gating uses 400 ms blocks with 75% overlap.
527 // With a 100 ms sub-block hop, that is EXACTLY the mean of the last
528 // four sub-blocks committed at every 100 ms step. (Gating raw 100 ms
529 // blocks has higher variance and fails the EBU conformance vectors.)
530 if (totalCommittedBlocks_ >= 4)
531 {
532 double gating400 = meanPower;
533 for (int back = 1; back < 4; ++back)
534 {
535 int idx = (currentPos - back + 30) % 30;
536 gating400 += blockPowers_[idx].load(std::memory_order_relaxed);
537 }
538 gating400 *= 0.25;
539
540 const double lufs = powerToLUFS(gating400);
541 if (!std::isfinite(gating400) || !std::isfinite(lufs))
542 {
543 invalidateMeasurement();
544 }
545 else if (lufs >= kMinHistogramLUFS)
546 {
547 int binIndex = static_cast<int>(std::round((lufs - kMinHistogramLUFS) / kBinWidth));
548 if (binIndex >= kNumBins)
549 invalidateMeasurement();
550 binIndex = std::clamp(binIndex, 0, kNumBins - 1);
551 incrementHistogram(histogram_[binIndex]);
552 }
553 }
554
555 // EBU Tech 3342 loudness range: short-term (3 s) values sampled at
556 // 10 Hz - every 100 ms sub-block, the 2.9 s minimum window overlap
557 // Tech 3342 has required since V3 (2016) - absolute-gated at -70 LUFS
558 // into a histogram.
559 if (totalCommittedBlocks_ >= 30)
560 {
561 const double stLufs = static_cast<double>(calculateLUFSFromBlocks(30));
562 if (!std::isfinite(stLufs))
563 {
564 invalidateMeasurement();
565 }
566 else if (stLufs >= kMinHistogramLUFS)
567 {
568 int binIndex = static_cast<int>(std::round((stLufs - kMinHistogramLUFS) / kBinWidth));
569 if (binIndex >= kNumBins)
570 invalidateMeasurement();
571 binIndex = std::clamp(binIndex, 0, kNumBins - 1);
572 incrementHistogram(lraHistogram_[binIndex]);
573 }
574 }
575 }
576
577 void invalidateMeasurement() noexcept
578 {
579 measurementValid_.store(false, std::memory_order_relaxed);
580 }
581
582 void incrementHistogram(std::atomic<uint32_t>& bin) noexcept
583 {
584 if (bin.load(std::memory_order_relaxed)
585 == std::numeric_limits<uint32_t>::max())
586 {
587 invalidateMeasurement();
588 return;
589 }
590 bin.fetch_add(1, std::memory_order_relaxed);
591 }
592
593 T calculateLUFSFromBlocks(int numBlocks) const noexcept
594 {
595 double sum = 0.0;
596 int currentPos = blockWritePos_.load(std::memory_order_acquire);
597
598 for (int i = 0; i < numBlocks; ++i)
599 {
600 int idx = (currentPos - 1 - i + 30) % 30;
601 sum += blockPowers_[idx].load(std::memory_order_relaxed);
602 }
603
604 return static_cast<T>(powerToLUFS(sum / numBlocks));
605 }
606
607 [[nodiscard]] static double powerToLUFS(double meanPower) noexcept
608 {
609 if (meanPower <= 1e-10) return -100.0; // Prevent log10(0)
610 return std::max(-100.0, -0.691 + 10.0 * std::log10(meanPower));
611 }
612
613 [[nodiscard]] static double lufsToPower(double lufs) noexcept
614 {
615 return std::pow(10.0, (lufs + 0.691) / 10.0);
616 }
617
618 double sampleRate_ = 48000.0;
619 std::atomic<bool> prepared_{ false };
620
621 BiquadCoeff pre_, rlb_;
622 BiquadState preState_[kMaxChannels], rlbState_[kMaxChannels];
623
624 int blockSamples_ = 4800;
625 double currentBlockPower_ = 0.0;
626 int currentBlockSamples_ = 0;
627
628 // Concurrency-safe components
629 std::array<std::atomic<double>, 30> blockPowers_;
630 std::atomic<int> blockWritePos_{0};
631 int64_t totalCommittedBlocks_ = 0;
632
633 // O(1) Histogram for Integrated Loudness
634 std::array<std::atomic<uint32_t>, kNumBins> histogram_;
635
636 // EBU Tech 3342 loudness-range histogram (short-term values, 100 ms hop)
637 std::array<std::atomic<uint32_t>, kNumBins> lraHistogram_;
638
639 // Sticky for one reset-to-reset measurement transaction.
640 std::atomic<bool> measurementValid_ { true };
641
642 // ITU-R BS.1770-4 true-peak (dBTP) tracking
643 TruePeakDetector<T, kMaxChannels> truePeak_;
644 std::atomic<T> truePeakMax_ { T(0) };
645};
646
647} // 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).
MeasurementInfo getMeasurementInfo() const noexcept
Returns window coverage without modifying any state. Call from the stream owner, or after processing ...
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.
void finalizeTruePeak() noexcept
Includes the final interpolation tail of a finite programme. Call after the last input block,...
T getShortTermLUFS() const noexcept
Reads the Short-term loudness (3 second window).
bool isMeasurementValid() const noexcept
Reports whether every measurement since reset was representable.
void processBlock(AudioBufferView< const T > buffer) noexcept
Processes a non-interleaved buffer view (read-only).
T getTailPeak(int channel) const noexcept
Measures the zero-extended interpolation tail of one channel. Evaluates getTaps()-1 zero frames on a ...
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:89
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
static BiquadCoeffs makeKWeightingHighPass(double sampleRate) noexcept
ITU-R BS.1770 K-weighting, stage 2: the RLB high-pass.
Definition Biquad.h:529
static BiquadCoeffs makeKWeightingShelf(double sampleRate) noexcept
ITU-R BS.1770 K-weighting, stage 1: the head-related high shelf.
Definition Biquad.h:504
Window/gate coverage for a stopped or stream-owner measurement. Durations use the existing floor(samp...