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