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:
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");
-}