diff --git a/SDR.md b/SDR.md index d491829..821be1b 100644 --- a/SDR.md +++ b/SDR.md @@ -40,7 +40,7 @@ This document specifies the requirements for a SoapySDR-based RX-only backend (` | ID | Status | Task | Touches | Needs | |----|--------|------|---------|-------| -| SDR-10 | `[ ]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 | +| SDR-10 | `[x]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 | | SDR-11 | `[ ]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 | --- diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs index 173bc15..124880f 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs @@ -160,3 +160,170 @@ fn demod_cw(samples: &[Complex]) -> Vec { output } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use num_complex::Complex; + + fn complex_tone(freq_norm: f32, len: usize) -> Vec> { + use std::f32::consts::TAU; + (0..len) + .map(|n| Complex::from_polar(1.0, TAU * freq_norm * n as f32)) + .collect() + } + + fn assert_approx_eq(a: f32, b: f32, tol: f32, label: &str) { + assert!( + (a - b).abs() <= tol, + "{}: expected {} ≈ {} (tol {})", + label, + a, + b, + tol + ); + } + + // Test 1: USB and Passthrough return the real part of each sample. + #[test] + fn test_usb_passthrough_takes_real_part() { + let input = vec![ + Complex::new(1.0_f32, 2.0), + Complex::new(3.0, 4.0), + Complex::new(-1.0, 0.0), + Complex::new(0.0, -1.0), + ]; + let expected = vec![1.0_f32, 3.0, -1.0, 0.0]; + + let usb_out = Demodulator::Usb.demodulate(&input); + assert_eq!(usb_out, expected, "USB should return real parts"); + + let pass_out = Demodulator::Passthrough.demodulate(&input); + assert_eq!(pass_out, expected, "Passthrough should return real parts"); + } + + // Test 2: LSB returns the real part (mixing handled upstream). + #[test] + fn test_lsb_takes_real_part() { + let input = vec![ + Complex::new(1.0_f32, 2.0), + Complex::new(3.0, 4.0), + Complex::new(-1.0, 0.0), + Complex::new(0.0, -1.0), + ]; + let expected = vec![1.0_f32, 3.0, -1.0, 0.0]; + + let lsb_out = Demodulator::Lsb.demodulate(&input); + assert_eq!(lsb_out, expected, "LSB should return real parts"); + } + + // Test 3: AM on a constant-magnitude signal produces all zeros (DC removed). + #[test] + fn test_am_dc_removed() { + let input: Vec> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect(); + let out = Demodulator::Am.demodulate(&input); + assert_eq!(out.len(), 8); + for (i, &v) in out.iter().enumerate() { + assert_approx_eq(v, 0.0, 1e-6, &format!("AM DC removed sample {}", i)); + } + } + + // Test 4: AM on alternating-magnitude signal produces DC-centered output. + #[test] + fn test_am_varying_envelope() { + let input = vec![ + Complex::new(0.0_f32, 0.0), + Complex::new(1.0, 0.0), + Complex::new(0.0, 0.0), + Complex::new(1.0, 0.0), + ]; + let expected = vec![-0.5_f32, 0.5, -0.5, 0.5]; + let out = Demodulator::Am.demodulate(&input); + assert_eq!(out.len(), 4); + for (i, (&got, &exp)) in out.iter().zip(expected.iter()).enumerate() { + assert_approx_eq(got, exp, 1e-6, &format!("AM varying envelope sample {}", i)); + } + } + + // Test 5: FM discriminator on a pure tone at 0.25 cycles/sample. + // arg(s[n]*conj(s[n-1])) = 2π*0.25 = π/2; scaled by 1/π → 0.5. + #[test] + fn test_fm_tone_frequency() { + let input = complex_tone(0.25, 16); + let out = Demodulator::Fm.demodulate(&input); + assert_eq!(out.len(), 16); + // First sample is 0.0 by convention. + assert_approx_eq(out[0], 0.0, 1e-6, "FM tone sample 0 (zero by convention)"); + // Remaining samples should be approximately 0.5. + for i in 1..out.len() { + assert_approx_eq(out[i], 0.5, 0.01, &format!("FM tone sample {}", i)); + } + } + + // Test 6: FM discriminator on a DC (constant-phase) signal outputs all zeros. + #[test] + fn test_fm_silence_is_zero() { + let input: Vec> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect(); + let out = Demodulator::Fm.demodulate(&input); + assert_eq!(out.len(), 8); + for (i, &v) in out.iter().enumerate() { + assert_approx_eq(v, 0.0, 1e-6, &format!("FM silence sample {}", i)); + } + } + + // Test 7: CW envelope detector normalises peak to 1.0. + #[test] + fn test_cw_magnitude_envelope() { + let input = vec![ + Complex::new(3.0_f32, 4.0), // magnitude 5.0 + Complex::new(0.0, 0.0), // magnitude 0.0 + Complex::new(1.0, 0.0), // magnitude 1.0 + ]; + let out = Demodulator::Cw.demodulate(&input); + assert_eq!(out.len(), 3); + assert_approx_eq(out[0], 1.0, 1e-6, "CW sample 0"); + assert_approx_eq(out[1], 0.0, 1e-6, "CW sample 1"); + assert_approx_eq(out[2], 0.2, 1e-6, "CW sample 2"); + } + + // Test 8: Demodulator::for_mode maps each RigMode to the correct variant. + #[test] + fn test_demodulator_for_mode_mapping() { + assert_eq!(Demodulator::for_mode(&RigMode::USB), Demodulator::Usb); + assert_eq!(Demodulator::for_mode(&RigMode::LSB), Demodulator::Lsb); + assert_eq!(Demodulator::for_mode(&RigMode::AM), Demodulator::Am); + assert_eq!(Demodulator::for_mode(&RigMode::FM), Demodulator::Fm); + assert_eq!(Demodulator::for_mode(&RigMode::WFM), Demodulator::Wfm); + assert_eq!(Demodulator::for_mode(&RigMode::CW), Demodulator::Cw); + assert_eq!(Demodulator::for_mode(&RigMode::CWR), Demodulator::Cw); + assert_eq!(Demodulator::for_mode(&RigMode::DIG), Demodulator::Passthrough); + assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Passthrough); + } + + // Test 9: All demodulators return an empty Vec for empty input without panicking. + #[test] + fn test_empty_input() { + let empty: Vec> = Vec::new(); + let demodulators = [ + Demodulator::Usb, + Demodulator::Lsb, + Demodulator::Am, + Demodulator::Fm, + Demodulator::Wfm, + Demodulator::Cw, + Demodulator::Passthrough, + ]; + for demod in &demodulators { + let out = demod.demodulate(&empty); + assert!( + out.is_empty(), + "{:?} should return empty Vec for empty input", + demod + ); + } + } +}