Skip to content

Commit 03be881

Browse files
add biquad filter
1 parent 2e74f70 commit 03be881

11 files changed

Lines changed: 473 additions & 171 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2121
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF, Gain-Scheduled Controller, Lead-Lag Compensator, Luenberger Observer |
2222
| [Dynamics](doc/dynamics/README.md) | Euler-Lagrange, Newton-Euler, Recursive Newton-Euler, ABA |
2323
| [Estimators](doc/estimators/README.md) | Linear Regression, Polynomial Fitting, Yule-Walker (offline), Recursive Least Squares, LMS / NLMS Adaptive Filter (online) |
24-
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter, CIC (Cascaded Integrator-Comb), Notch/Comb Filter, Savitzky-Golay Filter |
24+
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter, CIC (Cascaded Integrator-Comb), Notch/Comb Filter, Savitzky-Golay Filter, Biquad/Second-Order-Section Cascade |
2525
| [Kinematics](doc/kinematics/README.md) | Forward Kinematics |
2626
| [Neural Network](doc/neural_network/README.md) | Layers, activations, losses, model |
2727
| [Optimization](doc/optimization/README.md) | Gradient Descent |

ROADMAP.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ Difficulty legend:
2727

2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
30-
| 15 | Biquad / Second-Order-Section cascade | `filters/passive` | ★★★☆☆ |
3130
| 20 | Integral / servo state feedback (LQI) | `controllers` | ★★★☆☆ |
3231
| 27 | QR decomposition (Householder / Givens) | `solvers` | ★★★★☆ |
3332
| 28 | LU decomposition with partial pivoting | `solvers` | ★★★★☆ |
@@ -123,12 +122,6 @@ Difficulty legend:
123122

124123
## Tier 3 — Moderate ★★★☆☆
125124

