DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
LadderFilter.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
24#include "DspMath.h"
25#include "AudioSpec.h"
26#include "AudioBuffer.h"
27#include "DenormalGuard.h"
28
29#include <algorithm>
30#include <array>
31#include <atomic>
32#include <cmath>
33
34namespace dspark {
35
61template <FloatType T>
63{
64public:
66 enum class Mode
67 {
68 LP6,
69 LP12,
70 LP18,
71 LP24,
72 BP12,
73 HP24
74 };
75
76 ~LadderFilter() = default;
77
78 // -- Lifecycle --------------------------------------------------------------
79
88 void prepare(const AudioSpec& spec) noexcept
89 {
90 if (!(spec.sampleRate > 0.0)) return;
91 spec_ = spec;
92 updateCoefficients();
93 reset();
94 }
95
104 void processBlock(AudioBufferView<T> buffer) noexcept
105 {
106 DenormalGuard guard;
107 const int nCh = std::min(buffer.getNumChannels(), kMaxChannels);
108 const int nS = buffer.getNumSamples();
109
110 // Local snapshot to guarantee thread-safety and avoid mid-block changes
111 const T currentG = g_.load(std::memory_order_relaxed);
112 const T currentRes = resonance_.load(std::memory_order_relaxed);
113 const T currentDrive = drive_.load(std::memory_order_relaxed);
114 const Mode currentMode = mode_.load(std::memory_order_relaxed);
115
116 // Template dispatch to eliminate per-sample branching
117 switch (currentMode)
118 {
119 case Mode::LP6: processBlockInternal<Mode::LP6>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
120 case Mode::LP12: processBlockInternal<Mode::LP12>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
121 case Mode::LP18: processBlockInternal<Mode::LP18>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
122 case Mode::LP24: processBlockInternal<Mode::LP24>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
123 case Mode::BP12: processBlockInternal<Mode::BP12>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
124 case Mode::HP24: processBlockInternal<Mode::HP24>(buffer, nCh, nS, currentG, currentRes, currentDrive); break;
125 }
126 }
127
144 [[nodiscard]] T processSample(T input, int channel) noexcept
145 {
146 if (channel < 0 || channel >= kMaxChannels) return input;
147
148 const T g = g_.load(std::memory_order_relaxed);
149 const T res = resonance_.load(std::memory_order_relaxed);
150 const T drive = drive_.load(std::memory_order_relaxed);
151 const BlockCoeffs c = makeBlockCoeffs(g, res);
152 ChannelState& s = state_[channel];
153
154 switch (mode_.load(std::memory_order_relaxed))
155 {
156 case Mode::LP6: return processSampleInternal<Mode::LP6>(input, s, c, drive);
157 case Mode::LP12: return processSampleInternal<Mode::LP12>(input, s, c, drive);
158 case Mode::LP18: return processSampleInternal<Mode::LP18>(input, s, c, drive);
159 case Mode::LP24: return processSampleInternal<Mode::LP24>(input, s, c, drive);
160 case Mode::BP12: return processSampleInternal<Mode::BP12>(input, s, c, drive);
161 case Mode::HP24: return processSampleInternal<Mode::HP24>(input, s, c, drive);
162 }
163 return input;
164 }
165
172 void reset() noexcept
173 {
174 for (auto& s : state_)
175 s.z.fill(T(0));
176 }
177
178 // -- Parameters -------------------------------------------------------------
179
191 void setCutoff(T hz) noexcept
192 {
193 if (!std::isfinite(hz)) return;
194 cutoff_.store(std::max(hz, T(0)), std::memory_order_relaxed);
195 updateCoefficients();
196 }
197
204 void setResonance(T amount) noexcept
205 {
206 if (!std::isfinite(amount)) return; // NaN would poison the loop via k
207 resonance_.store(std::clamp(amount, T(0), T(1)), std::memory_order_relaxed);
208 }
209
216 void setDrive(T amount) noexcept
217 {
218 if (!std::isfinite(amount)) return; // Inf turns 0 * drive into NaN
219 drive_.store(std::max(amount, T(0.1)), std::memory_order_relaxed);
220 }
221
226 void setMode(Mode mode) noexcept
227 {
228 mode_.store(mode, std::memory_order_relaxed);
229 }
230
231 [[nodiscard]] T getCutoff() const noexcept { return cutoff_.load(std::memory_order_relaxed); }
232 [[nodiscard]] T getResonance() const noexcept { return resonance_.load(std::memory_order_relaxed); }
233 [[nodiscard]] T getDrive() const noexcept { return drive_.load(std::memory_order_relaxed); }
234 [[nodiscard]] Mode getMode() const noexcept { return mode_.load(std::memory_order_relaxed); }
235
236protected:
237 static constexpr int kMaxChannels = 16;
238
239 // Scalar per-channel state - no SIMD kernel ever touches it, and adjacent
240 // channels sharing a cache line is better locality than padded isolation,
241 // so it is deliberately not over-aligned. The stage tap outputs are NOT
242 // state (each sample overwrites all of them), so they live in registers
243 // inside processSampleInternal().
245 {
246 std::array<T, 4> z {}; // TPT integrator states (z^-1 equivalents)
247 };
248
249 std::array<ChannelState, kMaxChannels> state_ {};
251
252 // Atomic variables for thread-safety between Audio Thread and UI/Main Thread
253 std::atomic<T> cutoff_ {T(1000)};
254 std::atomic<T> resonance_ {T(0)};
255 std::atomic<T> drive_ {T(1)};
256 std::atomic<T> g_ {T(0)};
257 std::atomic<Mode> mode_ {Mode::LP24};
258
259private:
266 void updateCoefficients() noexcept
267 {
268 if (!(spec_.sampleRate > 0.0)) return;
269 const T clamped = std::clamp(cutoff_.load(std::memory_order_relaxed),
270 T(20), static_cast<T>(spec_.sampleRate) * T(0.499));
271 cutoff_.store(clamped, std::memory_order_relaxed);
272 const T preWarpedGain = static_cast<T>(std::tan(pi<double> * static_cast<double>(clamped) / spec_.sampleRate));
273 g_.store(preWarpedGain, std::memory_order_relaxed);
274 }
275
277 struct BlockCoeffs
278 {
279 T G, G2, G3, G4, ig, k, invFbDen;
280 };
281
283 [[nodiscard]] static BlockCoeffs makeBlockCoeffs(T g, T res) noexcept
284 {
285 BlockCoeffs c;
286 c.G = g / (T(1) + g);
287 c.G2 = c.G * c.G;
288 c.G3 = c.G2 * c.G;
289 c.G4 = c.G3 * c.G;
290 c.ig = T(1) / (T(1) + g);
291 c.k = res * T(4); // k = 4 -> self-oscillation
292 c.invFbDen = T(1) / (T(1) + c.k * c.G4); // zero-delay loop denominator
293 return c;
294 }
295
297 template <Mode FilterMode>
298 void processBlockInternal(AudioBufferView<T> buffer, int nCh, int nS, T g, T res, T drive) noexcept
299 {
300 // Precompute the block-constant coefficients ONCE (previously these two
301 // divisions ran for every sample of every channel).
302 const BlockCoeffs c = makeBlockCoeffs(g, res);
303
304 for (int ch = 0; ch < nCh; ++ch)
305 {
306 auto* channelData = buffer.getChannel(ch);
307 auto& state = state_[ch];
308
309 for (int i = 0; i < nS; ++i)
310 {
311 channelData[i] = processSampleInternal<FilterMode>(channelData[i], state, c, drive);
312 }
313 }
314 }
315
322 template <Mode FilterMode>
323 [[nodiscard]] T processSampleInternal(T input, ChannelState& s, const BlockCoeffs& c, T drive) noexcept
324 {
325 // Estimate the LP24 output from the integrator states (zero-delay logic)
326 const T Sest = c.G3 * c.ig * s.z[0]
327 + c.G2 * c.ig * s.z[1]
328 + c.G * c.ig * s.z[2]
329 + c.ig * s.z[3];
330
331 // Saturate the estimated feedback (cheap ZDF nonlinearity: the state
332 // contribution is shaped, the direct G4*u term stays linear).
333 // fastTanh clamps its input, so the saturated loop stays bounded.
334 T fbSignal = Sest;
335 if (drive > T(1))
336 fbSignal = fastTanh(fbSignal * drive) / drive;
337
338 // Resolve zero-delay loop (denominator precomputed once per block)
339 const T u = (input - c.k * fbSignal) * c.invFbDen;
340
341 // Four cascaded TPT one-pole stages. Tap outputs carry no state
342 // between samples, so they stay in registers ({} avoids a spurious
343 // uninitialised-variable warning; the stores are dead after unroll).
344 std::array<T, 4> y {};
345 T x = u;
346 for (int i = 0; i < 4; ++i)
347 {
348 const T v = (x - s.z[i]) * c.G;
349 y[i] = v + s.z[i];
350 s.z[i] = y[i] + v;
351 x = y[i];
352 }
353
354 // Output tap mix (resolved at compile time per mode instantiation)
355 if constexpr (FilterMode == Mode::LP6) return y[0];
356 else if constexpr (FilterMode == Mode::LP12) return y[1];
357 else if constexpr (FilterMode == Mode::LP18) return y[2];
358 else if constexpr (FilterMode == Mode::LP24) return y[3];
359 else if constexpr (FilterMode == Mode::BP12) return y[0] - y[2];
360 else
361 {
362 // HP24: apply the binomial (1-L)^4 to `u` (the ladder's true input,
363 // post feedback), NOT to `input`. With resonance, input = u*(1+k) at
364 // DC, so the input-based form leaked DC with gain k/(1+k) - e.g. 67%
365 // of the DC passed straight through a "high-pass" at resonance 0.5.
366 // With u the DC gain is exactly 0 and the resonant peak is preserved.
367 return u - T(4)*y[0] + T(6)*y[1] - T(4)*y[2] + y[3];
368 }
369 }
370};
371
372} // namespace dspark
Owning audio buffer and non-owning view for real-time DSP processing.
Describes the audio processing environment (sample rate, block size, channels).
RAII scope guard that disables denormal (subnormal) float arithmetic.
Core mathematical utilities for digital signal processing.
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:126
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43