Skip to main content

vector_calls/
declick.rs

1//! Removes the click a microphone makes when its cable is pulled or pushed in: a
2//! sample-to-sample slam that speech never produces, followed by a short offset
3//! tail. Runs a couple of milliseconds behind the input so the samples just
4//! before the slam can go too.
5
6/// Full-scale fraction of a sample-to-sample jump that speech never reaches.
7const JUMP: i32 = 8000;
8/// Anything this close to the rail is a slam, whatever came before it.
9const RAIL: i32 = 30000;
10/// The jump must also dwarf the recent loudness, so a shout is not a click.
11const RATIO: f32 = 8.0;
12const PRE_MS: u32 = 2;
13const HOLD_MS: u32 = 20;
14const FADE_MS: u32 = 4;
15const ENV_MS: f32 = 100.0;
16
17pub struct Declicker {
18    delay: Vec<i16>,
19    head: usize,
20    prev: i32,
21    env: f32,
22    alpha: f32,
23    /// One-pole high-pass state: the slam leaves an offset that decays for tens of
24    /// milliseconds, and a microphone can carry one of its own.
25    dc_x1: f32,
26    dc_y1: f32,
27    dc_r: f32,
28    hold: usize,
29    fade: usize,
30    mute_left: usize,
31    fade_left: usize,
32    triggers: u32,
33}
34
35impl Declicker {
36    pub fn new(rate: u32) -> Self {
37        let pre = (rate * PRE_MS / 1000).max(1) as usize;
38        Self {
39            delay: vec![0; pre],
40            head: 0,
41            prev: 0,
42            env: 300.0,
43            alpha: 1.0 / (ENV_MS / 1000.0 * rate as f32),
44            dc_x1: 0.0,
45            dc_y1: 0.0,
46            dc_r: 1.0 - 2.0 * std::f32::consts::PI * 20.0 / rate as f32,
47            hold: (rate * HOLD_MS / 1000) as usize,
48            fade: (rate * FADE_MS / 1000).max(1) as usize,
49            mute_left: 0,
50            fade_left: 0,
51            triggers: 0,
52        }
53    }
54
55    /// How many clicks have been cut so far.
56    pub fn triggers(&self) -> u32 {
57        self.triggers
58    }
59
60    /// Cleans the block in place. Output lags input by `PRE_MS`.
61    pub fn process(&mut self, block: &mut [i16]) {
62        for x in block.iter_mut() {
63            // Block DC first, so an offset tail cannot outlive the mute.
64            let xin = *x as f32;
65            let y = xin - self.dc_x1 + self.dc_r * self.dc_y1;
66            self.dc_x1 = xin;
67            self.dc_y1 = y;
68            *x = y.clamp(-32768.0, 32767.0) as i16;
69
70            let now = *x as i32;
71            let jump = (now - self.prev).abs();
72            self.prev = now;
73            // A slam on the rail, or a jump that dwarfs the recent loudness. Speech
74            // that clips still rises sample by sample; a cable does not.
75            let slam = now.abs() > RAIL && jump > JUMP;
76            let spike = jump > JUMP && (now.abs() as f32) > RATIO * self.env;
77            if self.mute_left == 0 && (slam || spike) {
78                self.triggers += 1;
79                self.mute_left = self.hold;
80                self.delay.iter_mut().for_each(|d| *d = 0);
81            }
82            // The sample leaving the delay line is this position's output.
83            let out = self.delay[self.head];
84            let mut input = *x;
85            if self.mute_left > 0 {
86                input = 0;
87                self.mute_left -= 1;
88                if self.mute_left == 0 {
89                    self.fade_left = self.fade;
90                }
91            } else if self.fade_left > 0 {
92                input = ((*x as f32) * (1.0 - self.fade_left as f32 / self.fade as f32)) as i16;
93                self.fade_left -= 1;
94            } else {
95                let f = now as f32;
96                self.env = ((1.0 - self.alpha) * self.env * self.env + self.alpha * f * f).sqrt();
97            }
98            self.delay[self.head] = input;
99            self.head = (self.head + 1) % self.delay.len();
100            *x = out;
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    /// `DECLICK_WAV=/path/to/note.wav cargo test declick::real -- --ignored --nocapture`:
110    /// prints where the clicks were cut in a real 16-bit recording.
111    #[test]
112    #[ignore]
113    fn real_recording() {
114        let Ok(path) = std::env::var("DECLICK_WAV") else { return };
115        let mut reader = hound::WavReader::open(&path).unwrap();
116        let spec = reader.spec();
117        let ch = spec.channels as usize;
118        let mut pcm: Vec<i16> = reader.samples::<i16>().map(|s| s.unwrap()).step_by(ch).collect();
119        let before: Vec<i16> = pcm.clone();
120        let mut d = Declicker::new(spec.sample_rate);
121        let mut last = 0u32;
122        let mut cuts = Vec::new();
123        for (i, block) in pcm.chunks_mut(spec.sample_rate as usize / 100).enumerate() {
124            d.process(block);
125            if d.triggers() != last {
126                last = d.triggers();
127                cuts.push(i as f32 / 100.0);
128            }
129        }
130        let peak_before = before.iter().map(|s| s.abs()).max().unwrap();
131        let peak_after = pcm.iter().map(|s| s.abs()).max().unwrap();
132        eprintln!("{path}: {} cut(s) at {:?} s; peak {peak_before} -> {peak_after}", d.triggers(), cuts);
133    }
134
135    fn voice(rate: u32, secs: f32) -> Vec<i16> {
136        // A buzz in bursts, like the AGC test: rich in harmonics, moderate level.
137        let mut phase = 0f32;
138        (0..(rate as f32 * secs) as usize)
139            .map(|i| {
140                phase += 2.0 * std::f32::consts::PI * 140.0 / rate as f32;
141                let on = (i / (rate as usize / 5)) % 2 == 0;
142                let v: f32 = (1..=8).map(|h| (phase * h as f32).sin() / h as f32).sum();
143                if on { (v * 6000.0) as i16 } else { 0 }
144            })
145            .collect()
146    }
147
148    #[test]
149    fn a_cable_slam_is_cut_and_speech_is_not() {
150        let rate = 16_000;
151        let clean = voice(rate, 2.0);
152        let mut hit = clean.clone();
153        // The rip: 1.5 ms on the rail, then an offset that decays over 30 ms.
154        let at = rate as usize;
155        for k in 0..24 {
156            hit[at + k] = -32766;
157        }
158        for k in 24..(24 + 480) {
159            hit[at + k] = (20000.0 * (-(k as f32 - 24.0) / 160.0).exp()) as i16;
160        }
161        let mut d = Declicker::new(rate);
162        d.process(&mut hit);
163        let lag = (rate * PRE_MS / 1000) as usize;
164        // The slam and its tail are gone.
165        let region = &hit[at - 16 + lag..at + 24 + 320 + lag];
166        assert!(region.iter().all(|s| s.abs() < 800), "residue {}", region.iter().map(|s| s.abs()).max().unwrap());
167        assert_eq!(d.triggers(), 1);
168        // Clean voice never triggers, and keeps its loudness through the high-pass.
169        let mut pristine = clean.clone();
170        let mut d2 = Declicker::new(rate);
171        d2.process(&mut pristine);
172        assert_eq!(d2.triggers(), 0);
173        let rms = |v: &[i16]| (v.iter().map(|s| (*s as f64).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
174        let ratio = rms(&pristine[lag..]) / rms(&clean[..clean.len() - lag]);
175        assert!((0.97..1.03).contains(&ratio), "loudness ratio {ratio}");
176    }
177}