126-
### 15. Biquad / Second-Order-Section cascade
127-
- **What:** High-order IIR realized as a cascade of second-order sections (Direct Form I / Transposed Direct Form II).
128-
- **Embedded value:** **Highest-value passive-filter gap.** Direct-form high-order IIR is numerically fragile in `Q15`/`Q31`; SOS cascade is the standard robust realization. A float biquad already lives only in the [IIR simulator](simulator/filters/IirFilter/application/IirFilterSimulator.hpp); [Iir.md](doc/filters/passive/Iir.md) explicitly lists SOS as future work.
129-
- **Algorithm / paper:** Oppenheim & Schafer, *DTSP*, Ch. 6 (cascade/parallel structures); R. Bristow-Johnson, "Cookbook formulae for audio EQ biquad filter coefficients."
130-
- **Reuses:** [Iir.hpp](numerical/filters/passive/Iir.hpp), `math::RecursiveBuffer`.
131-
132125
### 16. Notch / comb filter
133126
- **What:** Narrow-band rejection (notch biquad) and periodic comb rejection.
134127
- **Embedded value:** Removes 50/60 Hz mains hum and harmonic interference from bio-signals and instrumentation.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Biquad / Second-Order-Section Cascade
2+
3+
## Overview & Motivation
4+
5+
High-order IIR filters realized as a single monolithic direct-form transfer function suffer
6+
from severe numerical sensitivity: small perturbations in any coefficient can shift poles
7+
dramatically, and round-off noise accumulates in proportion to the filter order. These
8+
effects are catastrophic on fixed-point hardware and non-trivial even in single-precision
9+
floating-point.
10+
11+
The solution adopted universally in professional DSP is to factor the transfer function into
12+
quadratic terms — each with exactly two poles and two zeros — and chain them in series.
13+
This cascade of second-order sections ("biquads") keeps each section's coefficients
14+
well-scaled, its pole-sensitivity small, and its round-off noise bounded independently of
15+
the total filter order.
16+
17+
## Mathematical Theory
18+
19+
### Transfer Function Factorization
20+
21+
Any real-coefficient rational transfer function of order $N$ factors as:
22+
23+
$$H(z) = G \prod_{k=1}^{\lceil N/2 \rceil} \frac{b_{0,k} + b_{1,k}z^{-1} + b_{2,k}z^{-2}}{1 + a_{1,k}z^{-1} + a_{2,k}z^{-2}}$$
24+
25+
Each factor is a second-order section (SOS). The overall frequency response is the product
26+
of the per-section responses; the total group delay is the sum of per-section group delays.
27+
28+
### RBJ Cookbook Design Equations
29+
30+
For standard shelving, peaking, and pass/reject types, closed-form normalized coefficients
31+
derive from the bilinear transform applied to an analog prototype. Define:
32+
33+
$$\omega_0 = \frac{2\pi f_c}{f_s}, \qquad c_\omega = \cos(\omega_0), \qquad \alpha = \frac{\sin(\omega_0)}{2Q}$$
34+
35+
**Low-pass:**
36+
37+
$$b_0 = \frac{1 - c_\omega}{2(1+\alpha)}, \quad b_1 = \frac{1-c_\omega}{1+\alpha}, \quad b_2 = b_0$$
38+
39+
$$a_1 = \frac{-2c_\omega}{1+\alpha}, \quad a_2 = \frac{1-\alpha}{1+\alpha}$$
40+
41+
**High-pass:** replace $(1 - c_\omega)$ with $(1 + c_\omega)$ and negate $b_1$.
42+
43+
**Notch (band-reject):**
44+
45+
$$b_0 = b_2 = \frac{1}{1+\alpha}, \quad b_1 = a_1 = \frac{-2c_\omega}{1+\alpha}, \quad a_2 = \frac{1-\alpha}{1+\alpha}$$
46+
47+
All coefficients are pre-normalized by $a_0 = 1 + \alpha$, so the feedback denominator leading
48+
coefficient is always 1 and the hot path requires no division.
49+
50+
### Transposed Direct Form II (TDF-II)
51+
52+
TDF-II is the canonical embedded realization. It minimizes the number of state registers to
53+
two per section while achieving low round-off noise. The recurrence for section $k$ is:
54+
55+
$$y[n] = b_0 x[n] + z_1[n-1]$$
56+
57+
$$z_1[n] = b_1 x[n] - a_1 y[n] + z_2[n-1]$$
58+
59+
$$z_2[n] = b_2 x[n] - a_2 y[n]$$
60+
61+
This is exactly five multiplies and four adds per sample — the theoretical minimum for an
62+
arbitrary biquad — and requires no intermediate storage beyond $z_1$ and $z_2$.
63+
64+
## Complexity Analysis
65+
66+
| Case | Time | Space | Notes |
67+
|------------|---------------|---------------|------------------------------------------------|
68+
| Per sample | $O(S)$ | $O(S)$ | $S$ = number of sections; 5 mults + 4 adds each |
69+
| Design | $O(1)$ | $O(1)$ | Closed-form RBJ formulas; no iteration |
70+
| Reset | $O(S)$ || Zero two state words per section |
71+
72+
## Step-by-Step Walkthrough
73+
74+
**Example:** design a 4th-order low-pass as two cascaded 2nd-order sections.
75+
76+
Given $f_c = 100$ Hz, $f_s = 1000$ Hz, $Q = 0.707$ (Butterworth factor for each section):
77+
78+
1. Compute $\omega_0 = 2\pi \cdot 0.1 \approx 0.6283$
79+
2. $c_\omega = \cos(0.6283) \approx 0.8090$, $\alpha = \sin(0.6283)/(2 \times 0.707) \approx 0.4142$
80+
3. $a_0 = 1.4142$; normalize: $b_0 \approx 0.06745$, $b_1 \approx 0.13490$, $b_2 \approx 0.06745$,
81+
$a_1 \approx -1.1429$, $a_2 \approx 0.4128$
82+
4. Use the same section twice to realize a 4th-order filter.
83+
5. At each sample, pass input through section 1, then section 2: $y = H_2(H_1(x))$.
84+
85+
**Impulse response trace** for bypass section $\{b_0=1, b_1=b_2=a_1=a_2=0\}$:
86+
Input $[1, 0, 0, \ldots]$ → output $[1, 0, 0, \ldots]$ — identity passthrough.
87+
88+
## Pitfalls & Edge Cases
89+
90+
- **Section ordering**: sections should be ordered by increasing peak gain and poles paired with
91+
their nearest zeros. Misordering can cause intermediate signals to overflow before later
92+
sections attenuate them, especially in fixed-point implementations.
93+
- **Poles near the unit circle**: high-$Q$ or near-Nyquist designs push poles close to
94+
$|z| = 1$. Finite-precision rounding can move a pole just outside, causing instability.
95+
Use $Q \leq 30$ in single precision; double precision or lattice realizations for higher $Q$.
96+
- **Denormal floats**: small state values approaching the denormal range stall the FPU pipeline
97+
on many embedded cores. Enabling flush-to-zero (FTZ) or the fast-math pragma prevents this
98+
at the cost of negligible numerical error.
99+
- **DC gain normalization**: the RBJ low-pass has unity DC gain by construction. Gain-staging
100+
between sections is not required; each section's output is well-scaled relative to its input.
101+
102+
## Variants & Generalizations
103+
104+
- **Direct Form I**: maintains four state variables per section (input and output history) but
105+
provides extra dynamic range at the cost of higher memory. Preferred when signal levels are
106+
difficult to bound.
107+
- **Lattice / wave-digital**: alternative topologies that remain stable under very aggressive
108+
coefficient quantization; used in high-speed fixed-point applications.
109+
- **Parallel SOS**: sections are summed rather than chained; useful for multi-band equalizers.
110+
- **Second-order allpass**: $b_0 = a_2$, $b_1 = a_1$, $b_2 = 1$ — phase rotation without
111+
magnitude change, used in crossover networks and polyphase systems.
112+
113+
## Applications
114+
115+
- Multi-pole anti-aliasing and reconstruction filters in ADC/DAC chains.
116+
- Audio equalization (parametric EQ, shelving filters) in embedded audio processors.
117+
- Vibration isolation and sensor conditioning in industrial control systems.
118+
- ECG/EEG baseline wander removal with high-pass SOS cascades.
119+
- Motor drive current-sensing loop compensation when a precise roll-off characteristic is needed.
120+
121+
## Connections to Other Algorithms
122+
123+
- **IIR (direct form)**: the single-section IIR is the primitive building block. A cascade of
124+
biquads is simply a structured composition of single sections with superior numerical properties.
125+
- **NotchCombFilter**: the notch filter is a single biquad specialized to place zeros exactly
126+
on the unit circle; a cascade realizes multi-pole notch designs.
127+
- **IirFilterDesign**: a companion design routine generates SOS coefficient arrays from an analog
128+
prototype via the bilinear transform, feeding directly into the cascade.
129+
- **RecursiveBuffer**: the IIR simulator uses `math::RecursiveBuffer` for time-domain state;
130+
the biquad avoids the overhead by keeping only two explicit state scalars per section.
131+
132+
## References & Further Reading
133+
134+
- R. Bristow-Johnson, "Cookbook formulae for audio EQ biquad filter coefficients",
135+
<https://www.w3.org/TR/audio-eq-cookbook/>
136+
- A. V. Oppenheim & R. W. Schafer, *Discrete-Time Signal Processing*, 3rd ed., Ch. 6
137+
(cascade and parallel structures).
138+
- R. G. Lyons, *Understanding Digital Signal Processing*, 3rd ed., Prentice Hall, 2011, Ch. 6.
139+
- S. J. Orfanidis, *Introduction to Signal Processing*, Prentice Hall, 1996, Ch. 12
140+
(second-order sections and ladder filters).
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#include "numerical/filters/passive/BiquadCascade.hpp"
2+
3+
namespace filters::passive
4+
{
5+
template struct BiquadCoeffs<float>;
6+
template class Biquad<float>;
7+
template class BiquadCascade<float, 2>;
8+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#pragma once
2+
3+
#if defined(__GNUC__) || defined(__clang__)
4+
#pragma GCC optimize("O3", "fast-math")
5+
#endif
6+
7+
#include "numerical/math/CompilerOptimizations.hpp"
8+
#include <array>
9+
#include <cmath>
10+
#include <cstddef>
11+
#include <numbers>
12+
#include <type_traits>
13+
14+
namespace filters::passive
15+
{
16+
template<typename T>
17+
struct BiquadCoeffs
18+
{
19+
static_assert(std::is_floating_point_v<T>, "BiquadCoeffs supports floating-point types");
20+
21+
T b0{};
22+
T b1{};
23+
T b2{};
24+
T a1{};
25+
T a2{};
26+
};
27+
28+
template<typename T>
29+
class Biquad
30+
{
31+
static_assert(std::is_floating_point_v<T>, "Biquad supports floating-point types");
32+
33+
public:
34+
explicit Biquad(BiquadCoeffs<T> coeffs) noexcept;
35+
36+
OPTIMIZE_FOR_SPEED T Filter(T x) noexcept;
37+
void Reset() noexcept;
38+
39+
static BiquadCoeffs<T> LowPass(T fc, T fs, T Q) noexcept;
40+
static BiquadCoeffs<T> HighPass(T fc, T fs, T Q) noexcept;
41+
static BiquadCoeffs<T> BandPass(T fc, T fs, T Q) noexcept;
42+
static BiquadCoeffs<T> Notch(T fc, T fs, T Q) noexcept;
43+
static BiquadCoeffs<T> Peaking(T fc, T fs, T Q, T gainDb) noexcept;
44+
45+
private:
46+
BiquadCoeffs<T> c{};
47+
T z1{};
48+
T z2{};
49+
};
50+
51+
template<typename T, std::size_t Sections>
52+
class BiquadCascade
53+
{
54+
static_assert(std::is_floating_point_v<T>, "BiquadCascade supports floating-point types");
55+
static_assert(Sections > 0, "BiquadCascade must have at least one section");
56+
57+
public:
58+
explicit BiquadCascade(std::array<BiquadCoeffs<T>, Sections> coeffs) noexcept;
59+
60+
OPTIMIZE_FOR_SPEED T Filter(T x) noexcept;
61+
void Reset() noexcept;
62+
63+
private:
64+
std::array<Biquad<T>, Sections> stages;
65+
};
66+
67+
//// Biquad Implementation ////
68+
69+
template<typename T>
70+
Biquad<T>::Biquad(BiquadCoeffs<T> coeffs) noexcept
71+
: c{ coeffs }
72+
{}
73+
74+
template<typename T>
75+
OPTIMIZE_FOR_SPEED T Biquad<T>::Filter(T x) noexcept
76+
{
77+
const T y{ c.b0 * x + z1 };
78+
z1 = c.b1 * x - c.a1 * y + z2;
79+
z2 = c.b2 * x - c.a2 * y;
80+
return y;
81+
}
82+
83+
template<typename T>
84+
void Biquad<T>::Reset() noexcept
85+
{
86+
z1 = T{};
87+
z2 = T{};
88+
}
89+
90+
template<typename T>
91+
BiquadCoeffs<T> Biquad<T>::LowPass(T fc, T fs, T Q) noexcept
92+
{
93+
const T w0{ T{ 2 } * std::numbers::pi_v<T> * fc / fs };
94+
const T cw{ std::cos(w0) };
95+
const T alpha{ std::sin(w0) / (T{ 2 } * Q) };
96+
const T a0{ T{ 1 } + alpha };
97+
return BiquadCoeffs<T>{
98+
(T{ 1 } - cw) / (T{ 2 } * a0),
99+
(T{ 1 } - cw) / a0,
100+
(T{ 1 } - cw) / (T{ 2 } * a0),
101+
(T{ -2 } * cw) / a0,
102+
(T{ 1 } - alpha) / a0
103+
};
104+
}
105+
106+
template<typename T>
107+
BiquadCoeffs<T> Biquad<T>::HighPass(T fc, T fs, T Q) noexcept
108+
{
109+
const T w0{ T{ 2 } * std::numbers::pi_v<T> * fc / fs };
110+
const T cw{ std::cos(w0) };
111+
const T alpha{ std::sin(w0) / (T{ 2 } * Q) };
112+
const T a0{ T{ 1 } + alpha };
113+
return BiquadCoeffs<T>{
114+
(T{ 1 } + cw) / (T{ 2 } * a0),
115+
-(T{ 1 } + cw) / a0,
116+
(T{ 1 } + cw) / (T{ 2 } * a0),
117+
(T{ -2 } * cw) / a0,
118+
(T{ 1 } - alpha) / a0
119+
};
120+
}
121+
122+
template<typename T>
123+
BiquadCoeffs<T> Biquad<T>::BandPass(T fc, T fs, T Q) noexcept
124+
{
125+
const T w0{ T{ 2 } * std::numbers::pi_v<T> * fc / fs };
126+
const T alpha{ std::sin(w0) / (T{ 2 } * Q) };
127+
const T cw{ std::cos(w0) };
128+
const T a0{ T{ 1 } + alpha };
129+
return BiquadCoeffs<T>{
130+
(std::sin(w0) / T{ 2 }) / a0,
131+
T{ 0 },
132+
-(std::sin(w0) / T{ 2 }) / a0,
133+
(T{ -2 } * cw) / a0,
134+
(T{ 1 } - alpha) / a0
135+
};
136+
}
137+
138+
template<typename T>
139+
BiquadCoeffs<T> Biquad<T>::Notch(T fc, T fs, T Q) noexcept
140+
{
141+
const T w0{ T{ 2 } * std::numbers::pi_v<T> * fc / fs };
142+
const T cw{ std::cos(w0) };
143+
const T alpha{ std::sin(w0) / (T{ 2 } * Q) };
144+
const T a0{ T{ 1 } + alpha };
145+
return BiquadCoeffs<T>{
146+
T{ 1 } / a0,
147+
(T{ -2 } * cw) / a0,
148+
T{ 1 } / a0,
149+
(T{ -2 } * cw) / a0,
150+
(T{ 1 } - alpha) / a0
151+
};
152+
}
153+
154+
template<typename T>
155+
BiquadCoeffs<T> Biquad<T>::Peaking(T fc, T fs, T Q, T gainDb) noexcept
156+
{
157+
const T A{ std::pow(T{ 10 }, gainDb / T{ 40 }) };
158+
const T w0{ T{ 2 } * std::numbers::pi_v<T> * fc / fs };
159+
const T cw{ std::cos(w0) };
160+
const T alpha{ std::sin(w0) / (T{ 2 } * Q) };
161+
const T a0{ T{ 1 } + alpha / A };
162+
return BiquadCoeffs<T>{
163+
(T{ 1 } + alpha * A) / a0,
164+
(T{ -2 } * cw) / a0,
165+
(T{ 1 } - alpha * A) / a0,
166+
(T{ -2 } * cw) / a0,
167+
(T{ 1 } - alpha / A) / a0
168+
};
169+
}
170+
171+
//// BiquadCascade Implementation ////
172+
173+
template<typename T, std::size_t Sections>
174+
BiquadCascade<T, Sections>::BiquadCascade(std::array<BiquadCoeffs<T>, Sections> coeffs) noexcept
175+
: stages{ [&]()
176+
{
177+
std::array<Biquad<T>, Sections> arr{ [&]<std::size_t... Is>(std::index_sequence<Is...>) -> std::array<Biquad<T>, Sections>
178+
{
179+
return { Biquad<T>{ coeffs[Is] }... };
180+
}(std::make_index_sequence<Sections>{}) };
181+
return arr;
182+
}() }
183+
{}
184+
185+
template<typename T, std::size_t Sections>
186+
OPTIMIZE_FOR_SPEED T BiquadCascade<T, Sections>::Filter(T x) noexcept
187+
{
188+
for (auto& stage : stages)
189+
x = stage.Filter(x);
190+
return x;
191+
}
192+
193+
template<typename T, std::size_t Sections>
194+
void BiquadCascade<T, Sections>::Reset() noexcept
195+
{
196+
for (auto& stage : stages)
197+
stage.Reset();
198+
}
199+
200+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
201+
extern template struct BiquadCoeffs<float>;
202+
extern template class Biquad<float>;
203+
extern template class BiquadCascade<float, 2>;
204+
#endif
205+
}

0 commit comments

Comments
 (0)