goertzel.rs (6216B)
1 //! Goertzel resonator for AFSK tone detection 2 3 #[derive(Clone, Copy, Debug)] 4 pub struct GoertzelFilter { 5 coeff: f32, 6 s0: f32, 7 s1: f32, 8 s2: f32, 9 energy: f32, 10 } 11 12 impl GoertzelFilter { 13 /// Create a new Goertzel filter with a pre-computed coefficient 14 /// 15 /// The coefficient should be `2cos(2πf / sample_rate)`. We can't easily compute the 16 /// coefficient inside this method because it's `const`. 17 pub const fn new_with_coeff(coeff: f32) -> Self { 18 Self { 19 coeff, 20 s0: 0.0, 21 s1: 0.0, 22 s2: 0.0, 23 energy: 0.0, 24 } 25 } 26 27 const MARK_COEFF: f32 = 1.961_69; 28 const SPACE_COEFF: f32 = 1.872_207_2; 29 30 pub const fn mark() -> Self { 31 Self::new_with_coeff(Self::MARK_COEFF) 32 } 33 34 pub const fn space() -> Self { 35 Self::new_with_coeff(Self::SPACE_COEFF) 36 } 37 38 pub fn update(&mut self, x: f32) { 39 self.s0 = x + self.coeff * self.s1 - self.s2; 40 self.s2 = self.s1; 41 self.s1 = self.s0; 42 self.energy = self.s1 * self.s1 + self.s2 * self.s2 - self.coeff * self.s1 * self.s2; 43 } 44 45 pub fn energy(&self) -> f32 { 46 self.energy 47 } 48 49 #[cfg(test)] 50 pub fn coeff(&self) -> f32 { 51 self.coeff 52 } 53 54 #[cfg(test)] 55 pub fn s0(&self) -> f32 { 56 self.s0 57 } 58 59 #[cfg(test)] 60 pub fn s1(&self) -> f32 { 61 self.s1 62 } 63 64 #[cfg(test)] 65 pub fn s2(&self) -> f32 { 66 self.s2 67 } 68 69 pub fn reset(&mut self) { 70 self.s0 = 0.0; 71 self.s1 = 0.0; 72 self.s2 = 0.0; 73 self.energy = 0.0; 74 } 75 } 76 77 #[cfg(test)] 78 mod tests { 79 use alloc::vec::Vec; 80 use core::f64::consts::TAU; 81 82 use rand::rngs::StdRng; 83 use rand::SeedableRng; 84 use rand_distr::{Distribution, Normal}; 85 86 use super::*; 87 use crate::constants::{MARK_HZ, SAMPLE_RATE_HZ, SPACE_HZ}; 88 89 /// Generate a phase-continuous tone at the given frequency 90 fn generate_tone(freq: f32, samples: usize) -> Vec<f32> { 91 let sr = SAMPLE_RATE_HZ as f64; 92 let freq = freq as f64; 93 let dt = 1.0 / sr; 94 (0..samples) 95 .map(|i| (TAU * freq * dt * i as f64).sin()) 96 .map(|v| v as f32) 97 .collect() 98 } 99 100 /// Add Gaussian noise at the given SNR, assuming a full-scale signal 101 fn add_noise(samples: &[f32], snr_db: f32, seed: u64) -> Vec<f32> { 102 let signal_power = 0.5; 103 let noise_std = f32::sqrt(signal_power / 10f32.powf(snr_db / 10.0)); 104 let normal = Normal::new(0.0, noise_std).unwrap(); 105 let mut rng = StdRng::seed_from_u64(seed); 106 samples 107 .iter() 108 .zip(normal.sample_iter(&mut rng)) 109 .map(|(s, n)| s + n) 110 .collect() 111 } 112 113 #[test] 114 fn filter_reset_clears_states() { 115 let mut filter = GoertzelFilter::mark(); 116 117 // Feed some samples so energy builds up 118 for i in 0..32 { 119 let sample = (TAU * 1200.0 * i as f64 / 38460.0).sin() as f32; 120 filter.update(sample); 121 } 122 123 let energy_before = filter.energy(); 124 assert!( 125 energy_before > 0.0, 126 "Filter should have non-zero energy after feeding samples" 127 ); 128 129 filter.reset(); 130 131 assert_eq!(filter.s0(), 0.0, "s0 should be reset to 0.0"); 132 assert_eq!(filter.s1(), 0.0, "s1 should be reset to 0.0"); 133 assert_eq!(filter.s2(), 0.0, "s2 should be reset to 0.0"); 134 assert_eq!(filter.energy(), 0.0, "energy should be reset to 0.0"); 135 } 136 137 #[test] 138 fn mark_tone_gives_higher_mark_energy() { 139 let mut mark = GoertzelFilter::mark(); 140 let mut space = GoertzelFilter::space(); 141 142 let samples = generate_tone(MARK_HZ, 32); 143 for &sample in &samples { 144 mark.update(sample); 145 space.update(sample); 146 } 147 148 assert!( 149 mark.energy() > space.energy(), 150 "Mark resonator should have higher energy than space resonator for 1200 Hz tone. \n\ 151 mark_energy={}, space_energy={}", 152 mark.energy(), 153 space.energy() 154 ); 155 } 156 157 #[test] 158 fn space_tone_gives_higher_space_energy() { 159 let mut mark = GoertzelFilter::mark(); 160 let mut space = GoertzelFilter::space(); 161 162 let samples = generate_tone(SPACE_HZ, 32); 163 for &sample in &samples { 164 mark.update(sample); 165 space.update(sample); 166 } 167 168 assert!( 169 space.energy() > mark.energy(), 170 "Space resonator should have higher energy than mark resonator for 2200 Hz tone. \n\ 171 mark_energy={}, space_energy={}", 172 mark.energy(), 173 space.energy() 174 ); 175 } 176 177 #[test] 178 fn mark_tone_noisy_gives_higher_mark_energy() { 179 // Verify the filter discriminates mark tone even with noise. 180 let snr_db = 15.0; 181 let mut mark = GoertzelFilter::mark(); 182 let mut space = GoertzelFilter::space(); 183 184 let samples = generate_tone(MARK_HZ, 32); 185 let noisy = add_noise(&samples, snr_db, 42); 186 for &sample in &noisy { 187 mark.update(sample); 188 space.update(sample); 189 } 190 191 assert!( 192 mark.energy() > space.energy(), 193 "Mark resonator should still exceed space at {} dB SNR. \n\ 194 mark_energy={}, space_energy={}", 195 snr_db, 196 mark.energy(), 197 space.energy() 198 ); 199 } 200 201 #[test] 202 fn space_tone_noisy_gives_higher_space_energy() { 203 // Verify the filter discriminates space tone even with noise. 204 let snr_db = 15.0; 205 let mut mark = GoertzelFilter::mark(); 206 let mut space = GoertzelFilter::space(); 207 208 let samples = generate_tone(SPACE_HZ, 32); 209 let noisy = add_noise(&samples, snr_db, 42); 210 for &sample in &noisy { 211 mark.update(sample); 212 space.update(sample); 213 } 214 215 assert!( 216 space.energy() > mark.energy(), 217 "Space resonator should still exceed mark at {} dB SNR. \n\ 218 mark_energy={}, space_energy={}", 219 snr_db, 220 mark.energy(), 221 space.energy() 222 ); 223 } 224 }