arduino-tnc

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

commit 2ff5a0b61275abaa4e55f471f90b78640474feaa
parent da729fef43bbe6f460f48e534a1db6f37ec4608e
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Sat, 30 May 2026 17:55:18 -0400

Switch to block-based Goertzel filters and other improvements

Diffstat:
Mfirmware/src/main.rs | 9++++-----
Mfirmware/src/rx.rs | 25+++++++++++++++++--------
Mtnc-core/Cargo.lock | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtnc-core/Cargo.toml | 4++++
Mtnc-core/src/demodulator.rs | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtnc-core/src/goertzel.rs | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtnc-core/src/lib.rs | 42++++++++++++++++++++++++++++++++++++++----
Dtnc-core/tests/demodulator_test.rs | 224-------------------------------------------------------------------------------
Dtnc-core/tests/goertzel_filter_test.rs | 77-----------------------------------------------------------------------------
9 files changed, 849 insertions(+), 343 deletions(-)

diff --git a/firmware/src/main.rs b/firmware/src/main.rs @@ -88,8 +88,7 @@ fn TIMER0_COMPA() { let mut tnc = TNC.borrow(cs).borrow_mut(); match tnc.state { TncState::Receiving => { - tnc.receiver.sample_update(); - if tnc.receiver.is_at_symbol_transition() { + if tnc.receiver.sample_update() { if let Some(ref mut symbol_timer) = tnc.symbol_timer { symbol_timer .tcnt1() @@ -116,9 +115,8 @@ fn TIMER0_COMPA() { fn TIMER1_COMPA() { interrupt::free(|cs| { let mut tnc = TNC.borrow(cs).borrow_mut(); - match tnc.state { - TncState::Receiving => tnc.receiver.symbol_update(), - TncState::Transmitting => tnc.transmitter.symbol_update(), + if let TncState::Transmitting = tnc.state { + tnc.transmitter.symbol_update(); } }); } @@ -161,6 +159,7 @@ fn main() -> ! { interrupt::free(|cs| { let mut tnc = TNC.borrow(cs).borrow_mut(); + tnc.serial = Some(serial); tnc.sample_timer = Some(sample_timer); tnc.symbol_timer = Some(symbol_timer); tnc.receiver diff --git a/firmware/src/rx.rs b/firmware/src/rx.rs @@ -6,12 +6,13 @@ use arduino_hal::{ }, Adc, }; -use tnc_core::demodulator::Bell202Demodulator; +use tnc_core::{constants::SAMPLES_PER_SYMBOL, demodulator::Bell202Demodulator}; pub(crate) struct Receiver { adc: Option<Adc>, input: Option<Pin<Analog, PC0>>, demod: Bell202Demodulator, + sample_count: u8, } impl Receiver { @@ -20,25 +21,33 @@ impl Receiver { adc: None, input: None, demod: Bell202Demodulator::new(), + sample_count: 0, } } pub(crate) fn initialize(&mut self, mut adc: Adc, input: Pin<Input<Floating>, PC0>) { self.input = Some(input.into_analog_input(&mut adc)); self.adc = Some(adc); } - pub(crate) fn is_at_symbol_transition(&self) -> bool { - self.demod.get_output().is_some() - } pub(crate) fn get_output(&self) -> Option<u8> { self.demod.get_output() } - pub(crate) fn sample_update(&mut self) { + /// Accumulate a sample and check for symbol boundary + /// + /// Returns `true` when 32 samples have been accumulated and a symbol decision has been made + /// (filters reset). This is used by the TIMER0 ISR to trigger clock-recovery adjustments on + /// TIMER1. + pub(crate) fn sample_update(&mut self) -> bool { + let mut at_boundary = false; if let (Some(ref input), Some(ref mut adc)) = (&self.input, &mut self.adc) { let new_sample = input.analog_read(adc) as f32 / 512. - 0.5; self.demod.sample_update(new_sample); + self.sample_count += 1; + if self.sample_count >= SAMPLES_PER_SYMBOL { + self.sample_count = 0; + self.demod.symbol_update(); + at_boundary = true; + } } - } - pub(crate) fn symbol_update(&mut self) { - self.demod.symbol_update(); + at_boundary } } diff --git a/tnc-core/Cargo.lock b/tnc-core/Cargo.lock @@ -3,5 +3,164 @@ version = 4 [[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] name = "tnc-core" version = "0.1.0" +dependencies = [ + "rand", + "rand_distr", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tnc-core/Cargo.toml b/tnc-core/Cargo.toml @@ -2,3 +2,7 @@ name = "tnc-core" version = "0.1.0" edition = "2021" + +[dev-dependencies] +rand = "0.8" +rand_distr = "0.4" diff --git a/tnc-core/src/demodulator.rs b/tnc-core/src/demodulator.rs @@ -1,12 +1,12 @@ //! Bell 202 demodulator — produces byte-aligned NRZI-decoded output from raw AFSK samples //! //! Uses two [`GoertzelFilter`] resonators (mark @ 1200 Hz, space @ 2200 Hz), updates them -//! per-sample via [`Bell202Demodulator::sample_update`], then calls [`Bell202Demodulator::symbol_update`] -//! once per symbol period to get NRZI-decoded, HDLC-flag-aware byte output. +//! per-sample via [`Bell202Demodulator::sample_update`], then calls +//! [`Bell202Demodulator::symbol_update`] once per symbol period to get NRZI-decoded, +//! HDLC-flag-aware byte output. //! //! All frequency/timing parameters are defined in [`super::constants`]. -use crate::constants; use crate::goertzel::GoertzelFilter; #[derive(PartialEq, Clone, Copy, Debug)] @@ -27,12 +27,6 @@ pub struct Bell202Demodulator { } impl Bell202Demodulator { - pub const SAMPLE_RATE_HZ: f32 = constants::SAMPLE_RATE_HZ; - pub const MARK_HZ: f32 = constants::MARK_HZ; - pub const SPACE_HZ: f32 = constants::SPACE_HZ; - pub const SYMBOL_RATE_HZ: f32 = constants::SYMBOL_RATE_HZ; - pub const SAMPLES_PER_SYMBOL: u8 = constants::SAMPLES_PER_SYMBOL; - pub const fn new() -> Self { Self { mark: GoertzelFilter::mark(), @@ -49,34 +43,37 @@ impl Bell202Demodulator { pub fn sample_update(&mut self, sample: f32) { self.mark.update(sample); self.space.update(sample); - let new_symbol_estimate = if self.mark.energy() > self.space.energy() { + } + + pub fn symbol_update(&mut self) -> Option<u8> { + let mark_energy = self.mark.energy(); + let space_energy = self.space.energy(); + self.previous_symbol_estimate = self.symbol_estimate; + self.symbol_estimate = if mark_energy > space_energy { Symbol::Mark } else { Symbol::Space }; - if new_symbol_estimate != self.symbol_estimate { - self.symbol_phase = 0; - } else { - self.symbol_phase = self.symbol_phase.saturating_add(1) % Self::SAMPLES_PER_SYMBOL; - } - self.symbol_estimate = new_symbol_estimate; - } - pub fn symbol_update(&mut self) -> Option<u8> { self.byte <<= 1; if self.symbol_estimate == self.previous_symbol_estimate { self.byte += 1; } + self.byte_phase = (self.byte_phase + 1) % 8; if self.byte == 0x7e { self.byte_phase = 0; } + if self.byte_phase == 0 { self.output = Some(self.byte); } else { self.output = None; } - self.previous_symbol_estimate = self.symbol_estimate; + + self.mark.reset(); + self.space.reset(); + self.output } @@ -94,4 +91,440 @@ impl Bell202Demodulator { pub fn get_output(&self) -> Option<u8> { self.output } + + #[cfg(test)] + pub fn byte_phase(&self) -> u8 { + self.byte_phase + } +} + +#[cfg(test)] +mod tests { + use alloc::vec; + use alloc::vec::Vec; + use core::iter; + + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + use rand_distr::{Distribution, Normal}; + + use super::*; + use crate::constants::SAMPLES_PER_SYMBOL; + use crate::test_utils::generate_audio_phase_continuous; + + /// Add Gaussian noise at the given SNR + fn add_noise(samples: &[f32], snr_db: f32, seed: u64) -> Vec<f32> { + let signal_power = 0.5; + let noise_std = f32::sqrt(signal_power / 10f32.powf(snr_db / 10.0)); + let normal = Normal::new(0.0, noise_std).unwrap(); + let mut rng = StdRng::seed_from_u64(seed); + samples + .iter() + .zip(normal.sample_iter(&mut rng)) + .map(|(s, n)| s + n) + .collect() + } + + /// Generate random symbols with the given seed + fn generate_random_symbols(seed: u64, n: usize) -> Vec<Symbol> { + let mut rng = StdRng::seed_from_u64(seed); + (0..n) + .map(|_| { + if rng.gen::<u8>() % 2 == 0 { + Symbol::Mark + } else { + Symbol::Space + } + }) + .collect() + } + + #[test] + fn mark_only_tone_yields_mark_decisions() { + let mut demod = Bell202Demodulator::new(); + let symbols = vec![Symbol::Mark; 64]; + let audio = generate_audio_phase_continuous(&symbols); + let sps = SAMPLES_PER_SYMBOL as usize; + + let mut mark_count = 0; + let mut total_decisions = 0; + + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + if demod.symbol_estimate == Symbol::Mark { + mark_count += 1; + } + total_decisions += 1; + } + } + + assert_eq!( + mark_count, total_decisions, + "All {} symbol decisions should be Mark, but only {} were", + total_decisions, mark_count + ); + } + + #[test] + fn space_only_tone_yields_space_decisions() { + let mut demod = Bell202Demodulator::new(); + let symbols = vec![Symbol::Space; 64]; + let audio = generate_audio_phase_continuous(&symbols); + let sps = SAMPLES_PER_SYMBOL as usize; + + let mut space_count = 0; + let mut total_decisions = 0; + + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + if demod.symbol_estimate == Symbol::Space { + space_count += 1; + } + total_decisions += 1; + } + } + + assert_eq!( + space_count, total_decisions, + "All {} symbol decisions should be Space, but only {} were", + total_decisions, space_count + ); + } + + #[test] + fn phase_shift_resets_on_tone_transition() { + let mut demod = Bell202Demodulator::new(); + let sps = SAMPLES_PER_SYMBOL as usize; + + let mut all_symbols = vec![Symbol::Space; 10]; + all_symbols.extend(iter::repeat(Symbol::Mark).take(10)); + let audio = generate_audio_phase_continuous(&all_symbols); + + let mut prev_est = Symbol::Space; + let mut transitions: usize = 0; + + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + let current_est = demod.symbol_estimate; + if current_est != prev_est { + transitions += 1; + } + prev_est = current_est; + } + } + + assert!( + transitions == 1, + "Expected exactly one tone transition (Space→Mark), got {}", + transitions + ); + } + + #[test] + fn byte_phase_advances_correctly() { + // Verify that the demodulator's byte_phase advances correctly through byte boundaries, and + // that flag bytes reset it while non-flag bytes complete two full cycles. + // + // Helper: run sample_update for audio, then call symbol_update for any remaining symbol + // that hasn't been processed yet (the loop's modulo check misses the last symbol + // boundary). + macro_rules! feed { + ($demod:expr, $audio:expr, $sps:expr) => { + for (i, &sample) in $audio.iter().enumerate() { + $demod.sample_update(sample); + if i > 0 && i % $sps == 0 { + $demod.symbol_update(); + } + } + // Flush last symbol + $demod.symbol_update(); + }; + } + + let mut demod = Bell202Demodulator::new(); + let sps = SAMPLES_PER_SYMBOL as usize; + + // --- Phase 1: 16 Space symbols (0xff 0xff). byte_phase wraps 0→7 twice without resetting + // (0xff ≠ 0x7e). + let symbols = vec![Symbol::Space; 16]; + let audio = generate_audio_phase_continuous(&symbols); + feed!(demod, audio, sps); + // get_output() returns the last completed byte (0xff), not None. + assert_eq!( + demod.byte_phase(), + 0, + "byte_phase should wrap to 0 after 2 bytes" + ); + + // --- Phase 2: 8 Space symbols. byte_phase wraps 0→0 (full byte, 0xff again). + let symbols = vec![Symbol::Space; 8]; + let audio = generate_audio_phase_continuous(&symbols); + feed!(demod, audio, sps); + assert_eq!(demod.byte_phase(), 0, "byte_phase wraps to 0 after 1 byte"); + + // --- Phase 3: 4 Space symbols. byte_phase advances 0→4. + let symbols = vec![Symbol::Space; 4]; + let audio = generate_audio_phase_continuous(&symbols); + feed!(demod, audio, sps); + assert_eq!( + demod.byte_phase(), + 4, + "byte_phase should advance to 4 (0.5 byte)" + ); + + // --- Phase 4: 4 more Space symbols completes a byte. byte_phase wraps 4→0. get_output() + // returns 0xff again. + let symbols = vec![Symbol::Space; 4]; + let audio = generate_audio_phase_continuous(&symbols); + feed!(demod, audio, sps); + assert_eq!( + demod.byte_phase(), + 0, + "byte_phase wraps to 0 after completing byte" + ); + } + + #[test] + fn flag_byte_detection() { + // Verify flag byte 0x7e is detected correctly with block-based Goertzel (filters reset at + // symbol boundaries) + // + // 0x7e = 01111110 → NRZI: flip(S→M), keep(M)×6, flip(M→S) → 7 Marks + 1 Space + let mut demod = Bell202Demodulator::new(); + let nrzi = vec![ + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Space, + ]; + let audio = generate_audio_phase_continuous(&nrzi); + let sps = SAMPLES_PER_SYMBOL as usize; + + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + } + } + demod.symbol_update(); + + assert_eq!( + demod.get_output(), + Some(0x7e), + "Flag byte 0x7e should be detected correctly with block-based \ + Goertzel (filters reset at symbol boundaries)" + ); + } + + #[test] + fn consecutive_flag_bytes() { + // Verify two consecutive 0x7e flag bytes are both detected + // + // NRZI sequence: M×7, S, M×7, S (16 symbols); The demodulator's NRZI state carries over; + // after the first 0x7e, prev=Space. The second flag starts with S→M (flip, data bit=0). + let mut demod = Bell202Demodulator::new(); + let nrzi = vec![ + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Space, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Space, + ]; + let audio = generate_audio_phase_continuous(&nrzi); + let sps = SAMPLES_PER_SYMBOL as usize; + + let mut outputs = Vec::new(); + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + if let Some(byte) = demod.get_output() { + outputs.push(byte); + } + } + } + demod.symbol_update(); + if let Some(byte) = demod.get_output() { + outputs.push(byte); + } + + assert_eq!( + outputs, + vec![0x7e, 0x7e], + "Both consecutive 0x7e flag bytes should be detected. Got {:?}", + outputs + ); + } + + #[test] + fn mixed_bytes() { + // Verify demodulator handles a realistic mix of flag and data bytes. + // + // Sequence (NRZI, state carries over between bytes): + // 0x7e: M×7, S (flag) + // 0xff: S×8 (all 1s → same NRZI state) + // 0x7e: S→M, M×6, M→S (flag) + // 0x00: M,S,M,S,M,S,M,S (alternating, from S state) + let mut demod = Bell202Demodulator::new(); + let nrzi = vec![ + // 0x7e flag: M×7, S + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Space, + // 0xff: all S (same state) → S×8 + Symbol::Space, + Symbol::Space, + Symbol::Space, + Symbol::Space, + Symbol::Space, + Symbol::Space, + Symbol::Space, + Symbol::Space, + // 0x7e flag: S→M, M×6, M→S + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Mark, + Symbol::Space, + // 0x00: all 0s → flip every bit from S state → M,S,M,S,M,S,M,S + Symbol::Mark, + Symbol::Space, + Symbol::Mark, + Symbol::Space, + Symbol::Mark, + Symbol::Space, + Symbol::Mark, + Symbol::Space, + ]; + let audio = generate_audio_phase_continuous(&nrzi); + let sps = SAMPLES_PER_SYMBOL as usize; + + let mut outputs = Vec::new(); + for (i, &sample) in audio.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + if let Some(byte) = demod.get_output() { + outputs.push(byte); + } + } + } + demod.symbol_update(); + if let Some(byte) = demod.get_output() { + outputs.push(byte); + } + + assert_eq!( + outputs, + vec![0x7e, 0xff, 0x7e, 0x00], + "All four bytes should decode correctly with block-based Goertzel. \n\ + Got {:?}", + outputs + ); + } + + // ── Noisy tests ──────────────────────────────────────────────────── + + #[test] + fn alternating_symbols_15_db_snr() { + let symbols: Vec<Symbol> = (0..64) + .map(|i| { + if i % 2 == 0 { + Symbol::Mark + } else { + Symbol::Space + } + }) + .collect(); + let audio = generate_audio_phase_continuous(&symbols); + let noisy = add_noise(&audio, 15.0, 42); + let sps = SAMPLES_PER_SYMBOL as usize; + let mut demod = Bell202Demodulator::new(); + + let mut correct = 0usize; + for (i, &sample) in noisy.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + let expected = symbols[i / sps - 1] == Symbol::Mark; + let actual = demod.symbol_estimate == Symbol::Mark; + if actual == expected { + correct += 1; + } + } + } + + let total = symbols.len(); + let accuracy = correct as f64 / total as f64; + assert!( + accuracy >= 0.95, + "Alternating symbols @ 15 dB SNR: {:.1}% ({} / {}); \ + Block-based Goertzel should handle 50% transition rate correctly", + accuracy * 100.0, + correct, + total + ); + } + + #[test] + fn random_symbols_15_db_snr() { + let symbols = generate_random_symbols(42, 200); + let audio = generate_audio_phase_continuous(&symbols); + let noisy = add_noise(&audio, 15.0, 42); + let sps = SAMPLES_PER_SYMBOL as usize; + let mut demod = Bell202Demodulator::new(); + + let mut correct = 0usize; + for (i, &sample) in noisy.iter().enumerate() { + demod.sample_update(sample); + if i > 0 && i % sps == 0 { + demod.symbol_update(); + let expected = symbols[i / sps - 1] == Symbol::Mark; + let actual = demod.symbol_estimate == Symbol::Mark; + if actual == expected { + correct += 1; + } + } + } + + let total = symbols.len(); + let accuracy = correct as f64 / total as f64; + assert!( + accuracy >= 0.95, + "Random symbols @ 15 dB SNR: {:.1}% ({} / {}); \ + Block-based Goertzel should exceed 95% at 15 dB SNR", + accuracy * 100.0, + correct, + total + ); + } } diff --git a/tnc-core/src/goertzel.rs b/tnc-core/src/goertzel.rs @@ -2,15 +2,15 @@ #[derive(Clone, Copy, Debug)] pub struct GoertzelFilter { - pub coeff: f32, - pub s0: f32, - pub s1: f32, - pub s2: f32, - pub energy: f32, + coeff: f32, + s0: f32, + s1: f32, + s2: f32, + energy: f32, } impl GoertzelFilter { - /// Create a new Goertzel filter with a pre-computed coefficient. + /// Create a new Goertzel filter with a pre-computed coefficient /// /// The coefficient should be `2cos(2πf / sample_rate)`. We can't easily compute the /// coefficient inside this method because it's `const`. @@ -46,6 +46,26 @@ impl GoertzelFilter { self.energy } + #[cfg(test)] + pub fn coeff(&self) -> f32 { + self.coeff + } + + #[cfg(test)] + pub fn s0(&self) -> f32 { + self.s0 + } + + #[cfg(test)] + pub fn s1(&self) -> f32 { + self.s1 + } + + #[cfg(test)] + pub fn s2(&self) -> f32 { + self.s2 + } + pub fn reset(&mut self) { self.s0 = 0.0; self.s1 = 0.0; @@ -53,3 +73,152 @@ impl GoertzelFilter { self.energy = 0.0; } } + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + use core::f64::consts::TAU; + + use rand::rngs::StdRng; + use rand::SeedableRng; + use rand_distr::{Distribution, Normal}; + + use super::*; + use crate::constants::{MARK_HZ, SAMPLE_RATE_HZ, SPACE_HZ}; + + /// Generate a phase-continuous tone at the given frequency + fn generate_tone(freq: f32, samples: usize) -> Vec<f32> { + let sr = SAMPLE_RATE_HZ as f64; + let freq = freq as f64; + let dt = 1.0 / sr; + (0..samples) + .map(|i| (TAU * freq * dt * i as f64).sin()) + .map(|v| v as f32) + .collect() + } + + /// Add Gaussian noise at the given SNR, assuming a full-scale signal + fn add_noise(samples: &[f32], snr_db: f32, seed: u64) -> Vec<f32> { + let signal_power = 0.5; + let noise_std = f32::sqrt(signal_power / 10f32.powf(snr_db / 10.0)); + let normal = Normal::new(0.0, noise_std).unwrap(); + let mut rng = StdRng::seed_from_u64(seed); + samples + .iter() + .zip(normal.sample_iter(&mut rng)) + .map(|(s, n)| s + n) + .collect() + } + + #[test] + fn filter_reset_clears_states() { + let mut filter = GoertzelFilter::mark(); + + // Feed some samples so energy builds up + for i in 0..32 { + let sample = (TAU * 1200.0 * i as f64 / 38460.0).sin() as f32; + filter.update(sample); + } + + let energy_before = filter.energy(); + assert!( + energy_before > 0.0, + "Filter should have non-zero energy after feeding samples" + ); + + filter.reset(); + + assert_eq!(filter.s0(), 0.0, "s0 should be reset to 0.0"); + assert_eq!(filter.s1(), 0.0, "s1 should be reset to 0.0"); + assert_eq!(filter.s2(), 0.0, "s2 should be reset to 0.0"); + assert_eq!(filter.energy(), 0.0, "energy should be reset to 0.0"); + } + + #[test] + fn mark_tone_gives_higher_mark_energy() { + let mut mark = GoertzelFilter::mark(); + let mut space = GoertzelFilter::space(); + + let samples = generate_tone(MARK_HZ, 32); + for &sample in &samples { + mark.update(sample); + space.update(sample); + } + + assert!( + mark.energy() > space.energy(), + "Mark resonator should have higher energy than space resonator for 1200 Hz tone. \n\ + mark_energy={}, space_energy={}", + mark.energy(), + space.energy() + ); + } + + #[test] + fn space_tone_gives_higher_space_energy() { + let mut mark = GoertzelFilter::mark(); + let mut space = GoertzelFilter::space(); + + let samples = generate_tone(SPACE_HZ, 32); + for &sample in &samples { + mark.update(sample); + space.update(sample); + } + + assert!( + space.energy() > mark.energy(), + "Space resonator should have higher energy than mark resonator for 2200 Hz tone. \n\ + mark_energy={}, space_energy={}", + mark.energy(), + space.energy() + ); + } + + #[test] + fn mark_tone_noisy_gives_higher_mark_energy() { + // Verify the filter discriminates mark tone even with noise. + let snr_db = 15.0; + let mut mark = GoertzelFilter::mark(); + let mut space = GoertzelFilter::space(); + + let samples = generate_tone(MARK_HZ, 32); + let noisy = add_noise(&samples, snr_db, 42); + for &sample in &noisy { + mark.update(sample); + space.update(sample); + } + + assert!( + mark.energy() > space.energy(), + "Mark resonator should still exceed space at {} dB SNR. \n\ + mark_energy={}, space_energy={}", + snr_db, + mark.energy(), + space.energy() + ); + } + + #[test] + fn space_tone_noisy_gives_higher_space_energy() { + // Verify the filter discriminates space tone even with noise. + let snr_db = 15.0; + let mut mark = GoertzelFilter::mark(); + let mut space = GoertzelFilter::space(); + + let samples = generate_tone(SPACE_HZ, 32); + let noisy = add_noise(&samples, snr_db, 42); + for &sample in &noisy { + mark.update(sample); + space.update(sample); + } + + assert!( + space.energy() > mark.energy(), + "Space resonator should still exceed mark at {} dB SNR. \n\ + mark_energy={}, space_energy={}", + snr_db, + mark.energy(), + space.energy() + ); + } +} diff --git a/tnc-core/src/lib.rs b/tnc-core/src/lib.rs @@ -1,9 +1,43 @@ -//! Shared, hardware-agnostic algorithms for the Arduino TNC. -//! -//! To run tests: `cargo test` (from workspace root or from tnc-core/) - #![no_std] pub mod constants; pub mod demodulator; pub mod goertzel; + +#[cfg(test)] +extern crate alloc; + +#[cfg(test)] +mod test_utils { + use alloc::vec; + use alloc::vec::Vec; + use core::f64::consts::TAU; + + use crate::constants::{MARK_HZ, SAMPLE_RATE_HZ, SYMBOL_RATE_HZ}; + use crate::demodulator::Symbol; + + /// Generate phase-continuous AFSK audio for the given symbol sequence + pub fn generate_audio_phase_continuous(symbols: &[Symbol]) -> Vec<f32> { + let samples_per_symbol = (SAMPLE_RATE_HZ / SYMBOL_RATE_HZ) as usize; + let mut audio = vec![]; + audio.reserve(symbols.len() * samples_per_symbol); + + let mut phase = 0_f64; + let dt = 1. / SAMPLE_RATE_HZ as f64; + + for sym in symbols { + let freq = match sym { + Symbol::Mark => MARK_HZ as f64, + Symbol::Space => crate::constants::SPACE_HZ as f64, + }; + let phase_inc = freq * dt * TAU; + + for _ in 0..samples_per_symbol { + audio.push((phase.sin()) as f32); + phase += phase_inc; + } + } + + audio + } +} diff --git a/tnc-core/tests/demodulator_test.rs b/tnc-core/tests/demodulator_test.rs @@ -1,224 +0,0 @@ -use std::f64::consts::TAU; - -use tnc_core::constants::{MARK_HZ, SAMPLE_RATE_HZ, SPACE_HZ, SYMBOL_RATE_HZ}; -use tnc_core::demodulator::{Bell202Demodulator, Symbol}; -use tnc_core::goertzel::GoertzelFilter; - -/// Generate phase-continuous AFSK audio for the given symbol sequence -fn generate_audio_phase_continuous(symbols: &[Symbol]) -> Vec<f32> { - let samples_per_symbol = (SAMPLE_RATE_HZ / SYMBOL_RATE_HZ) as usize; - let mut audio = Vec::with_capacity(symbols.len() * samples_per_symbol); - - let mut phase = 0_f64; - let dt = 1. / SAMPLE_RATE_HZ as f64; - - for sym in symbols { - let freq = match sym { - Symbol::Mark => MARK_HZ as f64, - Symbol::Space => SPACE_HZ as f64, - }; - let phase_inc = freq * dt * TAU; - - for _ in 0..samples_per_symbol { - audio.push(phase.sin() as f32); - phase += phase_inc; - } - } - - audio -} - -struct Rng(u64); - -impl Rng { - fn new(seed: u64) -> Self { - Self(seed) - } - fn next(&mut self) -> u64 { - self.0 = self - .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - self.0 - } - fn uniform(&mut self) -> f32 { - self.next() as f32 / u64::MAX as f32 - } -} - -fn generate_symbols(n: usize, seed: u64) -> Vec<Symbol> { - let mut rng = Rng::new(seed); - (0..n) - .map(|_| { - if rng.next() % 2 == 0 { - Symbol::Mark - } else { - Symbol::Space - } - }) - .collect() -} - -fn add_noise(samples: &[f32], noise_std: f32, seed: u64) -> Vec<f32> { - let mut rng = Rng::new(seed); - samples - .iter() - .map(|&s| { - if noise_std <= 0.0 { - return s; - } - let normal: f32 = (0..12).map(|_| rng.uniform() - 0.5).sum::<f32>() / (3_f32).sqrt(); - s + normal * noise_std - }) - .collect() -} - -#[test] -fn mark_only_tone_yields_mark_decisions() { - let mut demod = Bell202Demodulator::new(); - let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; - let total_samples = 64 * sps; - let dt = 1. / SAMPLE_RATE_HZ as f64; - let mut phase = 0_f64; - let phase_inc = MARK_HZ as f64 * dt * TAU; - - for i in 0..total_samples { - let sample = phase.sin() as f32; - demod.sample_update(sample); - if i > 0 && i % sps == 0 { - demod.symbol_update(); - } - phase += phase_inc; - } -} - -#[test] -fn space_only_tone_yields_space_decisions() { - let mut demod = Bell202Demodulator::new(); - let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; - let total_samples = 64 * sps; - let dt = 1. / SAMPLE_RATE_HZ as f64; - let mut phase = 0_f64; - let phase_inc = SPACE_HZ as f64 * dt * TAU; - - for i in 0..total_samples { - let sample = phase.sin() as f32; - demod.sample_update(sample); - if i > 0 && i % sps == 0 { - demod.symbol_update(); - } - phase += phase_inc; - } -} - -#[test] -fn alternating_symbols_no_noise() { - let mut symbols = Vec::with_capacity(64); - for i in 0..64 { - symbols.push(if i % 2 == 0 { - Symbol::Mark - } else { - Symbol::Space - }); - } - let audio = generate_audio_phase_continuous(&symbols); - let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; - - let mut mark = GoertzelFilter::mark(); - let mut space = GoertzelFilter::space(); - let mut correct = 0usize; - let mut total = 0usize; - - for (i, &sample) in audio.iter().enumerate() { - mark.update(sample); - space.update(sample); - if i > 0 && i % sps == 0 { - total += 1; - let expected_mark = symbols[i / sps - 1] == Symbol::Mark; - if (mark.energy() > space.energy()) == expected_mark { - correct += 1; - } - mark.reset(); - space.reset(); - } - } - - let accuracy = correct as f64 / total as f64; - assert!( - accuracy >= 0.95, - "Symbol accuracy should be >= 95% for clean alternating symbols, got {:.1}%", - accuracy * 100.0 - ); -} - -#[test] -fn random_symbols_no_noise() { - let symbols = generate_symbols(200, 42); - let audio = generate_audio_phase_continuous(&symbols); - let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; - - let mut mark = GoertzelFilter::mark(); - let mut space = GoertzelFilter::space(); - let mut correct = 0usize; - let mut total = 0usize; - - for (i, &sample) in audio.iter().enumerate() { - mark.update(sample); - space.update(sample); - if i > 0 && i % sps == 0 { - total += 1; - let expected_mark = symbols[i / sps - 1] == Symbol::Mark; - if (mark.energy() > space.energy()) == expected_mark { - correct += 1; - } - mark.reset(); - space.reset(); - } - } - - let accuracy = correct as f64 / total as f64; - assert!( - accuracy >= 0.99, - "Symbol accuracy should be >= 99% for clean random symbols, got {:.1}% ({} / {})", - accuracy * 100.0, - correct, - total - ); -} - -#[test] -fn moderate_noise_accuracy() { - let noise_std = 0.5 * 10.0f32.powf(-15.0 / 20.0); - let symbols = generate_symbols(200, 42); - let audio = generate_audio_phase_continuous(&symbols); - let noisy = add_noise(&audio, noise_std, 12345); - let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; - - let mut mark = GoertzelFilter::mark(); - let mut space = GoertzelFilter::space(); - let mut correct = 0usize; - let mut total = 0usize; - - for (i, &sample) in noisy.iter().enumerate() { - mark.update(sample); - space.update(sample); - if i > 0 && i % sps == 0 { - total += 1; - let expected_mark = symbols[i / sps - 1] == Symbol::Mark; - if (mark.energy() > space.energy()) == expected_mark { - correct += 1; - } - mark.reset(); - space.reset(); - } - } - - let accuracy = correct as f64 / total as f64; - assert!( - accuracy >= 0.90, - "Symbol accuracy at ~15 dB SNR should be >= 90%, got {:.1}% ({} / {})", - accuracy * 100.0, - correct, - total - ); -} diff --git a/tnc-core/tests/goertzel_filter_test.rs b/tnc-core/tests/goertzel_filter_test.rs @@ -1,77 +0,0 @@ -use tnc_core::constants::{MARK_HZ, SAMPLE_RATE_HZ, SPACE_HZ}; -use tnc_core::goertzel::GoertzelFilter; - -fn generate_tone(frequency: f32, duration_samples: usize, sample_rate: f32) -> Vec<f32> { - let mut samples = Vec::new(); - let dt = 1. / sample_rate; - for i in 0..duration_samples { - let phase = 2.0 * std::f32::consts::PI * frequency * dt * i as f32; - samples.push(phase.sin()); - } - samples -} - -#[test] -fn mark_tone_gives_higher_mark_energy() { - let mut mark = GoertzelFilter::mark(); - let mut space = GoertzelFilter::space(); - - let samples = generate_tone(MARK_HZ, 32, SAMPLE_RATE_HZ); - - for &sample in &samples { - mark.update(sample); - space.update(sample); - } - - assert!( - mark.energy() > space.energy(), - "Mark resonator should have higher energy than space resonator for 1200 Hz tone. \ - mark_energy={}, space_energy={}", - mark.energy(), - space.energy() - ); -} - -#[test] -fn space_tone_gives_higher_space_energy() { - let mut mark = GoertzelFilter::mark(); - let mut space = GoertzelFilter::space(); - - let samples = generate_tone(SPACE_HZ, 32, SAMPLE_RATE_HZ); - - for &sample in &samples { - mark.update(sample); - space.update(sample); - } - - assert!( - space.energy() > mark.energy(), - "Space resonator should have higher energy than mark resonator for 2200 Hz tone. \ - mark_energy={}, space_energy={}", - mark.energy(), - space.energy() - ); -} - -#[test] -fn filter_reset_clears_states() { - let mut filter = GoertzelFilter::mark(); - - let samples = generate_tone(MARK_HZ, 32, SAMPLE_RATE_HZ); - for &sample in &samples { - filter.update(sample); - } - - let energy_before = filter.energy(); - assert!( - energy_before > 0.0, - "Filter should have non-zero energy after feeding samples" - ); - - filter.reset(); - - assert_eq!(filter.s0, 0.0, "s0 should be reset to 0.0"); - assert_eq!(filter.s1, 0.0, "s1 should be reset to 0.0"); - assert_eq!(filter.s2, 0.0, "s2 should be reset to 0.0"); - assert_eq!(filter.energy, 0.0, "energy should be reset to 0.0"); -}