arduino-tnc

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

commit da729fef43bbe6f460f48e534a1db6f37ec4608e
parent f764ef0581c622626574581f551227744455c502
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Fri, 29 May 2026 19:42:29 -0400

Factor out common constants and split demodulator into its own module

Diffstat:
Mfirmware/src/rx.rs | 2+-
Atnc-core/src/constants.rs | 7+++++++
Atnc-core/src/demodulator.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtnc-core/src/goertzel.rs | 112+++++--------------------------------------------------------------------------
Mtnc-core/src/lib.rs | 2++
Mtnc-core/tests/demodulator_test.rs | 70++++++++++++++++------------------------------------------------------
Mtnc-core/tests/goertzel_filter_test.rs | 9++++-----
7 files changed, 133 insertions(+), 166 deletions(-)

diff --git a/firmware/src/rx.rs b/firmware/src/rx.rs @@ -6,7 +6,7 @@ use arduino_hal::{ }, Adc, }; -use tnc_core::goertzel::Bell202Demodulator; +use tnc_core::demodulator::Bell202Demodulator; pub(crate) struct Receiver { adc: Option<Adc>, diff --git a/tnc-core/src/constants.rs b/tnc-core/src/constants.rs @@ -0,0 +1,7 @@ +//! Shared constants for Bell 202 AFSK modem + +pub const MARK_HZ: f32 = 1200.; +pub const SPACE_HZ: f32 = 2200.; +pub const SYMBOL_RATE_HZ: f32 = 1200.; +pub const SAMPLE_RATE_HZ: f32 = 38461.5; +pub const SAMPLES_PER_SYMBOL: u8 = (SAMPLE_RATE_HZ / SYMBOL_RATE_HZ) as u8; diff --git a/tnc-core/src/demodulator.rs b/tnc-core/src/demodulator.rs @@ -0,0 +1,97 @@ +//! 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. +//! +//! All frequency/timing parameters are defined in [`super::constants`]. + +use crate::constants; +use crate::goertzel::GoertzelFilter; + +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum Symbol { + Mark, + Space, +} + +pub struct Bell202Demodulator { + mark: GoertzelFilter, + space: GoertzelFilter, + symbol_estimate: Symbol, + previous_symbol_estimate: Symbol, + symbol_phase: u8, + byte: u8, + byte_phase: u8, + output: Option<u8>, +} + +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(), + space: GoertzelFilter::space(), + symbol_estimate: Symbol::Space, + previous_symbol_estimate: Symbol::Space, + symbol_phase: 0, + byte: 0, + byte_phase: 0, + output: None, + } + } + + 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() { + 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.output + } + + pub fn reset(&mut self) { + self.mark.reset(); + self.space.reset(); + self.symbol_estimate = Symbol::Space; + self.previous_symbol_estimate = Symbol::Space; + self.symbol_phase = 0; + self.byte = 0; + self.byte_phase = 0; + self.output = None; + } + + pub fn get_output(&self) -> Option<u8> { + self.output + } +} diff --git a/tnc-core/src/goertzel.rs b/tnc-core/src/goertzel.rs @@ -1,9 +1,7 @@ -//! Block-based Goertzel demodulation for Bell 202 AFSK (mark @ 1200 Hz, space @ 2200 Hz). +//! Goertzel resonator for AFSK tone detection -/// A single Goertzel resonator. #[derive(Clone, Copy, Debug)] pub struct GoertzelFilter { - /// 2cos(2πf / sample_rate) pub coeff: f32, pub s0: f32, pub s1: f32, @@ -26,18 +24,17 @@ impl GoertzelFilter { } } + const MARK_COEFF: f32 = 1.961_69; + const SPACE_COEFF: f32 = 1.872_207_2; + pub const fn mark() -> Self { - Self::new_with_coeff(1.961_69) + Self::new_with_coeff(Self::MARK_COEFF) } pub const fn space() -> Self { - Self::new_with_coeff(1.872_207_2) + Self::new_with_coeff(Self::SPACE_COEFF) } - /// Feed one sample through the resonator. - /// - /// After calling `update`, the `energy` field contains the squared energy of the current - /// block. For block-based usage, reset `s0`, `s1`, `s2` to 0.0 at each symbol boundary. pub fn update(&mut self, x: f32) { self.s0 = x + self.coeff * self.s1 - self.s2; self.s2 = self.s1; @@ -56,100 +53,3 @@ impl GoertzelFilter { self.energy = 0.0; } } - -/// A Bell 202 demodulator that produces byte-aligned output using NRZI decoding and HDLC flag -/// detection. -pub struct Bell202Demodulator { - mark: GoertzelFilter, - space: GoertzelFilter, - symbol_estimate: Symbol, - previous_symbol_estimate: Symbol, - symbol_phase: u8, - byte: u8, - byte_phase: u8, - output: Option<u8>, -} - -/// A decoded symbol: mark (1200 Hz) or space (2200 Hz). -#[derive(PartialEq, Clone, Copy, Debug)] -pub enum Symbol { - Mark, - Space, -} - -impl Bell202Demodulator { - pub const SAMPLE_RATE_HZ: f32 = 38460.; - pub const MARK_HZ: f32 = 1200.; - pub const SPACE_HZ: f32 = 2200.; - pub const SYMBOL_RATE_HZ: f32 = 1200.; - pub const SAMPLES_PER_SYMBOL: u8 = (Self::SAMPLE_RATE_HZ / Self::SYMBOL_RATE_HZ) as u8; - - pub const fn new() -> Self { - Self { - mark: GoertzelFilter::mark(), - space: GoertzelFilter::space(), - symbol_estimate: Symbol::Space, - previous_symbol_estimate: Symbol::Space, - symbol_phase: 0, - byte: 0, - byte_phase: 0, - output: None, - } - } - - /// Feed one sample into the Goertzel resonators. - /// - /// This should be called at the configured sample rate. - 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() { - 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; - } - - /// Called once per symbol period. Produces NRZI-decoded, byte-aligned output. - /// - /// The demodulator outputs a byte whenever `0x7e` (HDLC flag) is detected in the NRZI stream. - /// Returns the output byte, or `None` if no flag was found. - 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.output - } - - pub fn reset(&mut self) { - self.mark.reset(); - self.space.reset(); - self.symbol_estimate = Symbol::Space; - self.previous_symbol_estimate = Symbol::Space; - self.symbol_phase = 0; - self.byte = 0; - self.byte_phase = 0; - self.output = None; - } - - pub fn get_output(&self) -> Option<u8> { - self.output - } -} diff --git a/tnc-core/src/lib.rs b/tnc-core/src/lib.rs @@ -4,4 +4,6 @@ #![no_std] +pub mod constants; +pub mod demodulator; pub mod goertzel; diff --git a/tnc-core/tests/demodulator_test.rs b/tnc-core/tests/demodulator_test.rs @@ -1,25 +1,21 @@ use std::f64::consts::TAU; -use tnc_core::goertzel::{Bell202Demodulator, GoertzelFilter, Symbol}; +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; -const SAMPLE_RATE: f32 = 38460.; - -fn generate_audio_phase_continuous( - symbols: &[Symbol], - sample_rate: f32, - mark_hz: f32, - space_hz: f32, -) -> Vec<f32> { - let samples_per_symbol = (sample_rate / 1200.) as usize; +/// 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 as 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, + Symbol::Mark => MARK_HZ as f64, + Symbol::Space => SPACE_HZ as f64, }; let phase_inc = freq * dt * TAU; @@ -82,9 +78,9 @@ 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 as f64; + let dt = 1. / SAMPLE_RATE_HZ as f64; let mut phase = 0_f64; - let phase_inc = 1200. * dt * TAU; + let phase_inc = MARK_HZ as f64 * dt * TAU; for i in 0..total_samples { let sample = phase.sin() as f32; @@ -101,9 +97,9 @@ 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 as f64; + let dt = 1. / SAMPLE_RATE_HZ as f64; let mut phase = 0_f64; - let phase_inc = 2200. * dt * TAU; + let phase_inc = SPACE_HZ as f64 * dt * TAU; for i in 0..total_samples { let sample = phase.sin() as f32; @@ -125,7 +121,7 @@ fn alternating_symbols_no_noise() { Symbol::Space }); } - let audio = generate_audio_phase_continuous(&symbols, SAMPLE_RATE, 1200., 2200.); + let audio = generate_audio_phase_continuous(&symbols); let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; let mut mark = GoertzelFilter::mark(); @@ -158,7 +154,7 @@ fn alternating_symbols_no_noise() { #[test] fn random_symbols_no_noise() { let symbols = generate_symbols(200, 42); - let audio = generate_audio_phase_continuous(&symbols, SAMPLE_RATE, 1200.0, 2200.0); + let audio = generate_audio_phase_continuous(&symbols); let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; let mut mark = GoertzelFilter::mark(); @@ -194,7 +190,7 @@ fn random_symbols_no_noise() { 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, SAMPLE_RATE, 1200., 2200.); + let audio = generate_audio_phase_continuous(&symbols); let noisy = add_noise(&audio, noise_std, 12345); let sps = Bell202Demodulator::SAMPLES_PER_SYMBOL as usize; @@ -226,37 +222,3 @@ fn moderate_noise_accuracy() { total ); } - -#[test] -fn frequency_offset_tolerance() { - let space_hz = 3200_f32; - let symbols = generate_symbols(100, 42); - let audio = generate_audio_phase_continuous(&symbols, SAMPLE_RATE, 1200.0, space_hz); - 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 with +1000 Hz space offset should be >= 95%, got {:.1}%", - accuracy * 100.0 - ); -} diff --git a/tnc-core/tests/goertzel_filter_test.rs b/tnc-core/tests/goertzel_filter_test.rs @@ -1,7 +1,6 @@ +use tnc_core::constants::{MARK_HZ, SAMPLE_RATE_HZ, SPACE_HZ}; use tnc_core::goertzel::GoertzelFilter; -const SAMPLE_RATE: f32 = 38460.; - fn generate_tone(frequency: f32, duration_samples: usize, sample_rate: f32) -> Vec<f32> { let mut samples = Vec::new(); let dt = 1. / sample_rate; @@ -17,7 +16,7 @@ fn mark_tone_gives_higher_mark_energy() { let mut mark = GoertzelFilter::mark(); let mut space = GoertzelFilter::space(); - let samples = generate_tone(1200.0, 32, SAMPLE_RATE); + let samples = generate_tone(MARK_HZ, 32, SAMPLE_RATE_HZ); for &sample in &samples { mark.update(sample); @@ -38,7 +37,7 @@ fn space_tone_gives_higher_space_energy() { let mut mark = GoertzelFilter::mark(); let mut space = GoertzelFilter::space(); - let samples = generate_tone(2200.0, 32, SAMPLE_RATE); + let samples = generate_tone(SPACE_HZ, 32, SAMPLE_RATE_HZ); for &sample in &samples { mark.update(sample); @@ -58,7 +57,7 @@ fn space_tone_gives_higher_space_energy() { fn filter_reset_clears_states() { let mut filter = GoertzelFilter::mark(); - let samples = generate_tone(1200.0, 32, SAMPLE_RATE); + let samples = generate_tone(MARK_HZ, 32, SAMPLE_RATE_HZ); for &sample in &samples { filter.update(sample); }