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 }