Skip to main content

embedded_dsp/
filter_analysis.rs

1//! Frequency-response, group-delay, and pole-based stability analysis for filters
2//! produced by [`crate::filter_design`] or hand-written FIR/biquad coefficients.
3//!
4//! These routines evaluate the DTFT `H(e^{jω})` of a coefficient set directly (no FFT
5//! required), so a filter design can be inspected at arbitrary frequencies before it is
6//! deployed to a real-time processing path.
7
8#[allow(unused_imports)]
9use crate::math::FloatMath;
10use crate::types::Complex;
11
12// --- Frequency Response (DTFT evaluation, H(e^{jw})) ---
13
14/// Evaluates the frequency response `H(e^{jω}) = Σ h[k] e^{-jkω}` of an FIR filter (or any
15/// raw coefficient sequence) at a single normalized frequency `freq_norm` (cycles/sample,
16/// `0.0..=0.5`, where `0.5` is Nyquist).
17pub fn fir_frequency_response(taps: &[f32], freq_norm: f32) -> Complex<f32> {
18    let omega = 2.0 * core::f32::consts::PI * freq_norm;
19    let mut real = 0.0f32;
20    let mut imag = 0.0f32;
21    for (k, &tap) in taps.iter().enumerate() {
22        let angle = omega * k as f32;
23        real += tap * angle.cos();
24        imag -= tap * angle.sin();
25    }
26    Complex::new(real, imag)
27}
28
29/// Evaluates the frequency response `H(e^{jω})` of a single Direct Form I biquad section
30/// `[b0, b1, b2, a1, a2]` (as produced by [`crate::filter_design`] and consumed by
31/// [`crate::filtering::biquad_cascade_df1_f32`], where `y(n) = b0 x(n) + b1 x(n-1) + b2 x(n-2)
32/// + a1 y(n-1) + a2 y(n-2)`) at a single normalized frequency `freq_norm` (cycles/sample,
33/// `0.0..=0.5`).
34pub fn biquad_frequency_response(coeffs: &[f32; 5], freq_norm: f32) -> Complex<f32> {
35    let omega = 2.0 * core::f32::consts::PI * freq_norm;
36    let cos1 = omega.cos();
37    let sin1 = omega.sin();
38    let cos2 = (2.0 * omega).cos();
39    let sin2 = (2.0 * omega).sin();
40
41    let num = Complex::new(
42        coeffs[0] + coeffs[1] * cos1 + coeffs[2] * cos2,
43        -coeffs[1] * sin1 - coeffs[2] * sin2,
44    );
45    // Denominator of H(z) = 1 - a1*z^-1 - a2*z^-2, matching the recurrence's sign convention.
46    let den = Complex::new(
47        1.0 - coeffs[3] * cos1 - coeffs[4] * cos2,
48        coeffs[3] * sin1 + coeffs[4] * sin2,
49    );
50    complex_divide(num, den)
51}
52
53/// Evaluates the combined frequency response of a cascade of Direct Form I biquad sections
54/// (`coeffs.len()` must be a multiple of 5, as produced by e.g.
55/// [`crate::filter_design::butterworth_lowpass_biquads`]) at a single normalized frequency
56/// `freq_norm` (cycles/sample, `0.0..=0.5`).
57pub fn biquad_cascade_frequency_response(coeffs: &[f32], freq_norm: f32) -> Complex<f32> {
58    let mut total = Complex::new(1.0f32, 0.0f32);
59    for stage in coeffs.chunks_exact(5) {
60        let section: [f32; 5] = [stage[0], stage[1], stage[2], stage[3], stage[4]];
61        total = complex_multiply(total, biquad_frequency_response(&section, freq_norm));
62    }
63    total
64}
65
66/// Returns the linear magnitude `|H(e^{jω})|` of a complex frequency-response value.
67pub fn response_magnitude(h: Complex<f32>) -> f32 {
68    (h.real * h.real + h.imag * h.imag).sqrt()
69}
70
71/// Returns the magnitude of a complex frequency-response value in decibels: `20 * log10(|H|)`.
72pub fn response_magnitude_db(h: Complex<f32>) -> f32 {
73    20.0 * response_magnitude(h).max(1e-20).log10()
74}
75
76/// Returns the phase (argument) of a complex frequency-response value, in radians, wrapped to
77/// `(-π, π]`.
78pub fn response_phase(h: Complex<f32>) -> f32 {
79    h.imag.atan2(h.real)
80}
81
82fn complex_multiply(a: Complex<f32>, b: Complex<f32>) -> Complex<f32> {
83    Complex::new(
84        a.real * b.real - a.imag * b.imag,
85        a.real * b.imag + a.imag * b.real,
86    )
87}
88
89fn complex_divide(a: Complex<f32>, b: Complex<f32>) -> Complex<f32> {
90    let denom = b.real * b.real + b.imag * b.imag;
91    if denom < 1e-20 {
92        return Complex::new(0.0, 0.0);
93    }
94    let inv_denom = 1.0 / denom;
95    Complex::new(
96        (a.real * b.real + a.imag * b.imag) * inv_denom,
97        (a.imag * b.real - a.real * b.imag) * inv_denom,
98    )
99}
100
101// --- Group Delay (Discrete-Time Fourier Transform, linear-phase analysis) ---
102
103/// Computes the group delay (in samples), `τ(ω) = Re[B(e^{jω}) / H(e^{jω})]` where
104/// `B(e^{jω}) = Σ k·h[k]·e^{-jkω}`, of an FIR filter at a single normalized frequency
105/// `freq_norm` (cycles/sample, `0.0..=0.5`). For a linear-phase (symmetric) FIR of length `M`,
106/// this is constant and equal to `(M - 1) / 2` at every frequency.
107pub fn fir_group_delay(taps: &[f32], freq_norm: f32) -> f32 {
108    let omega = 2.0 * core::f32::consts::PI * freq_norm;
109    let mut h_re = 0.0f32;
110    let mut h_im = 0.0f32;
111    let mut b_re = 0.0f32;
112    let mut b_im = 0.0f32;
113    for (k, &tap) in taps.iter().enumerate() {
114        let n = k as f32;
115        let angle = omega * n;
116        let c = angle.cos();
117        let s = angle.sin();
118        h_re += tap * c;
119        h_im -= tap * s;
120        b_re += n * tap * c;
121        b_im -= n * tap * s;
122    }
123    let denom = h_re * h_re + h_im * h_im;
124    if denom < 1e-20 {
125        return 0.0;
126    }
127    (b_re * h_re + b_im * h_im) / denom
128}
129
130// --- Pole-Based Stability Analysis (Z-transform) ---
131
132/// Computes the pole radius (largest pole magnitude on the z-plane) of a single Direct Form I
133/// biquad section `[b0, b1, b2, a1, a2]`, whose poles are the roots of
134/// `z^2 - a1*z - a2 = 0`. A causal LTI system is stable if and only if all poles lie strictly
135/// inside the unit circle (`pole_radius < 1.0`).
136pub fn biquad_pole_radius(coeffs: &[f32; 5]) -> f32 {
137    let a1 = coeffs[3];
138    let a2 = coeffs[4];
139    let discriminant = a1 * a1 + 4.0 * a2;
140    if discriminant >= 0.0 {
141        let sqrt_d = discriminant.sqrt();
142        let p1 = (a1 + sqrt_d) / 2.0;
143        let p2 = (a1 - sqrt_d) / 2.0;
144        p1.abs().max(p2.abs())
145    } else {
146        // Complex-conjugate pole pair: |pole|^2 equals the product of the roots, -a2.
147        (-a2).sqrt()
148    }
149}
150
151/// Returns `true` if the single biquad section `[b0, b1, b2, a1, a2]` is stable, i.e. both
152/// poles lie strictly inside the unit circle.
153pub fn biquad_is_stable(coeffs: &[f32; 5]) -> bool {
154    biquad_pole_radius(coeffs) < 1.0
155}
156
157/// Returns `true` if every stage of a biquad cascade (`coeffs.len()` a multiple of 5) is
158/// stable.
159pub fn biquad_cascade_is_stable(coeffs: &[f32]) -> bool {
160    coeffs
161        .chunks_exact(5)
162        .all(|stage| biquad_is_stable(&[stage[0], stage[1], stage[2], stage[3], stage[4]]))
163}
164
165// ─────────────────────────────────────────────────────────────────────────────
166// Quantization, Headroom, and SQNR Analysis
167// ─────────────────────────────────────────────────────────────────────────────
168
169use crate::types::q15;
170
171/// Computes the peak frequency response gain `||H(e^{jω})||_∞` of a biquad section.
172pub fn biquad_peak_gain(coeffs: &[f32; 5], num_points: usize) -> f32 {
173    let pts = num_points.max(16);
174    let mut max_mag = 0.0f32;
175    for i in 0..=pts {
176        let f = (i as f32) * 0.5 / (pts as f32);
177        let resp = biquad_frequency_response(coeffs, f);
178        let mag = (resp.real * resp.real + resp.imag * resp.imag).sqrt();
179        if mag > max_mag {
180            max_mag = mag;
181        }
182    }
183    max_mag
184}
185
186/// Computes the L2-norm energy `||H(e^{jω})||_2` of a biquad section.
187pub fn biquad_l2_norm(coeffs: &[f32; 5], num_points: usize) -> f32 {
188    let pts = num_points.max(16);
189    let mut sum_sq = 0.0f32;
190    for i in 0..=pts {
191        let f = (i as f32) * 0.5 / (pts as f32);
192        let resp = biquad_frequency_response(coeffs, f);
193        sum_sq += resp.real * resp.real + resp.imag * resp.imag;
194    }
195    (sum_sq / (pts as f32 + 1.0)).sqrt()
196}
197
198/// Estimates required integer headroom bits and peak gain for a biquad section.
199///
200/// Returns `(headroom_bits, peak_gain)`.
201/// `headroom_bits` is the number of bits required above unity (`ceil(log2(max(1.0, peak_gain)))`).
202pub fn estimate_biquad_headroom_bits(coeffs: &[f32; 5]) -> (u8, f32) {
203    let peak = biquad_peak_gain(coeffs, 64);
204    if peak <= 1.0 {
205        (0, peak)
206    } else {
207        // Calculate ceil(log2(peak))
208        let mut bits = 0u8;
209        let mut threshold = 1.0f32;
210        while threshold < peak && bits < 14 {
211            bits += 1;
212            threshold *= 2.0;
213        }
214        (bits, peak)
215    }
216}
217
218/// Evaluates the frequency response of a Q15 quantized biquad section.
219pub fn biquad_q15_frequency_response(
220    coeffs_q15: &[q15; 5],
221    post_shift: u8,
222    freq_norm: f32,
223) -> Complex<f32> {
224    let scale = (1u32 << post_shift.min(14)) as f32;
225    let b0 = coeffs_q15[0].to_num::<f32>() * scale;
226    let b1 = coeffs_q15[1].to_num::<f32>() * scale;
227    let b2 = coeffs_q15[2].to_num::<f32>() * scale;
228    let a1 = coeffs_q15[3].to_num::<f32>() * scale;
229    let a2 = coeffs_q15[4].to_num::<f32>() * scale;
230
231    let float_coeffs = [b0, b1, b2, a1, a2];
232    biquad_frequency_response(&float_coeffs, freq_norm)
233}
234
235/// Computes the Signal-to-Quantization-Noise Ratio (SQNR in dB) between an ideal floating-point
236/// biquad cascade and its Q15 quantized equivalent.
237pub fn biquad_quantization_snr_db(
238    sos_f32: &[f32],
239    sos_q15: &[q15],
240    post_shift: u8,
241    num_points: usize,
242) -> f32 {
243    if sos_f32.len() != sos_q15.len() || sos_f32.is_empty() || sos_f32.len() % 5 != 0 {
244        return 0.0;
245    }
246
247    let num_stages = sos_f32.len() / 5;
248    let pts = num_points.max(32);
249    let mut sig_pow = 0.0f32;
250    let mut err_pow = 0.0f32;
251
252    for i in 0..=pts {
253        let f = (i as f32) * 0.5 / (pts as f32);
254
255        // Ideal response
256        let mut h_ideal = Complex::new(1.0f32, 0.0f32);
257        for stage in 0..num_stages {
258            let idx = stage * 5;
259            let section = [
260                sos_f32[idx],
261                sos_f32[idx + 1],
262                sos_f32[idx + 2],
263                sos_f32[idx + 3],
264                sos_f32[idx + 4],
265            ];
266            h_ideal = h_ideal * biquad_frequency_response(&section, f);
267        }
268
269        // Quantized response
270        let mut h_quant = Complex::new(1.0f32, 0.0f32);
271        for stage in 0..num_stages {
272            let idx = stage * 5;
273            let section = [
274                sos_q15[idx],
275                sos_q15[idx + 1],
276                sos_q15[idx + 2],
277                sos_q15[idx + 3],
278                sos_q15[idx + 4],
279            ];
280            h_quant = h_quant * biquad_q15_frequency_response(&section, post_shift, f);
281        }
282
283        let mag_sq = h_ideal.real * h_ideal.real + h_ideal.imag * h_ideal.imag;
284        let diff_re = h_ideal.real - h_quant.real;
285        let diff_im = h_ideal.imag - h_quant.imag;
286        let err_sq = diff_re * diff_re + diff_im * diff_im;
287
288        sig_pow += mag_sq;
289        err_pow += err_sq;
290    }
291
292    if err_pow < 1e-20 {
293        return 120.0; // Near perfect representation
294    }
295    10.0 * (sig_pow / err_pow).log10()
296}
297
298/// Computes the SQNR (in dB) between an ideal floating-point FIR filter and its Q15 quantized version.
299pub fn fir_quantization_snr_db(taps_f32: &[f32], taps_q15: &[q15], num_points: usize) -> f32 {
300    if taps_f32.len() != taps_q15.len() || taps_f32.is_empty() {
301        return 0.0;
302    }
303
304    let pts = num_points.max(32);
305    let mut sig_pow = 0.0f32;
306    let mut err_pow = 0.0f32;
307
308    for i in 0..=pts {
309        let f = (i as f32) * 0.5 / (pts as f32);
310        let h_ideal = fir_frequency_response(taps_f32, f);
311
312        // Quantized FIR response (scale taps by 1/32768)
313        let omega = 2.0 * core::f32::consts::PI * f;
314        let mut q_re = 0.0f32;
315        let mut q_im = 0.0f32;
316        for (k, &tap) in taps_q15.iter().enumerate() {
317            let tap_f = tap.to_num::<f32>();
318            let angle = omega * k as f32;
319            q_re += tap_f * angle.cos();
320            q_im -= tap_f * angle.sin();
321        }
322
323        let mag_sq = h_ideal.real * h_ideal.real + h_ideal.imag * h_ideal.imag;
324        let diff_re = h_ideal.real - q_re;
325        let diff_im = h_ideal.imag - q_im;
326        let err_sq = diff_re * diff_re + diff_im * diff_im;
327
328        sig_pow += mag_sq;
329        err_pow += err_sq;
330    }
331
332    if err_pow < 1e-20 {
333        return 120.0;
334    }
335    10.0 * (sig_pow / err_pow).log10()
336}