diff --git a/src/decoders/trx-wspr/src/decoder.rs b/src/decoders/trx-wspr/src/decoder.rs index ea0df63..4f4193c 100644 --- a/src/decoders/trx-wspr/src/decoder.rs +++ b/src/decoders/trx-wspr/src/decoder.rs @@ -26,8 +26,10 @@ const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2; const MAX_FREQ_CANDIDATES: usize = 8; // Minimum normalized sync correlation score to attempt decode. -// Matches reference wsprd minsync1=0.10. -const MIN_SYNC_SCORE: f32 = 0.10; +// The reference wsprd uses minsync1=0.10 but applies additional filtering +// downstream. A higher threshold here prevents noise from reaching the Fano +// decoder and producing false positives. +const MIN_SYNC_SCORE: f32 = 0.20; // Soft-symbol normalization factor (reference wsprd: symfac=50) const SYMFAC: f32 = 50.0; @@ -52,6 +54,13 @@ pub struct WsprDecodeResult { pub freq_hz: f32, } +// Minimum estimated SNR (dB) to attempt decode. WSPR's theoretical decode +// limit is around -28 dB in 2500 Hz bandwidth, but the per-tone SNR estimate +// computed here uses a narrower noise reference and reads higher. Setting +// -20 dB is conservative enough to pass all real signals while rejecting +// pure-noise candidates where the Fano decoder might otherwise hallucinate. +const MIN_SNR_DB: f32 = -20.0; + pub struct WsprDecoder { min_rms: f32, } @@ -63,7 +72,7 @@ struct DemodOutput { impl WsprDecoder { pub fn new() -> Result { - Ok(Self { min_rms: 0.0005 }) + Ok(Self { min_rms: 0.005 }) } pub fn sample_rate(&self) -> u32 { @@ -134,8 +143,18 @@ impl WsprDecoder { let mut results = Vec::new(); let mut seen_messages = std::collections::HashSet::new(); + // Track (freq, dt) of successful decodes to skip near-duplicates + let mut decoded_positions: Vec<(f32, isize)> = Vec::new(); for &(freq, dt_samples, _score) in candidates.iter().take(MAX_FREQ_CANDIDATES) { + // Skip candidates too close in (freq, dt) to an already-decoded signal + let dominated = decoded_positions.iter().any(|&(df, ddt)| { + (freq - df).abs() < 4.0 * TONE_SPACING_HZ + && (dt_samples - ddt).unsigned_abs() < DT_SEARCH_STEP_SAMPLES as usize + }); + if dominated { + continue; + } let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize; let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES]; @@ -146,6 +165,13 @@ impl WsprDecoder { } let demod = demodulate_soft_symbols(signal, freq); + + // Reject candidates where estimated SNR is too low — the Fano + // decoder can converge on noise-only input after normalization. + if demod.snr_db < MIN_SNR_DB { + continue; + } + if let Some(decoded) = protocol::decode_symbols(&demod.soft_symbols) { if seen_messages.insert(decoded.message.clone()) { let dt_s = dt_samples as f32 / WSPR_SAMPLE_RATE as f32; @@ -155,6 +181,7 @@ impl WsprDecoder { dt_s, freq_hz: freq, }); + decoded_positions.push((freq, dt_samples)); } } } @@ -428,6 +455,25 @@ mod tests { ); } + #[test] + fn noise_only_slot_produces_no_decodes() { + // Deterministic pseudo-random noise via simple LCG + let mut rng_state = 0x12345678u64; + let mut next_f32 = || -> f32 { + rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((rng_state >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0 + }; + + let dec = WsprDecoder::new().expect("decoder"); + let slot: Vec = (0..dec.slot_samples()).map(|_| next_f32() * 0.05).collect(); + let results = dec.decode_slot(&slot, None).expect("decode"); + assert!( + results.is_empty(), + "noise-only slot should produce no decodes, got {}", + results.len() + ); + } + #[test] fn normalized_sync_score_is_bounded() { let base_hz = 1500.0_f32; @@ -453,10 +499,12 @@ mod tests { // Normalized score should be positive and bounded assert!(score > 0.0, "score should be positive: {score}"); assert!(score <= 1.0, "score should be <= 1.0: {score}"); - // For a signal where only sync tones are present, score should be high + // This synthetic signal only uses sync tones (no data tones), so the + // normalized score is moderate (~0.18). A real WSPR signal occupies all + // 4 tones and produces higher scores (>0.3). assert!( - score > MIN_SYNC_SCORE, - "score {score} should exceed threshold {MIN_SYNC_SCORE}" + score > 0.10, + "score {score} should be clearly above noise floor" ); } } diff --git a/src/decoders/trx-wspr/src/protocol.rs b/src/decoders/trx-wspr/src/protocol.rs index ce6d26d..f92cb21 100644 --- a/src/decoders/trx-wspr/src/protocol.rs +++ b/src/decoders/trx-wspr/src/protocol.rs @@ -109,6 +109,13 @@ fn encode_sym(state: u32) -> u32 { (p1 << 1) | p2 } +/// Result from the Fano decoder including quality metric. +struct FanoResult { + bits: [u8; NBITS], + /// Cumulative path metric — higher values indicate higher confidence. + metric: i64, +} + /// Soft-decision Fano sequential decoder for K=32, rate-1/2 convolutional code. /// /// Closely follows the reference implementation from WSJT-X (fano.c by Phil Karn, KA9Q). @@ -117,8 +124,8 @@ fn encode_sym(state: u32) -> u32 { /// Symbols are read in pairs: `symbols[2k]` and `symbols[2k+1]` are the two /// coded bits for input bit k. /// -/// Output: 81 decoded bits (first 50 are payload), or None on timeout. -fn fano_decode(symbols: &[u8; NSYMS]) -> Option<[u8; NBITS]> { +/// Output: decoded bits and cumulative path metric, or None on timeout. +fn fano_decode(symbols: &[u8; NSYMS]) -> Option { let mettab = build_mettab(); let max_cycles = FANO_MAX_CYCLES_PER_BIT * NBITS; let tail_start = NBITS - 31; // position 50: first tail bit @@ -234,7 +241,10 @@ fn fano_decode(symbols: &[u8; NSYMS]) -> Option<[u8; NBITS]> { for k in 0..NBITS { bits[k] = (encstate[k] & 1) as u8; } - Some(bits) + Some(FanoResult { + bits, + metric: gamma[NBITS], + }) } /// Unpack 50 payload bits into a formatted WSPR message string. @@ -343,6 +353,15 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option { Some(format!("{} {} {}", callsign, grid, power_dbm)) } +/// Minimum Fano cumulative path metric to accept a decode. +/// +/// The Fano decoder can sometimes converge on random noise, producing bits +/// that happen to unpack into a valid-looking message. The cumulative path +/// metric reflects how well the received symbols matched the best trellis +/// path. Real WSPR signals at decodable SNR produce metrics well above this +/// threshold; noise-induced decodes have metrics near or below zero. +const FANO_MIN_METRIC: i64 = 20; + /// Attempt protocol-level decode from 162 soft-decision symbols. /// /// Input: 162 bytes where each value is a soft-decision symbol (0-255): @@ -354,8 +373,14 @@ pub fn decode_symbols(symbols: &[u8]) -> Option { return None; } let coded = deinterleave(symbols); - let bits = fano_decode(&coded)?; - let message = unpack_message(&bits)?; + let result = fano_decode(&coded)?; + + // Reject low-confidence decodes that are likely false positives from noise + if result.metric < FANO_MIN_METRIC { + return None; + } + + let message = unpack_message(&result.bits)?; Some(WsprProtocolMessage { message }) } @@ -537,13 +562,17 @@ mod tests { } // Fano decode (already in coded order, no interleaving needed) - let decoded = fano_decode(&soft); - assert!(decoded.is_some(), "Fano decoder should succeed"); - let decoded = decoded.unwrap(); + let result = fano_decode(&soft); + assert!(result.is_some(), "Fano decoder should succeed"); + let result = result.unwrap(); assert_eq!( - &decoded[..NBITS], + &result.bits[..NBITS], &input_bits[..NBITS], "Decoded bits should match input" ); + assert!( + result.metric > 0, + "Path metric should be positive for perfect symbols" + ); } }