arduino-tnc

git clone https://git.bradleygannon.com/arduino-tnc.git
Log | Files | Refs

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 }