Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
Loading...
Searching...
No Matches
ladder.hpp
Go to the documentation of this file.
1// sbl/dsp/modules/ladder.hpp — Moog-style Ladder Filter (Audio Stack — Modules)
2//
3// 4-pole (24 dB/oct) resonant lowpass filter modeled on the Moog transistor
4// ladder topology. Uses the Huovilainen nonlinear digital model with tanh
5// saturation per stage, feedback delay compensation, and 4x internal
6// oversampling with 2-stage decimation (pair-average 4→2, Hann 2→1).
7//
8// The Moog ladder's character comes from three properties:
9// 1. Steep 24 dB/oct rolloff — dramatic filter sweeps
10// 2. Tanh nonlinearities — warm saturation that depends on input level
11// 3. Input-side gain compensation — the feedback topology naturally
12// attenuates passband gain by 1/(1+k); we compensate by driving
13// the input harder (like an analog Moog), and the tanh stages
14// naturally limit the resonant peak — no output clipping possible
15//
16// The drive parameter controls how hard the input hits the tanh stages.
17// Higher drive = more harmonics = fatter, more saturated sound. This is
18// how the analog Moog behaves — the saturation is part of the filter,
19// not a separate effect.
20//
21// Self-oscillation: at resonance = 1.0, the filter self-oscillates,
22// producing a sine wave at the cutoff frequency. This is the hallmark
23// Moog behavior.
24//
25// Signal Unit API (FDP-052):
26// Widgets expose normalized [0,1] inputs via _su() methods. The widget
27// owns internal mapping (exp curve for cutoff, linear for resonance/drive)
28// and parameter conditioning (CriticalDamp for filter coefficients,
29// OnePole for drive). Applications pass normalized pot/CV values directly.
30//
31// Reference: Huovilainen, A. (2004). "Non-linear Digital Implementation
32// of the Moog Ladder Filter." DAFx-04.
33//
34// See FDP-032 for design rationale.
35//
36// Usage:
37// sbl::dsp::modules::Ladder filter;
38// filter.set_cutoff_su(0.5f); // [0,1] → 20–20000 Hz (exp mapped)
39// filter.set_resonance_su(0.7f); // [0,1] → internally mapped
40// filter.set_drive_su(0.3f); // [0,1] → 1.0–4.0 drive
41// filter.process(buf, frames); // per-sample smoothed coefficients
42//
43// // Per-sample modulated filtering (LFO → cutoff):
44// filter.process(buf, n, cv_buf, 3.0f); // ±3 oct around smoothed center
45
46#ifndef SBL_DSP_MODULES_LADDER_HPP_
47#define SBL_DSP_MODULES_LADDER_HPP_
48
49#include <cstdint>
50
58
60
61class Ladder {
62public:
63 /// @note All public methods are ISR-safe — bounded computation, no I/O.
64
65 // === Frequency range constants (for test helpers) ===
66 static constexpr float kMinFreqHz = 20.0f;
67 static constexpr float kMaxFreqHz = 20000.0f;
68 static constexpr float kCutoffOctaves = 9.965784f; // log2(20000/20)
69 static constexpr float kMinDrive = 1.0f;
70 static constexpr float kMaxDrive = 4.0f;
71
72 // === Signal Unit API (FDP-052) ===
73
74 /**
75 * @brief Set cutoff frequency from normalized [0,1] signal unit
76 * @param su Unipolar [0,1] — exponentially mapped to 20–20000 Hz
77 *
78 * First call auto-initializes (no ramp from 0). Subsequent calls
79 * smooth via CriticalDamp (30ms settling, no overshoot).
80 */
81 void set_cutoff_su(float su) {
82 su = math::clamp01(su);
84 target_freq_hz_ = hz;
85 ensure_smoothers();
86 if (!cutoff_init_) {
87 cd_cutoff_.reset(hz);
88 apply_cutoff(hz);
89 cutoff_init_ = true;
90 }
91 }
92
93 /**
94 * @brief Set resonance from normalized [0,1] signal unit
95 * @param su Unipolar [0,1] — 0 = flat, 1 = self-oscillation
96 *
97 * First call auto-initializes. Subsequent calls smooth via
98 * CriticalDamp (20ms settling, no overshoot).
99 */
100 void set_resonance_su(float su) {
101 su = math::clamp01(su);
102 target_q_ = su;
103 ensure_smoothers();
104 if (!reso_init_) {
105 cd_resonance_.reset(su);
106 apply_resonance(su);
107 reso_init_ = true;
108 }
109 }
110
111 /**
112 * @brief Set drive from normalized [0,1] signal unit
113 * @param su Unipolar [0,1] — linearly mapped to 1.0–4.0 drive
114 *
115 * First call auto-initializes. Subsequent calls smooth via
116 * OnePole (20ms settling).
117 */
118 void set_drive_su(float su) {
119 su = math::clamp01(su);
120 float drive = kMinDrive + su * (kMaxDrive - kMinDrive);
121 target_drive_ = drive;
122 ensure_smoothers();
123 if (!drive_init_) {
124 op_drive_.reset(drive);
125 drive_ = drive;
126 drive_init_ = true;
127 }
128 }
129
130 /**
131 * @brief Process a block of audio samples in-place (4-pole lowpass)
132 *
133 * Per-sample CriticalDamp/OnePole smoothing for cutoff, resonance,
134 * and drive. When cutoff_mod is provided, exponential modulation is
135 * applied around the smoothed cutoff center per-sample.
136 *
137 * @param buf Float audio buffer (modified in-place)
138 * @param frames Number of samples
139 * @param cutoff_mod Cutoff modulation signal [-1,1] (nullptr = no modulation)
140 * @param mod_depth Modulation depth in octaves (e.g., 2.0 = ±2 octaves)
141 */
142 void process(float* buf, uint16_t frames,
143 const float* cutoff_mod = nullptr, float mod_depth = 0.0f) {
144 ensure_smoothers();
145 float freq_target = target_freq_hz_;
146 float q_target = target_q_;
147 float drive_target = target_drive_;
148 float sr_os = types::SAMPLE_RATE_F * static_cast<float>(OS_FACTOR);
149 float s0 = stage_[0], s1 = stage_[1];
150 float s2 = stage_[2], s3 = stage_[3];
151 float d = delay_;
152 float dp = decim_prev_;
153
154 for (uint16_t i = 0; i < frames; ++i) {
155 // Advance coefficient smoothers
156 float center = cd_cutoff_.process(freq_target);
157 float q = cd_resonance_.process(q_target);
158 float drive = op_drive_.process(drive_target);
159
160 // Apply modulation if provided
161 float freq = (cutoff_mod != nullptr)
162 ? math::exp_mod(center, cutoff_mod[i], mod_depth,
164 : center;
165
166 // Compute Ladder coefficients from smoothed values
167 float g = compute_g(freq, sr_os);
168 float k = q * 6.0f;
169 float gain_comp = 1.0f + k * 0.5f;
170 float total_drive = drive * gain_comp;
171 if (total_drive > MAX_INPUT_DRIVE) total_drive = MAX_INPUT_DRIVE;
172
173 float x = buf[i] * total_drive;
174
175 // 4x oversampled with 2-stage decimation (4→2→1).
176 float os_out[OS_FACTOR];
177 for (int os = 0; os < OS_FACTOR; ++os) {
178 float feedback = d * k;
179 float input = math::fast_tanhf(x - feedback);
180
181 s0 += g * (input - math::fast_tanhf(s0));
182 s1 += g * (math::fast_tanhf(s0) - math::fast_tanhf(s1));
183 s2 += g * (math::fast_tanhf(s1) - math::fast_tanhf(s2));
184 s3 += g * (math::fast_tanhf(s2) - math::fast_tanhf(s3));
185
186 d = s3;
187 os_out[os] = s3;
188 }
189
190 // 2-stage: pair average (4→2), then Hann (2→1)
191 float half0 = (os_out[0] + os_out[1]) * 0.5f;
192 float half1 = (os_out[2] + os_out[3]) * 0.5f;
193 buf[i] = 0.25f * dp + 0.5f * half0 + 0.25f * half1;
194 dp = half1;
195 }
196
197 stage_[0] = s0; stage_[1] = s1;
198 stage_[2] = s2; stage_[3] = s3;
199 delay_ = d;
200 decim_prev_ = dp;
201
202 // Sync cached coefficients with final smoothed state
203 sync_cached_coefficients();
204 }
205
206 /** @brief Reset filter state (audio state only, not parameter smoothers) */
207 void reset() {
208 stage_[0] = stage_[1] = stage_[2] = stage_[3] = 0.0f;
209 delay_ = 0.0f;
210 decim_prev_ = 0.0f;
211 }
212
213private:
214 // Test-only access to engineering-unit setters
215 friend struct LadderTestAccess;
216
217 /** @brief Set cutoff in Hz — immediate, no smoothing (private) */
218 void set_cutoff(float freq_hz) {
219 target_freq_hz_ = freq_hz;
220 cd_cutoff_.reset(freq_hz);
221 apply_cutoff(freq_hz);
222 cutoff_init_ = true;
223 }
224
225 /** @brief Set resonance 0–1 — immediate, no smoothing (private) */
226 void set_resonance(float q) {
227 target_q_ = q;
228 cd_resonance_.reset(q);
229 apply_resonance(q);
230 reso_init_ = true;
231 }
232
233 /** @brief Set drive — immediate, no smoothing (private) */
234 void set_drive(float drive) {
235 target_drive_ = drive;
236 op_drive_.reset(drive);
237 drive_ = drive;
238 drive_init_ = true;
239 }
240
241 void apply_cutoff(float freq_hz) {
242 cutoff_hz_ = freq_hz;
243 update_g();
244 }
245
246 void apply_resonance(float q) {
247 k_ = q * 6.0f;
248 gain_comp_ = 1.0f + k_ * 0.5f;
249 }
250
251 /// Compute one-pole stage gain from frequency
252 static float compute_g(float freq_hz, float sr) {
253 float fc = freq_hz / sr;
254 if (fc > 0.497f) fc = 0.497f;
255 if (fc < 0.0f) fc = 0.0f;
256 // Huovilainen stage coefficient: 1 - exp(-2π·fc)
257 // Using fast_exp2f: exp(-x) = 2^(-x/ln2)
258 constexpr float inv_ln2 = 1.4426950f; // 1/ln(2)
259 return 1.0f - math::fast_exp2f(-math::TWO_PI * fc * inv_ln2);
260 }
261
262 void update_g() {
263 g_ = compute_g(cutoff_hz_, types::SAMPLE_RATE_F * static_cast<float>(OS_FACTOR));
264 }
265
266 void ensure_smoothers() {
267 if (!smoothers_configured_) {
268 cd_cutoff_.set_time_ms(30.0f, types::SAMPLE_RATE_F);
269 cd_resonance_.set_time_ms(20.0f, types::SAMPLE_RATE_F);
270 op_drive_.set_time_ms(20.0f, types::SAMPLE_RATE_F);
271 cd_cutoff_.reset(target_freq_hz_);
272 cd_resonance_.reset(target_q_);
273 op_drive_.reset(target_drive_);
274 smoothers_configured_ = true;
275 update_g();
276 }
277 }
278
279 void sync_cached_coefficients() {
280 float q = cd_resonance_.value();
281 k_ = q * 6.0f;
282 gain_comp_ = 1.0f + k_ * 0.5f;
283 cutoff_hz_ = cd_cutoff_.value();
284 drive_ = op_drive_.value();
285 update_g();
286 }
287
288 static constexpr int OS_FACTOR = 4;
289 static constexpr float OS_RECIP = 1.0f / static_cast<float>(OS_FACTOR);
290 static constexpr float MAX_INPUT_DRIVE = 6.0f; // Cap total (drive * gain_comp)
291
292 float stage_[4] = {}; // One-pole state per stage
293 float delay_ = 0.0f; // Feedback delay (z^-1)
294 float g_ = 0.0f; // Stage coefficient
295 float k_ = 0.0f; // Feedback amount (0-6)
296 float gain_comp_ = 1.0f; // Precomputed resonance gain compensation
297 float drive_ = 1.0f; // Input drive
298 float cutoff_hz_ = 1000.0f;
299 float decim_prev_ = 0.0f; // History for 2-stage decimation Hann window
300
301 // CriticalDamp smoothers for _su() inputs (FDP-052)
302 primitives::CriticalDamp cd_cutoff_;
303 primitives::CriticalDamp cd_resonance_;
304 primitives::ParameterSmoother op_drive_;
305 float target_freq_hz_ = 1000.0f;
306 float target_q_ = 0.0f;
307 float target_drive_ = 1.0f;
308 bool cutoff_init_ = false;
309 bool reso_init_ = false;
310 bool drive_init_ = false;
311 bool smoothers_configured_ = false;
312};
313
314} // namespace sbl::dsp::modules
315
316#endif // SBL_DSP_MODULES_LADDER_HPP_
Clamping (Cross-cutting — Math)
void process(float *buf, uint16_t frames, const float *cutoff_mod=nullptr, float mod_depth=0.0f)
Process a block of audio samples in-place (4-pole lowpass)
Definition ladder.hpp:142
void set_drive_su(float su)
Set drive from normalized [0,1] signal unit.
Definition ladder.hpp:118
void set_cutoff_su(float su)
Set cutoff frequency from normalized [0,1] signal unit.
Definition ladder.hpp:81
static constexpr float kMaxDrive
Definition ladder.hpp:70
void reset()
Reset filter state (audio state only, not parameter smoothers)
Definition ladder.hpp:207
void set_resonance_su(float su)
Set resonance from normalized [0,1] signal unit.
Definition ladder.hpp:100
static constexpr float kMinFreqHz
Definition ladder.hpp:66
static constexpr float kMaxFreqHz
Definition ladder.hpp:67
static constexpr float kMinDrive
Definition ladder.hpp:69
static constexpr float kCutoffOctaves
Definition ladder.hpp:68
friend struct LadderTestAccess
Definition ladder.hpp:215
float value() const
Current output value.
void set_time_ms(float ms, float rate_hz)
Set settling time in milliseconds.
float process(float target)
Process one sample toward target.
void set_time_ms(float ms, float rate_hz)
Compute coefficient from settling time in milliseconds.
float process(float target)
Process one sample toward target.
float value() const
Current smoothed value.
The numbers every layer reaches for.
Critically damped 2nd-order parameter smoother.
Exponential modulation converter (Signal layer)
Fast analytical approximations (Audio Stack — Atoms)
Fixed-point constants and audio sample types.
constexpr float clamp01(float x)
x held to [0, 1]; NaN → 0.
Definition clamp.hpp:18
float fast_exp2f(float x)
Definition fast_math.hpp:99
float exp_mod(float base, float mod, float depth, float lo=20.0f, float hi=20000.0f)
Definition exp_mod.hpp:50
constexpr float TWO_PI
Definition constants.hpp:12
float su_to_hz(float su, float min_hz, float octaves)
float fast_tanhf(float x)
Complete musical tools.
Definition ladder.hpp:59
float SAMPLE_RATE_F
Definition fixed.hpp:17
One-pole parameter smoother.