Skip to main content

br41ndmg/
polyphase.rs

1//! Polyphase sinc filter-bank construction and phase lookup.
2//!
3//! [`PolyphaseFilterBank`] precomputes one windowed-sinc coefficient set per
4//! fractional phase so resampling only needs a table lookup plus a FIR dot
5//! product per output sample. [`PolyphaseFilterParams`] controls the phase
6//! count, taps per phase, and window.
7
8use crate::ResampleError;
9use crate::sinc::normalized_sinc;
10use crate::window::{Window, window_value};
11
12pub const DEFAULT_PHASES: usize = 256;
13pub const DEFAULT_TAPS_PER_PHASE: usize = 63;
14pub const DEFAULT_WINDOW: Window = Window::Blackman;
15
16const DOWNSAMPLE_CUTOFF_MARGIN: f64 = 0.95;
17
18/// Tunable polyphase filter parameters.
19///
20/// - `phases`: number of precomputed fractional phases (higher = finer
21///   fractional-delay resolution at the cost of a larger coefficient table).
22/// - `taps_per_phase`: must be **odd** and non-zero; larger values give a
23///   sharper filter (more stopband attenuation) and longer latency.
24/// - `window`: the window applied to the sinc kernel.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct PolyphaseFilterParams {
27    pub phases: usize,
28    pub taps_per_phase: usize,
29    pub window: Window,
30}
31
32impl Default for PolyphaseFilterParams {
33    fn default() -> Self {
34        Self {
35            phases: DEFAULT_PHASES,
36            taps_per_phase: DEFAULT_TAPS_PER_PHASE,
37            window: DEFAULT_WINDOW,
38        }
39    }
40}
41
42impl PolyphaseFilterParams {
43    pub fn validate(&self) -> Result<(), ResampleError> {
44        if self.phases == 0 {
45            return Err(ResampleError::InvalidFilterConfig(
46                "phase count must be non-zero".into(),
47            ));
48        }
49
50        if self.taps_per_phase == 0 || self.taps_per_phase.is_multiple_of(2) {
51            return Err(ResampleError::InvalidFilterConfig(
52                "tap count must be odd and non-zero".into(),
53            ));
54        }
55
56        if let Window::Kaiser { beta } = self.window
57            && (!beta.is_finite() || beta < 0.0)
58        {
59            return Err(ResampleError::InvalidFilterConfig(
60                "kaiser beta must be non-negative and finite".into(),
61            ));
62        }
63
64        Ok(())
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct PolyphaseFilterBank {
70    phases: usize,
71    taps_per_phase: usize,
72    radius: usize,
73    cutoff: f64,
74    window: Window,
75    coeffs: Vec<f32>,
76}
77
78impl PolyphaseFilterBank {
79    pub fn new(ratio: f64) -> Self {
80        Self::try_with_params(ratio, PolyphaseFilterParams::default())
81            .expect("invalid polyphase filter configuration")
82    }
83
84    pub fn with_config(ratio: f64, phases: usize, taps_per_phase: usize, window: Window) -> Self {
85        Self::try_with_config(ratio, phases, taps_per_phase, window)
86            .expect("invalid polyphase filter configuration")
87    }
88
89    pub fn try_with_config(
90        ratio: f64,
91        phases: usize,
92        taps_per_phase: usize,
93        window: Window,
94    ) -> Result<Self, ResampleError> {
95        Self::try_with_params(
96            ratio,
97            PolyphaseFilterParams {
98                phases,
99                taps_per_phase,
100                window,
101            },
102        )
103    }
104
105    pub fn try_with_params(
106        ratio: f64,
107        params: PolyphaseFilterParams,
108    ) -> Result<Self, ResampleError> {
109        if !ratio.is_finite() || ratio <= 0.0 {
110            return Err(ResampleError::InvalidRatio);
111        }
112
113        params.validate()?;
114
115        Ok(Self::build(ratio, params))
116    }
117
118    fn build(ratio: f64, params: PolyphaseFilterParams) -> Self {
119        let cutoff = if ratio < 1.0 {
120            0.5 * ratio * DOWNSAMPLE_CUTOFF_MARGIN
121        } else {
122            0.5
123        };
124        let radius = params.taps_per_phase / 2;
125        let center = radius as f64;
126        let mut coeffs = Vec::with_capacity(params.phases * params.taps_per_phase);
127
128        for phase in 0..params.phases {
129            let frac = phase as f64 / params.phases as f64;
130            let mut phase_coeffs = Vec::with_capacity(params.taps_per_phase);
131            let mut sum = 0.0;
132
133            for tap in 0..params.taps_per_phase {
134                let x = tap as f64 - center - frac;
135                let window_t = if radius == 0 {
136                    0.0
137                } else {
138                    (x / center).clamp(-1.0, 1.0)
139                };
140                let coeff = normalized_sinc(x, cutoff) * window_value(params.window, window_t);
141                phase_coeffs.push(coeff);
142                sum += coeff;
143            }
144
145            if sum.abs() > f64::EPSILON {
146                let inv = 1.0 / sum;
147                for coeff in &mut phase_coeffs {
148                    *coeff *= inv;
149                }
150            }
151
152            coeffs.extend(phase_coeffs.into_iter().map(|coeff| coeff as f32));
153        }
154
155        Self {
156            phases: params.phases,
157            taps_per_phase: params.taps_per_phase,
158            radius,
159            cutoff,
160            window: params.window,
161            coeffs,
162        }
163    }
164
165    pub fn params(&self) -> PolyphaseFilterParams {
166        PolyphaseFilterParams {
167            phases: self.phases,
168            taps_per_phase: self.taps_per_phase,
169            window: self.window,
170        }
171    }
172
173    pub fn phases(&self) -> usize {
174        self.phases
175    }
176
177    pub fn taps_per_phase(&self) -> usize {
178        self.taps_per_phase
179    }
180
181    pub fn radius(&self) -> usize {
182        self.radius
183    }
184
185    pub fn cutoff(&self) -> f64 {
186        self.cutoff
187    }
188
189    pub fn window(&self) -> Window {
190        self.window
191    }
192
193    pub fn left_offset(&self) -> isize {
194        -(self.radius as isize)
195    }
196
197    pub fn phase_for(&self, frac: f64) -> &[f32] {
198        let clamped = frac.clamp(0.0, 1.0);
199        let phase = ((clamped * self.phases as f64).round() as usize).min(self.phases - 1);
200        let start = phase * self.taps_per_phase;
201        let end = start + self.taps_per_phase;
202        &self.coeffs[start..end]
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn polyphase_bank_builds_normalized_phases() {
212        let bank = PolyphaseFilterBank::new(44_100.0 / 48_000.0);
213
214        assert_eq!(bank.taps_per_phase(), DEFAULT_TAPS_PER_PHASE);
215        assert_eq!(bank.radius(), DEFAULT_TAPS_PER_PHASE / 2);
216
217        for phase in 0..bank.phases() {
218            let coeffs =
219                &bank.coeffs[phase * bank.taps_per_phase()..(phase + 1) * bank.taps_per_phase()];
220            let sum: f32 = coeffs.iter().sum();
221            assert!((sum - 1.0).abs() <= 1.0e-4);
222        }
223    }
224
225    #[test]
226    fn polyphase_params_reject_invalid_values() {
227        let error = PolyphaseFilterParams {
228            phases: 0,
229            ..PolyphaseFilterParams::default()
230        }
231        .validate()
232        .unwrap_err();
233        assert!(error.to_string().contains("phase count"));
234
235        let error = PolyphaseFilterParams {
236            taps_per_phase: 32,
237            ..PolyphaseFilterParams::default()
238        }
239        .validate()
240        .unwrap_err();
241        assert!(error.to_string().contains("tap count"));
242    }
243}