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