arduino-tnc

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

demodulator.rs (16860B)


      1 //! Bell 202 demodulator — produces byte-aligned NRZI-decoded output from raw AFSK samples
      2 //!
      3 //! Uses two [`GoertzelFilter`] resonators (mark @ 1200 Hz, space @ 2200 Hz), updates them
      4 //! per-sample via [`Bell202Demodulator::sample_update`], then calls
      5 //! [`Bell202Demodulator::symbol_update`] once per symbol period to get NRZI-decoded,
      6 //! HDLC-flag-aware byte output.
      7 //!
      8 //! All frequency/timing parameters are defined in [`super::constants`].
      9 
     10 use crate::goertzel::GoertzelFilter;
     11 
     12 #[derive(PartialEq, Clone, Copy, Debug)]
     13 pub enum Symbol {
     14     Mark,
     15     Space,
     16 }
     17 
     18 pub struct Bell202Demodulator {
     19     mark: GoertzelFilter,
     20     space: GoertzelFilter,
     21     symbol_estimate: Symbol,
     22     previous_symbol_estimate: Symbol,
     23     symbol_phase: u8,
     24     byte: u8,
     25     byte_phase: u8,
     26     output: Option<u8>,
     27 }
     28 
     29 impl Bell202Demodulator {
     30     pub const fn new() -> Self {
     31         Self {
     32             mark: GoertzelFilter::mark(),
     33             space: GoertzelFilter::space(),
     34             symbol_estimate: Symbol::Space,
     35             previous_symbol_estimate: Symbol::Space,
     36             symbol_phase: 0,
     37             byte: 0,
     38             byte_phase: 0,
     39             output: None,
     40         }
     41     }
     42 
     43     pub fn sample_update(&mut self, sample: f32) {
     44         self.mark.update(sample);
     45         self.space.update(sample);
     46     }
     47 
     48     pub fn symbol_update(&mut self) -> Option<u8> {
     49         let mark_energy = self.mark.energy();
     50         let space_energy = self.space.energy();
     51         self.previous_symbol_estimate = self.symbol_estimate;
     52         self.symbol_estimate = if mark_energy > space_energy {
     53             Symbol::Mark
     54         } else {
     55             Symbol::Space
     56         };
     57 
     58         self.byte <<= 1;
     59         if self.symbol_estimate == self.previous_symbol_estimate {
     60             self.byte += 1;
     61         }
     62 
     63         self.byte_phase = (self.byte_phase + 1) % 8;
     64         if self.byte == 0x7e {
     65             self.byte_phase = 0;
     66         }
     67 
     68         if self.byte_phase == 0 {
     69             self.output = Some(self.byte);
     70         } else {
     71             self.output = None;
     72         }
     73 
     74         self.mark.reset();
     75         self.space.reset();
     76 
     77         self.output
     78     }
     79 
     80     pub fn reset(&mut self) {
     81         self.mark.reset();
     82         self.space.reset();
     83         self.symbol_estimate = Symbol::Space;
     84         self.previous_symbol_estimate = Symbol::Space;
     85         self.symbol_phase = 0;
     86         self.byte = 0;
     87         self.byte_phase = 0;
     88         self.output = None;
     89     }
     90 
     91     pub fn get_output(&self) -> Option<u8> {
     92         self.output
     93     }
     94 
     95     #[cfg(test)]
     96     pub fn byte_phase(&self) -> u8 {
     97         self.byte_phase
     98     }
     99 }
    100 
    101 #[cfg(test)]
    102 mod tests {
    103     use alloc::vec;
    104     use alloc::vec::Vec;
    105     use core::iter;
    106 
    107     use rand::rngs::StdRng;
    108     use rand::{Rng, SeedableRng};
    109     use rand_distr::{Distribution, Normal};
    110 
    111     use super::*;
    112     use crate::constants::SAMPLES_PER_SYMBOL;
    113     use crate::test_utils::generate_audio_phase_continuous;
    114 
    115     /// Add Gaussian noise at the given SNR
    116     fn add_noise(samples: &[f32], snr_db: f32, seed: u64) -> Vec<f32> {
    117         let signal_power = 0.5;
    118         let noise_std = f32::sqrt(signal_power / 10f32.powf(snr_db / 10.0));
    119         let normal = Normal::new(0.0, noise_std).unwrap();
    120         let mut rng = StdRng::seed_from_u64(seed);
    121         samples
    122             .iter()
    123             .zip(normal.sample_iter(&mut rng))
    124             .map(|(s, n)| s + n)
    125             .collect()
    126     }
    127 
    128     /// Generate random symbols with the given seed
    129     fn generate_random_symbols(seed: u64, n: usize) -> Vec<Symbol> {
    130         let mut rng = StdRng::seed_from_u64(seed);
    131         (0..n)
    132             .map(|_| {
    133                 if rng.gen::<u8>() % 2 == 0 {
    134                     Symbol::Mark
    135                 } else {
    136                     Symbol::Space
    137                 }
    138             })
    139             .collect()
    140     }
    141 
    142     #[test]
    143     fn mark_only_tone_yields_mark_decisions() {
    144         let mut demod = Bell202Demodulator::new();
    145         let symbols = vec![Symbol::Mark; 64];
    146         let audio = generate_audio_phase_continuous(&symbols);
    147         let sps = SAMPLES_PER_SYMBOL as usize;
    148 
    149         let mut mark_count = 0;
    150         let mut total_decisions = 0;
    151 
    152         for (i, &sample) in audio.iter().enumerate() {
    153             demod.sample_update(sample);
    154             if i > 0 && i % sps == 0 {
    155                 demod.symbol_update();
    156                 if demod.symbol_estimate == Symbol::Mark {
    157                     mark_count += 1;
    158                 }
    159                 total_decisions += 1;
    160             }
    161         }
    162 
    163         assert_eq!(
    164             mark_count, total_decisions,
    165             "All {} symbol decisions should be Mark, but only {} were",
    166             total_decisions, mark_count
    167         );
    168     }
    169 
    170     #[test]
    171     fn space_only_tone_yields_space_decisions() {
    172         let mut demod = Bell202Demodulator::new();
    173         let symbols = vec![Symbol::Space; 64];
    174         let audio = generate_audio_phase_continuous(&symbols);
    175         let sps = SAMPLES_PER_SYMBOL as usize;
    176 
    177         let mut space_count = 0;
    178         let mut total_decisions = 0;
    179 
    180         for (i, &sample) in audio.iter().enumerate() {
    181             demod.sample_update(sample);
    182             if i > 0 && i % sps == 0 {
    183                 demod.symbol_update();
    184                 if demod.symbol_estimate == Symbol::Space {
    185                     space_count += 1;
    186                 }
    187                 total_decisions += 1;
    188             }
    189         }
    190 
    191         assert_eq!(
    192             space_count, total_decisions,
    193             "All {} symbol decisions should be Space, but only {} were",
    194             total_decisions, space_count
    195         );
    196     }
    197 
    198     #[test]
    199     fn phase_shift_resets_on_tone_transition() {
    200         let mut demod = Bell202Demodulator::new();
    201         let sps = SAMPLES_PER_SYMBOL as usize;
    202 
    203         let mut all_symbols = vec![Symbol::Space; 10];
    204         all_symbols.extend(iter::repeat(Symbol::Mark).take(10));
    205         let audio = generate_audio_phase_continuous(&all_symbols);
    206 
    207         let mut prev_est = Symbol::Space;
    208         let mut transitions: usize = 0;
    209 
    210         for (i, &sample) in audio.iter().enumerate() {
    211             demod.sample_update(sample);
    212             if i > 0 && i % sps == 0 {
    213                 demod.symbol_update();
    214                 let current_est = demod.symbol_estimate;
    215                 if current_est != prev_est {
    216                     transitions += 1;
    217                 }
    218                 prev_est = current_est;
    219             }
    220         }
    221 
    222         assert!(
    223             transitions == 1,
    224             "Expected exactly one tone transition (Space→Mark), got {}",
    225             transitions
    226         );
    227     }
    228 
    229     #[test]
    230     fn byte_phase_advances_correctly() {
    231         // Verify that the demodulator's byte_phase advances correctly through byte boundaries, and
    232         // that flag bytes reset it while non-flag bytes complete two full cycles.
    233         //
    234         // Helper: run sample_update for audio, then call symbol_update for any remaining symbol
    235         // that hasn't been processed yet (the loop's modulo check misses the last symbol
    236         // boundary).
    237         macro_rules! feed {
    238             ($demod:expr, $audio:expr, $sps:expr) => {
    239                 for (i, &sample) in $audio.iter().enumerate() {
    240                     $demod.sample_update(sample);
    241                     if i > 0 && i % $sps == 0 {
    242                         $demod.symbol_update();
    243                     }
    244                 }
    245                 // Flush last symbol
    246                 $demod.symbol_update();
    247             };
    248         }
    249 
    250         let mut demod = Bell202Demodulator::new();
    251         let sps = SAMPLES_PER_SYMBOL as usize;
    252 
    253         // --- Phase 1: 16 Space symbols (0xff 0xff). byte_phase wraps 0→7 twice without resetting
    254         // (0xff ≠ 0x7e).
    255         let symbols = vec![Symbol::Space; 16];
    256         let audio = generate_audio_phase_continuous(&symbols);
    257         feed!(demod, audio, sps);
    258         // get_output() returns the last completed byte (0xff), not None.
    259         assert_eq!(
    260             demod.byte_phase(),
    261             0,
    262             "byte_phase should wrap to 0 after 2 bytes"
    263         );
    264 
    265         // --- Phase 2: 8 Space symbols. byte_phase wraps 0→0 (full byte, 0xff again).
    266         let symbols = vec![Symbol::Space; 8];
    267         let audio = generate_audio_phase_continuous(&symbols);
    268         feed!(demod, audio, sps);
    269         assert_eq!(demod.byte_phase(), 0, "byte_phase wraps to 0 after 1 byte");
    270 
    271         // --- Phase 3: 4 Space symbols. byte_phase advances 0→4.
    272         let symbols = vec![Symbol::Space; 4];
    273         let audio = generate_audio_phase_continuous(&symbols);
    274         feed!(demod, audio, sps);
    275         assert_eq!(
    276             demod.byte_phase(),
    277             4,
    278             "byte_phase should advance to 4 (0.5 byte)"
    279         );
    280 
    281         // --- Phase 4: 4 more Space symbols completes a byte. byte_phase wraps 4→0. get_output()
    282         // returns 0xff again.
    283         let symbols = vec![Symbol::Space; 4];
    284         let audio = generate_audio_phase_continuous(&symbols);
    285         feed!(demod, audio, sps);
    286         assert_eq!(
    287             demod.byte_phase(),
    288             0,
    289             "byte_phase wraps to 0 after completing byte"
    290         );
    291     }
    292 
    293     #[test]
    294     fn flag_byte_detection() {
    295         // Verify flag byte 0x7e is detected correctly with block-based Goertzel (filters reset at
    296         // symbol boundaries)
    297         //
    298         // 0x7e = 01111110 → NRZI: flip(S→M), keep(M)×6, flip(M→S) → 7 Marks + 1 Space
    299         let mut demod = Bell202Demodulator::new();
    300         let nrzi = vec![
    301             Symbol::Mark,
    302             Symbol::Mark,
    303             Symbol::Mark,
    304             Symbol::Mark,
    305             Symbol::Mark,
    306             Symbol::Mark,
    307             Symbol::Mark,
    308             Symbol::Space,
    309         ];
    310         let audio = generate_audio_phase_continuous(&nrzi);
    311         let sps = SAMPLES_PER_SYMBOL as usize;
    312 
    313         for (i, &sample) in audio.iter().enumerate() {
    314             demod.sample_update(sample);
    315             if i > 0 && i % sps == 0 {
    316                 demod.symbol_update();
    317             }
    318         }
    319         demod.symbol_update();
    320 
    321         assert_eq!(
    322             demod.get_output(),
    323             Some(0x7e),
    324             "Flag byte 0x7e should be detected correctly with block-based \
    325              Goertzel (filters reset at symbol boundaries)"
    326         );
    327     }
    328 
    329     #[test]
    330     fn consecutive_flag_bytes() {
    331         // Verify two consecutive 0x7e flag bytes are both detected
    332         //
    333         // NRZI sequence: M×7, S, M×7, S (16 symbols); The demodulator's NRZI state carries over;
    334         // after the first 0x7e, prev=Space. The second flag starts with S→M (flip, data bit=0).
    335         let mut demod = Bell202Demodulator::new();
    336         let nrzi = vec![
    337             Symbol::Mark,
    338             Symbol::Mark,
    339             Symbol::Mark,
    340             Symbol::Mark,
    341             Symbol::Mark,
    342             Symbol::Mark,
    343             Symbol::Mark,
    344             Symbol::Space,
    345             Symbol::Mark,
    346             Symbol::Mark,
    347             Symbol::Mark,
    348             Symbol::Mark,
    349             Symbol::Mark,
    350             Symbol::Mark,
    351             Symbol::Mark,
    352             Symbol::Space,
    353         ];
    354         let audio = generate_audio_phase_continuous(&nrzi);
    355         let sps = SAMPLES_PER_SYMBOL as usize;
    356 
    357         let mut outputs = Vec::new();
    358         for (i, &sample) in audio.iter().enumerate() {
    359             demod.sample_update(sample);
    360             if i > 0 && i % sps == 0 {
    361                 demod.symbol_update();
    362                 if let Some(byte) = demod.get_output() {
    363                     outputs.push(byte);
    364                 }
    365             }
    366         }
    367         demod.symbol_update();
    368         if let Some(byte) = demod.get_output() {
    369             outputs.push(byte);
    370         }
    371 
    372         assert_eq!(
    373             outputs,
    374             vec![0x7e, 0x7e],
    375             "Both consecutive 0x7e flag bytes should be detected. Got {:?}",
    376             outputs
    377         );
    378     }
    379 
    380     #[test]
    381     fn mixed_bytes() {
    382         // Verify demodulator handles a realistic mix of flag and data bytes.
    383         //
    384         // Sequence (NRZI, state carries over between bytes):
    385         //   0x7e: M×7, S (flag)
    386         //   0xff: S×8 (all 1s → same NRZI state)
    387         //   0x7e: S→M, M×6, M→S (flag)
    388         //   0x00: M,S,M,S,M,S,M,S (alternating, from S state)
    389         let mut demod = Bell202Demodulator::new();
    390         let nrzi = vec![
    391             // 0x7e flag: M×7, S
    392             Symbol::Mark,
    393             Symbol::Mark,
    394             Symbol::Mark,
    395             Symbol::Mark,
    396             Symbol::Mark,
    397             Symbol::Mark,
    398             Symbol::Mark,
    399             Symbol::Space,
    400             // 0xff: all S (same state) → S×8
    401             Symbol::Space,
    402             Symbol::Space,
    403             Symbol::Space,
    404             Symbol::Space,
    405             Symbol::Space,
    406             Symbol::Space,
    407             Symbol::Space,
    408             Symbol::Space,
    409             // 0x7e flag: S→M, M×6, M→S
    410             Symbol::Mark,
    411             Symbol::Mark,
    412             Symbol::Mark,
    413             Symbol::Mark,
    414             Symbol::Mark,
    415             Symbol::Mark,
    416             Symbol::Mark,
    417             Symbol::Space,
    418             // 0x00: all 0s → flip every bit from S state → M,S,M,S,M,S,M,S
    419             Symbol::Mark,
    420             Symbol::Space,
    421             Symbol::Mark,
    422             Symbol::Space,
    423             Symbol::Mark,
    424             Symbol::Space,
    425             Symbol::Mark,
    426             Symbol::Space,
    427         ];
    428         let audio = generate_audio_phase_continuous(&nrzi);
    429         let sps = SAMPLES_PER_SYMBOL as usize;
    430 
    431         let mut outputs = Vec::new();
    432         for (i, &sample) in audio.iter().enumerate() {
    433             demod.sample_update(sample);
    434             if i > 0 && i % sps == 0 {
    435                 demod.symbol_update();
    436                 if let Some(byte) = demod.get_output() {
    437                     outputs.push(byte);
    438                 }
    439             }
    440         }
    441         demod.symbol_update();
    442         if let Some(byte) = demod.get_output() {
    443             outputs.push(byte);
    444         }
    445 
    446         assert_eq!(
    447             outputs,
    448             vec![0x7e, 0xff, 0x7e, 0x00],
    449             "All four bytes should decode correctly with block-based Goertzel. \n\
    450              Got {:?}",
    451             outputs
    452         );
    453     }
    454 
    455     // ── Noisy tests ────────────────────────────────────────────────────
    456 
    457     #[test]
    458     fn alternating_symbols_15_db_snr() {
    459         let symbols: Vec<Symbol> = (0..64)
    460             .map(|i| {
    461                 if i % 2 == 0 {
    462                     Symbol::Mark
    463                 } else {
    464                     Symbol::Space
    465                 }
    466             })
    467             .collect();
    468         let audio = generate_audio_phase_continuous(&symbols);
    469         let noisy = add_noise(&audio, 15.0, 42);
    470         let sps = SAMPLES_PER_SYMBOL as usize;
    471         let mut demod = Bell202Demodulator::new();
    472 
    473         let mut correct = 0usize;
    474         for (i, &sample) in noisy.iter().enumerate() {
    475             demod.sample_update(sample);
    476             if i > 0 && i % sps == 0 {
    477                 demod.symbol_update();
    478                 let expected = symbols[i / sps - 1] == Symbol::Mark;
    479                 let actual = demod.symbol_estimate == Symbol::Mark;
    480                 if actual == expected {
    481                     correct += 1;
    482                 }
    483             }
    484         }
    485 
    486         let total = symbols.len();
    487         let accuracy = correct as f64 / total as f64;
    488         assert!(
    489             accuracy >= 0.95,
    490             "Alternating symbols @ 15 dB SNR: {:.1}% ({} / {}); \
    491              Block-based Goertzel should handle 50% transition rate correctly",
    492             accuracy * 100.0,
    493             correct,
    494             total
    495         );
    496     }
    497 
    498     #[test]
    499     fn random_symbols_15_db_snr() {
    500         let symbols = generate_random_symbols(42, 200);
    501         let audio = generate_audio_phase_continuous(&symbols);
    502         let noisy = add_noise(&audio, 15.0, 42);
    503         let sps = SAMPLES_PER_SYMBOL as usize;
    504         let mut demod = Bell202Demodulator::new();
    505 
    506         let mut correct = 0usize;
    507         for (i, &sample) in noisy.iter().enumerate() {
    508             demod.sample_update(sample);
    509             if i > 0 && i % sps == 0 {
    510                 demod.symbol_update();
    511                 let expected = symbols[i / sps - 1] == Symbol::Mark;
    512                 let actual = demod.symbol_estimate == Symbol::Mark;
    513                 if actual == expected {
    514                     correct += 1;
    515                 }
    516             }
    517         }
    518 
    519         let total = symbols.len();
    520         let accuracy = correct as f64 / total as f64;
    521         assert!(
    522             accuracy >= 0.95,
    523             "Random symbols @ 15 dB SNR: {:.1}% ({} / {}); \
    524              Block-based Goertzel should exceed 95% at 15 dB SNR",
    525             accuracy * 100.0,
    526             correct,
    527             total
    528         );
    529     }
    530 }