DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
LadderFilter.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
27#include "DspMath.h"
28#include "AudioSpec.h"
29#include "AudioBuffer.h"
30#include "DenormalGuard.h"
31
32#include <algorithm>
33#include <array>
34#include <atomic>
35#include <cmath>
36
37namespace dspark {
38
64template <FloatType T>
66{
67public:
69 enum class Mode
70 {
71 LP6,
72 LP12,
73 LP18,
74 LP24,
75 BP12,
76 HP24
77 };
78
79 ~LadderFilter() = default;
80
81 // -- Lifecycle --------------------------------------------------------------
82
91 void prepare(const AudioSpec& spec) noexcept
92 {
93 if (!(spec.sampleRate > 0.0)) return;
94 spec_ = spec;
95 updateCoefficients();
96 reset();
97 }
98
107 void processBlock(AudioBufferView<T> buffer) noexcept
108 {
109 DenormalGuard guard;
110 const int nCh = std::min(buffer.getNumChannels(), kMaxChannels);
111 const int nS = buffer.getNumSamples();
112
113 // Local snapshot to guarantee thread-safety and avoid mid-block changes
114 const T currentG = g_.load(std::memory_order_relaxed);
115 const T currentRes = resonance_.load(std::memory_order_relaxed);
116 const T currentDrive = drive_.load(std::memory_order_relaxed);
117 const Mode currentMode = mode_.load(std::memory_order_relaxed);
118
119 // Template dispatch to eliminate per-sample branching
120 switch (currentMode)
121 {
122 case Mode::LP6: processBlockInternal<Mode::LP6>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
123 case Mode::LP12: processBlockInternal<Mode::LP12>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
124 case Mode::LP18: processBlockInternal<Mode::LP18>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
125 case Mode::LP24: processBlockInternal<Mode::LP24>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
126 case Mode::BP12: processBlockInternal<Mode::BP12>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
127 case Mode::HP24: processBlockInternal<Mode::HP24>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
128 }
129 }
130
147 [[nodiscard]] T processSample(T input, int channel) noexcept
148 {
149 if (channel < 0 || channel >= kMaxChannels) return input;
150
151 const T g = g_.load(std::memory_order_relaxed);
152 const T res = resonance_.load(std::memory_order_relaxed);
153 const T drive = drive_.load(std::memory_order_relaxed);
154 const BlockCoeffs c = makeBlockCoeffs(g, res);
155 ChannelState& s = state_[channel];
156
157 switch (mode_.load(std::memory_order_relaxed))
158 {
159 case Mode::LP6: return processSampleInternal<Mode::LP6>(input, s, c, drive);
160 case Mode::LP12: return processSampleInternal<Mode::LP12>(input, s, c, drive);
161 case Mode::LP18: return processSampleInternal<Mode::LP18>(input, s, c, drive);
162 case Mode::LP24: return processSampleInternal<Mode::LP24>(input, s, c, drive);
163 case Mode::BP12: return processSampleInternal<Mode::BP12>(input, s, c, drive);
164 case Mode::HP24: return processSampleInternal<Mode::HP24>(input, s, c, drive);
165 }
166 return input;
167 }
168
175 void reset() noexcept
176 {
177 for (auto& s : state_)
178 s.z.fill(T(0));
179 }
180
181 // -- Parameters -------------------------------------------------------------
182
194 void setCutoff(T hz) noexcept
195 {
196 if (!std::isfinite(hz)) return;
197 cutoff_.store(std::max(hz, T(0)), std::memory_order_relaxed);
198 updateCoefficients();
199 }
200
207 void setResonance(T amount) noexcept
208 {
209 if (!std::isfinite(amount)) return; // NaN would poison the loop via k
210 resonance_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
211 }
212
219 void setDrive(T amount) noexcept
220 {
221 if (!std::isfinite(amount)) return; // Inf turns 0 * drive into NaN
222 drive_.store(std::max(amount, T(0.1)), std::memory_order_relaxed);
223 }
224
229 void setMode(Mode mode) noexcept
230 {
231 mode_.store(mode, std::memory_order_relaxed);
232 }
233
234 [[nodiscard]] T getCutoff() const noexcept { return cutoff_.load(std::memory_order_relaxed); }
235 [[nodiscard]] T getResonance() const noexcept { return resonance_.load(std::memory_order_relaxed); }
236 [[nodiscard]] T getDrive() const noexcept { return drive_.load(std::memory_order_relaxed); }
237 [[nodiscard]] Mode getMode() const noexcept { return mode_.load(std::memory_order_relaxed); }
238
239protected:
240 static constexpr int kMaxChannels = 16;
241
242 // Scalar per-channel state - no SIMD kernel ever touches it, and adjacent
243 // channels sharing a cache line is better locality than padded isolation,
244 // so it is deliberately not over-aligned. The stage tap outputs are NOT
245 // state (each sample overwrites all of them), so they live in registers
246 // inside processSampleInternal().
248 {
249 std::array<T, 4> z {}; // TPT integrator states (z^-1 equivalents)
250 };
251
252 std::array<ChannelState, kMaxChannels> state_ {};
254
255 // Atomic variables for thread-safety between Audio Thread and UI/Main Thread
256 std::atomic<T> cutoff_ {T(1000)};
257 std::atomic<T> resonance_ {T(0)};
258 std::atomic<T> drive_ {T(1)};
259 std::atomic<T> g_ {T(0)};
260 std::atomic<Mode> mode_ {Mode::LP24};
261
262private:
269 void updateCoefficients() noexcept
270 {
271 if (!(spec_.sampleRate > 0.0)) return;
272 const T clamped = std::clamp(cutoff_.load(std::memory_order_relaxed),
273 T(20), static_cast<T>(spec_.sampleRate) * T(0.499));
274 cutoff_.store(clamped, std::memory_order_relaxed);
275 const T preWarpedGain = static_cast<T>(std::tan(pi<double> * static_cast<double>(clamped) / spec_.sampleRate));
276 g_.store(preWarpedGain, std::memory_order_relaxed);
277 }
278
280 struct BlockCoeffs
281 {
282 T G, G2, G3, G4, ig, k, invFbDen;
283 };
284
286 [[nodiscard]] static BlockCoeffs makeBlockCoeffs(T g, T res) noexcept
287 {
288 BlockCoeffs c;
289 c.G = g / (T(1) + g);
290 c.G2 = c.G * c.G;
291 c.G3 = c.G2 * c.G;
292 c.G4 = c.G3 * c.G;
293 c.ig = T(1) / (T(1) + g);
294 c.k = res * T(4); // k = 4 -> self-oscillation
295 c.invFbDen = T(1) / (T(1) + c.k * c.G4); // zero-delay loop denominator
296 return c;
297 }
298
300 template <Mode FilterMode>
301 void processBlockInternal(AudioBufferView<T> buffer, int nCh, int nS, T g, T res, T drive) noexcept
302 {
303 // Precompute the block-constant coefficients ONCE (previously these two
304 // divisions ran for every sample of every channel).
305 const BlockCoeffs c = makeBlockCoeffs(g, res);
306
307 for (int ch = 0; ch < nCh; ++ch)
308 {
309 auto* channelData = buffer.getChannel(ch);
310 auto& state = state_[ch];
311
312 for (int i = 0; i < nS; ++i)
313 {
314 channelData[i] = processSampleInternal<FilterMode>(channelData[i], state, c, drive);
315 }
316 }
317 }
318
325 template <Mode FilterMode>
326 [[nodiscard]] T processSampleInternal(T input, ChannelState& s, const BlockCoeffs& c, T drive) noexcept
327 {
328 // Estimate the LP24 output from the integrator states (zero-delay logic)
329 const T Sest = c.G3 * c.ig * s.z[0]
330 + c.G2 * c.ig * s.z[1]
331 + c.G * c.ig * s.z[2]
332 + c.ig * s.z[3];
333
334 // Saturate the estimated feedback (cheap ZDF nonlinearity: the state
335 // contribution is shaped, the direct G4*u term stays linear).
336 // fastTanh clamps its input, so the saturated loop stays bounded.
337 T fbSignal = Sest;
338 if (drive > T(1))
339 fbSignal = fastTanh(fbSignal * drive) / drive;
340
341 // Resolve zero-delay loop (denominator precomputed once per block)
342 const T u = (input - c.k * fbSignal) * c.invFbDen;
343
344 // Four cascaded TPT one-pole stages. Tap outputs carry no state
345 // between samples, so they stay in registers ({} avoids a spurious
346 // uninitialised-variable warning; the stores are dead after unroll).
347 std::array<T, 4> y {};
348 T x = u;
349 for (int i = 0; i < 4; ++i)
350 {
351 const T v = (x - s.z[i]) * c.G;
352 y[i] = v + s.z[i];
353 s.z[i] = y[i] + v;
354 x = y[i];
355 }
356
357 // Output tap mix (resolved at compile time per mode instantiation)
358 if constexpr (FilterMode == Mode::LP6) return y[0];
359 else if constexpr (FilterMode == Mode::LP12) return y[1];
360 else if constexpr (FilterMode == Mode::LP18) return y[2];
361 else if constexpr (FilterMode == Mode::LP24) return y[3];
362 else if constexpr (FilterMode == Mode::BP12) return y[0] - y[2];
363 else
364 {
365 // HP24: apply the binomial (1-L)^4 to `u` (the ladder's true input,
366 // post feedback), NOT to `input`. With resonance, input = u*(1+k) at
367 // DC, so the input-based form leaked DC with gain k/(1+k) - e.g. 67%
368 // of the DC passed straight through a "high-pass" at resonance 0.5.
369 // With u the DC gain is exactly 0 and the resonant peak is preserved.
370 return u - T(4)*y[0] + T(6)*y[1] - T(4)*y[2] + y[3];
371 }
372 }
373};
374
375} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
4-pole resonant ladder filter (Moog topology, TPT discretization).
std::atomic< T > drive_
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an audio buffer in-place (thread-safe vs. setters).
std::atomic< T > cutoff_
T getCutoff() const noexcept
void setDrive(T amount) noexcept
Sets the nonlinear drive amount (thread-safe, lock-free).
Mode
Defines the frequency response mode of the filter output.
@ LP24
4-pole lowpass (24 dB/oct) - classic Moog style.
@ HP24
4-pole highpass (24 dB/oct).
@ LP6
1-pole lowpass (6 dB/oct).
@ BP12
Bandpass (6 dB/oct per side).
@ LP12
2-pole lowpass (12 dB/oct).
@ LP18
3-pole lowpass (18 dB/oct).
T getResonance() const noexcept
void setMode(Mode mode) noexcept
Sets the filter output mode (thread-safe, lock-free).
T getDrive() const noexcept
void setCutoff(T hz) noexcept
Sets the cutoff frequency (thread-safe, lock-free).
std::atomic< Mode > mode_
std::array< ChannelState, kMaxChannels > state_
Mode getMode() const noexcept
void prepare(const AudioSpec &spec) noexcept
Prepares the filter with the current audio environment.
std::atomic< T > resonance_
std::atomic< T > g_
static constexpr int kMaxChannels
void reset() noexcept
Clears the internal integrator state.
void setResonance(T amount) noexcept
Sets resonance amount (thread-safe, lock-free).
T processSample(T input, int channel) noexcept
Processes a single sample on one channel.
Main namespace for the DSPark framework.
T fastTanh(T x) noexcept
Fast tanh approximation using Pade rational function.
Definition DspMath.h:161
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45