Skip to main content

math_audio_dsp/
dc_blocker.rs

1// ============================================================================
2// DC Blocker — 1-pole high-pass filter for removing DC offset
3// ============================================================================
4//
5// A simple first-order HPF that removes DC bias introduced by asymmetric
6// nonlinear processing (e.g., tube saturation, tape hysteresis).
7//
8// Transfer function: H(z) = (1 - z^-1) / (1 - R*z^-1)
9// where R = 1 - 2*pi*cutoff/sample_rate
10//
11// HARD RULES:
12// - No allocations in process methods
13// - All Vecs pre-allocated in new()
14
15/// Per-channel DC blocker using a 1-pole high-pass filter.
16///
17/// Default cutoff is 5 Hz — low enough to preserve all audible content
18/// while effectively removing DC offset from nonlinear processors.
19#[derive(Debug, Clone)]
20pub struct DcBlocker {
21    x_prev: Vec<f32>,
22    y_prev: Vec<f32>,
23    coeff: f32,
24    channels: usize,
25}
26
27impl DcBlocker {
28    /// Create a new DC blocker.
29    ///
30    /// `cutoff_hz`: Corner frequency (typically 3-10 Hz).
31    pub fn new(channels: usize, sample_rate: u32, cutoff_hz: f32) -> Self {
32        Self {
33            x_prev: vec![0.0; channels],
34            y_prev: vec![0.0; channels],
35            coeff: Self::calculate_coeff(cutoff_hz, sample_rate),
36            channels,
37        }
38    }
39
40    /// Create with default 5 Hz cutoff.
41    pub fn new_default(channels: usize, sample_rate: u32) -> Self {
42        Self::new(channels, sample_rate, 5.0)
43    }
44
45    fn calculate_coeff(cutoff_hz: f32, sample_rate: u32) -> f32 {
46        if sample_rate == 0 {
47            return 0.99999; // Maximum R = lowest cutoff, safe default
48        }
49        // R = 1 - 2*pi*fc/fs
50        // Higher R = lower cutoff = less bass attenuation
51        let r = 1.0 - (2.0 * std::f32::consts::PI * cutoff_hz / sample_rate as f32);
52        r.clamp(0.9, 0.99999)
53    }
54
55    /// Process a single sample for one channel.
56    ///
57    /// y[n] = x[n] - x[n-1] + R * y[n-1]
58    #[inline]
59    pub fn process_sample(&mut self, sample: f32, channel: usize) -> f32 {
60        let x_prev = self.x_prev[channel];
61        let y_prev = self.y_prev[channel];
62        let output = sample - x_prev + self.coeff * y_prev;
63        self.x_prev[channel] = sample;
64        self.y_prev[channel] = output;
65        output
66    }
67
68    /// Process a block of interleaved audio in-place.
69    ///
70    /// `buffer`: Interleaved samples `[ch0_f0, ch1_f0, ch0_f1, ch1_f1, ...]`
71    /// `channels`: Number of interleaved channels.
72    /// `num_frames`: Number of frames (samples per channel).
73    pub fn process_block_interleaved(
74        &mut self,
75        buffer: &mut [f32],
76        channels: usize,
77        num_frames: usize,
78    ) {
79        debug_assert_eq!(channels, self.channels);
80        for frame in 0..num_frames {
81            for ch in 0..channels {
82                let idx = frame * channels + ch;
83                buffer[idx] = self.process_sample(buffer[idx], ch);
84            }
85        }
86    }
87
88    /// Reset all filter state to zero.
89    pub fn reset(&mut self) {
90        self.x_prev.fill(0.0);
91        self.y_prev.fill(0.0);
92    }
93
94    /// Update sample rate (recalculates coefficient).
95    pub fn set_sample_rate(&mut self, sample_rate: u32, cutoff_hz: f32) {
96        self.coeff = Self::calculate_coeff(cutoff_hz, sample_rate);
97    }
98
99    /// Update channel count (re-allocates state vectors).
100    pub fn set_channels(&mut self, channels: usize) {
101        self.channels = channels;
102        self.x_prev.resize(channels, 0.0);
103        self.y_prev.resize(channels, 0.0);
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_dc_removal() {
113        let mut blocker = DcBlocker::new(1, 48000, 5.0);
114        // Feed DC offset of 1.0 for 2 seconds
115        let num_samples = 96000;
116        let mut last_output = 0.0f32;
117        for _ in 0..num_samples {
118            last_output = blocker.process_sample(1.0, 0);
119        }
120        // After convergence, DC should be effectively removed
121        assert!(
122            last_output.abs() < 0.01,
123            "DC not removed: output = {last_output}"
124        );
125    }
126
127    #[test]
128    fn test_passband_preservation() {
129        let sr = 48000;
130        let mut blocker = DcBlocker::new(1, sr, 5.0);
131        // 1 kHz sine — well above cutoff, should pass through
132        let freq = 1000.0;
133        let num_samples = 4800; // 100ms
134        let mut max_input = 0.0f32;
135        let mut max_output = 0.0f32;
136        for i in 0..num_samples {
137            let t = i as f32 / sr as f32;
138            let sample = (2.0 * std::f32::consts::PI * freq * t).sin();
139            let out = blocker.process_sample(sample, 0);
140            if i > 2400 {
141                // skip transient
142                max_input = max_input.max(sample.abs());
143                max_output = max_output.max(out.abs());
144            }
145        }
146        let ratio = max_output / max_input;
147        assert!(ratio > 0.99, "1kHz attenuated too much: ratio = {ratio}");
148    }
149
150    #[test]
151    fn test_block_processing() {
152        let mut blocker = DcBlocker::new(2, 48000, 5.0);
153        // Interleaved stereo: DC on ch0, zero on ch1
154        let mut buffer = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
155        blocker.process_block_interleaved(&mut buffer, 2, 4);
156        // Ch0 should start moving toward 0; ch1 stays 0
157        assert!(buffer[1].abs() < 1e-10); // ch1 stays zero
158        assert!(buffer[0] > 0.0); // ch0 initially positive (transient)
159    }
160
161    #[test]
162    fn test_reset() {
163        let mut blocker = DcBlocker::new(1, 48000, 5.0);
164        blocker.process_sample(1.0, 0);
165        blocker.reset();
166        // After reset, state should be clean
167        let out = blocker.process_sample(0.0, 0);
168        assert!(out.abs() < 1e-10);
169    }
170
171    #[test]
172    fn test_sample_rate_zero_no_panic() {
173        // sample_rate=0 should not panic or produce NaN
174        let mut blocker = DcBlocker::new(1, 0, 5.0);
175        let out = blocker.process_sample(1.0, 0);
176        assert!(!out.is_nan(), "NaN from sample_rate=0");
177        assert!(!out.is_infinite(), "inf from sample_rate=0");
178    }
179}