DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
LevelFollower.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
46#include "../Core/AudioBuffer.h"
47#include "../Core/AudioSpec.h"
48#include "../Core/DspMath.h"
49
50#include <algorithm>
51#include <array>
52#include <atomic>
53#include <cmath>
54#include <type_traits>
55
56namespace dspark {
57
73template <FloatType T, int MaxChannels = 16>
75{
76public:
86 void prepare(const AudioSpec& spec) noexcept
87 {
88 if (!spec.isValid())
89 return;
90
91 sampleRate_ = spec.sampleRate;
92 numChannels_ = std::min(spec.numChannels, MaxChannels);
93 updateCoefficients();
94 reset();
95 }
96
102 void setAttackMs(float ms) noexcept
103 {
104 if (!std::isfinite(ms))
105 return;
106 attackMs_.store(std::max(T(0.001), static_cast<T>(ms)), std::memory_order_relaxed);
107 updateCoefficients();
108 }
109
115 void setReleaseMs(float ms) noexcept
116 {
117 if (!std::isfinite(ms))
118 return;
119 releaseMs_.store(std::max(T(0.001), static_cast<T>(ms)), std::memory_order_relaxed);
120 updateCoefficients();
121 }
122
133 void setRmsWindowMs(float ms) noexcept
134 {
135 if (!std::isfinite(ms))
136 return;
137 rmsWindowMs_.store(std::max(T(0.001), static_cast<T>(ms)), std::memory_order_relaxed);
138 updateCoefficients();
139 }
140
142 [[nodiscard]] T getAttackMs() const noexcept
143 {
144 return attackMs_.load(std::memory_order_relaxed);
145 }
146
148 [[nodiscard]] T getReleaseMs() const noexcept
149 {
150 return releaseMs_.load(std::memory_order_relaxed);
151 }
152
154 [[nodiscard]] T getRmsWindowMs() const noexcept
155 {
156 return rmsWindowMs_.load(std::memory_order_relaxed);
157 }
158
160 void reset() noexcept
161 {
162 for (auto& s : state_)
163 {
164 s.peak.store(T(0), std::memory_order_relaxed);
165 s.rmsAccum.store(T(0), std::memory_order_relaxed);
166 }
167 }
168
177 void process(AudioBufferView<const T> buffer) noexcept
178 {
179 const int nCh = std::min(buffer.getNumChannels(), numChannels_);
180 const int nS = buffer.getNumSamples();
181
182 // Constant offset that keeps the decay recursions out of the
183 // subnormal range (CPU spikes). Its equilibrium floor is
184 // kAntiDenormal / (1 - coeff), far below the -100 dB readout floor.
185 static constexpr T kAntiDenormal = std::is_same_v<T, float> ? T(1e-15) : T(1e-30);
186
187 // One relaxed load per block; the per-sample loop runs on locals.
188 const T attackCoeff = attackCoeff_.load(std::memory_order_relaxed);
189 const T releaseCoeff = releaseCoeff_.load(std::memory_order_relaxed);
190 const T rmsCoeff = rmsCoeff_.load(std::memory_order_relaxed);
191
192 for (int ch = 0; ch < nCh; ++ch)
193 {
194 const T* data = buffer.getChannel(ch);
195 auto& s = state_[ch];
196
197 // Local copies to avoid atomic operations per sample
198 T localPeak = s.peak.load(std::memory_order_relaxed);
199 T localRms = s.rmsAccum.load(std::memory_order_relaxed);
200
201 for (int i = 0; i < nS; ++i)
202 {
203 const T absSample = std::abs(data[i]);
204
205 // Branchless attack/release selector (avoids per-sample
206 // branch mispredictions; the recursion itself is serial)
207 const T isAttack = static_cast<T>(absSample > localPeak);
208 const T peakCoeff = isAttack * attackCoeff + (T(1) - isAttack) * releaseCoeff;
209
210 localPeak = absSample + peakCoeff * (localPeak - absSample) + kAntiDenormal;
211
212 // Symmetric one-pole lowpass over x^2 (exponential RMS)
213 const T squared = data[i] * data[i];
214 localRms = squared + rmsCoeff * (localRms - squared) + kAntiDenormal;
215 }
216
217 // A non-finite input would otherwise stick in the recursions
218 // forever (the one-pole never drains a NaN); a meter must report
219 // the signal as it is now, so sanitize at publish time and start
220 // clean on the next block.
221 if (!std::isfinite(localPeak)) localPeak = T(0);
222 if (!std::isfinite(localRms)) localRms = T(0);
223
224 // Publish final states for the UI thread once per block
225 s.peak.store(localPeak, std::memory_order_relaxed);
226 s.rmsAccum.store(localRms, std::memory_order_relaxed);
227 }
228 }
229
235 [[nodiscard]] T getPeakLevel(int channel) const noexcept
236 {
237 if (channel < 0 || channel >= MaxChannels) return T(0);
238 return state_[channel].peak.load(std::memory_order_relaxed);
239 }
240
246 [[nodiscard]] T getRmsLevel(int channel) const noexcept
247 {
248 if (channel < 0 || channel >= MaxChannels) return T(0);
249 const T squaredRms = state_[channel].rmsAccum.load(std::memory_order_relaxed);
250 return std::sqrt(std::max(squaredRms, T(0)));
251 }
252
258 [[nodiscard]] T getPeakLevelDb(int channel) const noexcept
259 {
260 return gainToDecibels(getPeakLevel(channel));
261 }
262
268 [[nodiscard]] T getRmsLevelDb(int channel) const noexcept
269 {
270 if (channel < 0 || channel >= MaxChannels) return T(-100);
271 const T squaredRms = state_[channel].rmsAccum.load(std::memory_order_relaxed);
272
273 // Fast path: avoid std::sqrt by using 10*log10(x^2) instead of 20*log10(x)
274 if (squaredRms <= T(1e-10)) return T(-100);
275 return T(10) * std::log10(squaredRms);
276 }
277
278private:
279 void updateCoefficients() noexcept
280 {
281 if (!(sampleRate_ > 0.0))
282 return;
283 const auto fs = static_cast<T>(sampleRate_);
284
285 const T attackMs = attackMs_.load(std::memory_order_relaxed);
286 const T releaseMs = releaseMs_.load(std::memory_order_relaxed);
287 const T rmsMs = rmsWindowMs_.load(std::memory_order_relaxed);
288
289 attackCoeff_.store(std::exp(T(-1) / (fs * attackMs / T(1000))), std::memory_order_relaxed);
290 releaseCoeff_.store(std::exp(T(-1) / (fs * releaseMs / T(1000))), std::memory_order_relaxed);
291 rmsCoeff_.store(std::exp(T(-1) / (fs * rmsMs / T(1000))), std::memory_order_relaxed);
292 }
293
294 struct ChannelState
295 {
296 // Written once per block by the audio thread, read at UI rate:
297 // contention is negligible, so no cache-line padding is needed.
298 std::atomic<T> peak{ T(0) };
299 std::atomic<T> rmsAccum{ T(0) };
300 };
301
302 double sampleRate_ = 44100.0;
303 int numChannels_ = 0;
304
305 std::atomic<T> attackMs_ { T(1) };
306 std::atomic<T> releaseMs_ { T(100) };
307 std::atomic<T> rmsWindowMs_ { T(300) };
308
309 std::atomic<T> attackCoeff_ { T(0) };
310 std::atomic<T> releaseCoeff_ { T(0) };
311 std::atomic<T> rmsCoeff_ { T(0) };
312
313 std::array<ChannelState, MaxChannels> state_{};
314};
315
316} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Per-channel peak and RMS envelope follower with lock-free readout.
T getAttackMs() const noexcept
Returns the peak attack time in milliseconds.
T getRmsWindowMs() const noexcept
Returns the RMS integration time constant in milliseconds.
void prepare(const AudioSpec &spec) noexcept
Prepares the follower for the given audio environment.
T getRmsLevelDb(int channel) const noexcept
Returns the current RMS level in decibels (optimized).
void setReleaseMs(float ms) noexcept
Sets the release time for peak metering.
void setRmsWindowMs(float ms) noexcept
Sets the integration time constant for RMS metering.
T getPeakLevelDb(int channel) const noexcept
Returns the current peak level in decibels.
void process(AudioBufferView< const T > buffer) noexcept
Processes a block of audio and updates level tracking.
T getPeakLevel(int channel) const noexcept
Returns the current peak level for the given channel safely.
void setAttackMs(float ms) noexcept
Sets the attack time for peak metering.
void reset() noexcept
Resets all envelope states to zero safely.
T getRmsLevel(int channel) const noexcept
Returns the current RMS level for the given channel safely.
T getReleaseMs() const noexcept
Returns the peak release time in milliseconds.
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