Three things I got wrong about diode ladder self-oscillation
I’ve been building a diode ladder filter for SquelchPro for about four months. The first three versions were quietly wrong in different ways: inconsistent self-oscillation, no self-oscillation, or too much self-oscillation in the form of a solver that exploded whenever cutoff crossed 15 kHz. None of these showed up as obvious bugs. They showed up as “it sounds clean but not right,” which is the worst kind.
Here are the three mistakes, in the order I made them.
What a diode ladder actually is
Four one-pole lowpass stages in series, with diodes as the nonlinear elements between stages instead of the transistors of a Moog-style ladder. Global feedback wraps the whole ladder. When the loop gain reaches unity at the cutoff, the filter self-oscillates: it sings the cutoff frequency whether you feed it anything or not. That self-oscillation, and how it fails, is what the rest of this post is about.
1. I modeled both halves of the diode pair with the same curve
Real diode pairs are asymmetric unless they’ve been hand-matched. One diode conducts slightly before the other, and the resulting clipped waveform is asymmetric around zero. Every curve I reached for first was odd-symmetric: tanh(x), x / (1 + |x|), a couple of polynomial approximations. Clean math, clean harmonics, decaying evenly above the cutoff.
The filter sounded almost too pure. A real 303 with its feedback cranked hoots. Mine whooshed.
Fix: treat the two half-waves separately. Slightly different curves on the positive and negative lobes. I’m running about 12% asymmetry between them, which is enough to put back the second-harmonic content that was missing, not so much that the filter sounds broken at low resonance.
Lesson filed under: odd-symmetric nonlinearities produce only odd harmonics, and half the character of a real analog filter is the even harmonics nobody puts in the paper.
2. I set the feedback gain to a constant and hoped self-oscillation would happen
Self-oscillation occurs when the loop gain hits unity at the cutoff frequency. Four one-pole stages give you 180° of phase rotation at cutoff, which means the feedback path has to provide the remaining gain and sign for the loop to close.
The word that matters there is loop. My first version had a hard-coded feedback coefficient k and a nonlinearity inside the feedback path. The effective gain of a nonlinearity depends on the signal level it’s seeing. So at low input, my “k = 0.95” behaved like k = 0.7 and the filter never self-oscillated. At high input the same nonlinearity shifted the phase, and when the filter did oscillate it was at the wrong frequency.
// wrong: fixed k, nonlinearity silently eats gain
float y = stages.process(x - k * fbSample);
fbSample = clip(y);
// right: compensate k for the running saturation gain
float k_eff = k / stages.currentSaturationGain();
float y = stages.process(x - k_eff * fbSample);
fbSample = clip(y);
currentSaturationGain() is a running estimate of the nonlinearity’s slope at the current operating point. Cheap to compute, one multiply-add per sample on an IIR average. It’s the difference between a filter that self-oscillates reliably at k ≈ 0.95 across all input levels and one that feels broken depending on how hard you hit it.
3. I used explicit Euler integration and the filter blew up above 15 kHz
A one-pole lowpass is a first-order ODE:
dy/dt = (x - y) · 2π · fc
Discretize that with explicit Euler at sample rate Fs and you get a stability ceiling around Fs / (2π), which is about 7 kHz at 44.1 kHz. Above that, each sample overshoots the target and the state diverges. Stack four stages, wrap feedback around them, and you have an explosion waiting for the user to sweep the filter.
Standard fix: topology-preserving transform (TPT), a trapezoidal-rule integrator that’s unconditionally stable regardless of cutoff. The complication with a nonlinear ladder is that the stage’s output on this sample depends on itself through the nonlinearity, so you need to solve it implicitly per sample.
// TPT one-pole with embedded nonlinearity, 2 Newton iterations
float g = std::tan(M_PI * fc / Fs);
float y = z1;
for (int i = 0; i < 2; ++i) {
float e = x - y;
float f = g * clip(e) + z1 - y;
float df = -g * clipPrime(e) - 1.0f;
y -= f / df;
}
z1 = 2.0f * y - z1;
return y;
Two Newton iterations per stage per sample. On my machine that’s around 8 ns per stage; at four stages and 44.1 kHz it’s well under 0.2% CPU per voice. The filter is stable up to fc = 20 kHz with no oversampling inside the ladder itself.¹
¹ You still want 2× oversampling around the filter if you’re feeding hot signals into it, because the output can contain harmonics above Nyquist regardless of how stable the filter’s internal state is. Stability is an argument about the state; aliasing is an argument about the output. Different problem.
What this means for the plugins
SquelchBox, the free one, ships with a version of this ladder. I tuned the diode asymmetry lighter there because the 303 bass range doesn’t want as much second-harmonic grit as a house lead does. It self-oscillates cleanly from about k = 0.92 upward and holds stable across the full cutoff sweep.
SquelchPro gets the fully hand-tuned version: per-voice diode-pair modeling, a separate saturation curve for the feedback path, and the same TPT solver underneath. That one launches in December. The free version is the best preview of what the paid version feels like; I keep it that way on purpose.
If you’re building a ladder filter of your own and any of the above sounded familiar, you probably had one of the same three bugs. The fixes are small. The difference is audible.
→ SquelchBox is up now, Linux first.