diff --git a/src/decoders/trx-wspr/src/decoder.rs b/src/decoders/trx-wspr/src/decoder.rs index 2750991..2535c5e 100644 --- a/src/decoders/trx-wspr/src/decoder.rs +++ b/src/decoders/trx-wspr/src/decoder.rs @@ -15,8 +15,27 @@ const TONE_SPACING_HZ: f32 = WSPR_SAMPLE_RATE as f32 / WSPR_SYMBOL_SAMPLES as f3 // Coarse search range for base tone. This matches common WSPR audio passband. const BASE_SEARCH_MIN_HZ: f32 = 1200.0; const BASE_SEARCH_MAX_HZ: f32 = 1800.0; -const BASE_SEARCH_STEP_HZ: f32 = 4.0; -const COARSE_SYMBOLS: usize = 48; +const BASE_SEARCH_STEP_HZ: f32 = 2.0; +const FINE_SEARCH_STEP_HZ: f32 = 0.25; + +// Timing offset search: search ±2s in 0.5s steps (4800 samples at 12 kHz) +const DT_SEARCH_RANGE_SAMPLES: isize = 2 * WSPR_SAMPLE_RATE as isize; +const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2; + +// Number of top frequency candidates to try full decode on +const MAX_FREQ_CANDIDATES: usize = 8; + +/// WSPR sync vector (162 bits). symbol = sync[i] + 2*data[i]. +/// The LSB of each received symbol should match this pattern. +#[rustfmt::skip] +const SYNC_VECTOR: [u8; 162] = [ + 1,1,0,0,0,0,0,0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,1,1,1,0,0,0, + 0,0,0,0,1,0,0,1,0,1,0,0,0,0,0,0,1,0,1,1,0,0,1,1,0,1,0,0,0,1, + 1,0,1,0,0,0,0,1,1,0,1,0,1,0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1, + 1,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1, + 0,1,0,0,0,1,1,1,0,0,0,0,0,1,0,1,0,0,1,1,0,0,0,0,0,0,0,1,1,0, + 1,0,1,1,0,0,0,1,1,0,0,1, +]; #[derive(Debug, Clone)] pub struct WsprDecodeResult { @@ -57,26 +76,72 @@ impl WsprDecoder { return Ok(Vec::new()); } - let start = EXPECTED_SIGNAL_START_SAMPLES; - if start + WSPR_SIGNAL_SAMPLES > samples.len() { - return Ok(Vec::new()); + // Collect top frequency candidates across timing offsets + let mut candidates: Vec<(f32, isize, f32)> = Vec::new(); // (freq, dt_samples, score) + + let mut dt = -DT_SEARCH_RANGE_SAMPLES; + while dt <= DT_SEARCH_RANGE_SAMPLES { + let start = EXPECTED_SIGNAL_START_SAMPLES as isize + dt; + if start < 0 || (start as usize) + WSPR_SIGNAL_SAMPLES > samples.len() { + dt += DT_SEARCH_STEP_SAMPLES; + continue; + } + let signal = &samples[start as usize..start as usize + WSPR_SIGNAL_SAMPLES]; + + // Coarse frequency search using sync vector correlation + let mut freq_scores: Vec<(f32, f32)> = Vec::new(); + let mut freq = BASE_SEARCH_MIN_HZ; + while freq <= BASE_SEARCH_MAX_HZ { + let score = sync_correlation_score(signal, freq); + freq_scores.push((freq, score)); + freq += BASE_SEARCH_STEP_HZ; + } + + // Keep top candidates from coarse search + freq_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + for &(coarse_freq, _) in freq_scores.iter().take(3) { + // Fine-tune frequency around each coarse candidate + let mut best_fine_freq = coarse_freq; + let mut best_fine_score = f32::MIN; + let mut fine_freq = coarse_freq - BASE_SEARCH_STEP_HZ; + while fine_freq <= coarse_freq + BASE_SEARCH_STEP_HZ { + let score = sync_correlation_score(signal, fine_freq); + if score > best_fine_score { + best_fine_score = score; + best_fine_freq = fine_freq; + } + fine_freq += FINE_SEARCH_STEP_HZ; + } + candidates.push((best_fine_freq, dt, best_fine_score)); + } + dt += DT_SEARCH_STEP_SAMPLES; } - let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES]; - let Some(base_hz) = estimate_base_tone_hz(signal) else { - return Ok(Vec::new()); - }; - let demod = demodulate_symbols(signal, base_hz); - let Some(decoded) = protocol::decode_symbols(&demod.symbols) else { - return Ok(Vec::new()); - }; + // Sort candidates by score (best first) and try to decode each + candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); - Ok(vec![WsprDecodeResult { - message: decoded.message, - snr_db: demod.snr_db, - dt_s: 0.0, - freq_hz: base_hz, - }]) + let mut results = Vec::new(); + let mut seen_messages = std::collections::HashSet::new(); + + for &(freq, dt_samples, _) in candidates.iter().take(MAX_FREQ_CANDIDATES) { + let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize; + let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES]; + + let demod = demodulate_symbols(signal, freq); + if let Some(decoded) = protocol::decode_symbols(&demod.symbols) { + if seen_messages.insert(decoded.message.clone()) { + let dt_s = dt_samples as f32 / WSPR_SAMPLE_RATE as f32; + results.push(WsprDecodeResult { + message: decoded.message, + snr_db: demod.snr_db, + dt_s, + freq_hz: freq, + }); + } + } + } + + Ok(results) } } @@ -86,39 +151,41 @@ struct DemodOutput { snr_db: f32, } -fn estimate_base_tone_hz(signal: &[f32]) -> Option { - if signal.len() < WSPR_SYMBOL_SAMPLES * COARSE_SYMBOLS { - return None; - } - - let mut best_freq = BASE_SEARCH_MIN_HZ; - let mut best_score = f32::MIN; - let mut freq = BASE_SEARCH_MIN_HZ; - while freq <= BASE_SEARCH_MAX_HZ { - let score = coarse_score(signal, freq); - if score > best_score { - best_score = score; - best_freq = freq; - } - freq += BASE_SEARCH_STEP_HZ; - } - Some(best_freq) -} - -fn coarse_score(signal: &[f32], base_hz: f32) -> f32 { +/// Score a candidate base frequency by correlating detected symbol LSBs with +/// the known WSPR sync vector. Higher score = better match. +fn sync_correlation_score(signal: &[f32], base_hz: f32) -> f32 { + let nsyms = WSPR_SYMBOL_COUNT.min(signal.len() / WSPR_SYMBOL_SAMPLES); let mut score = 0.0_f32; - for sym in 0..COARSE_SYMBOLS { + for (sym, &sync_bit) in SYNC_VECTOR.iter().enumerate().take(nsyms) { let off = sym * WSPR_SYMBOL_SAMPLES; let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES]; - let mut best = 0.0_f32; - for tone in 0..4 { - let hz = base_hz + tone as f32 * TONE_SPACING_HZ; - let p = goertzel_power(frame, hz, WSPR_SAMPLE_RATE as f32); - if p > best { - best = p; - } + // Sum power in even tones (0,2) vs odd tones (1,3) + let p0 = goertzel_power(frame, base_hz, WSPR_SAMPLE_RATE as f32); + let p2 = goertzel_power( + frame, + base_hz + 2.0 * TONE_SPACING_HZ, + WSPR_SAMPLE_RATE as f32, + ); + let p1 = goertzel_power( + frame, + base_hz + TONE_SPACING_HZ, + WSPR_SAMPLE_RATE as f32, + ); + let p3 = goertzel_power( + frame, + base_hz + 3.0 * TONE_SPACING_HZ, + WSPR_SAMPLE_RATE as f32, + ); + + let even_power = p0 + p2; // tones with LSB=0 + let odd_power = p1 + p3; // tones with LSB=1 + + // Correlate with sync vector: sync=1 means odd tone expected + if sync_bit == 1 { + score += odd_power - even_power; + } else { + score += even_power - odd_power; } - score += best; } score } @@ -220,8 +287,8 @@ mod tests { let start = EXPECTED_SIGNAL_START_SAMPLES; for sym in 0..WSPR_SYMBOL_COUNT { - let tone = (sym % 4) as f32; - let freq = base_hz + tone * TONE_SPACING_HZ; + let tone = SYNC_VECTOR[sym] + 2 * ((sym % 2) as u8); + let freq = base_hz + tone as f32 * TONE_SPACING_HZ; let begin = start + sym * WSPR_SYMBOL_SAMPLES; for i in 0..WSPR_SYMBOL_SAMPLES { let t = i as f32 / WSPR_SAMPLE_RATE as f32; @@ -230,7 +297,67 @@ mod tests { } let signal = &slot[start..start + WSPR_SIGNAL_SAMPLES]; - let estimated = estimate_base_tone_hz(signal).expect("base tone"); - assert!((estimated - base_hz).abs() <= BASE_SEARCH_STEP_HZ); + let candidates = find_candidates(signal); + assert!(!candidates.is_empty()); + let (estimated, _) = candidates[0]; + assert!( + (estimated - base_hz).abs() <= 1.0, + "estimated {estimated} Hz, expected {base_hz} Hz" + ); + } + + /// Helper: run the candidate search on a signal slice + fn find_candidates(signal: &[f32]) -> Vec<(f32, f32)> { + let mut freq_scores: Vec<(f32, f32)> = Vec::new(); + let mut freq = BASE_SEARCH_MIN_HZ; + while freq <= BASE_SEARCH_MAX_HZ { + let score = sync_correlation_score(signal, freq); + freq_scores.push((freq, score)); + freq += BASE_SEARCH_STEP_HZ; + } + freq_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Fine-tune top result + if let Some(&(coarse_freq, _)) = freq_scores.first() { + let mut best_fine_freq = coarse_freq; + let mut best_fine_score = f32::MIN; + let mut fine_freq = coarse_freq - BASE_SEARCH_STEP_HZ; + while fine_freq <= coarse_freq + BASE_SEARCH_STEP_HZ { + let score = sync_correlation_score(signal, fine_freq); + if score > best_fine_score { + best_fine_score = score; + best_fine_freq = fine_freq; + } + fine_freq += FINE_SEARCH_STEP_HZ; + } + vec![(best_fine_freq, best_fine_score)] + } else { + vec![] + } + } + + #[test] + fn sync_correlation_prefers_correct_frequency() { + let base_hz = 1500.0_f32; + let wrong_hz = 1400.0_f32; + + // Generate a synthetic WSPR-like signal using the sync vector + let mut signal = vec![0.0_f32; WSPR_SIGNAL_SAMPLES]; + for sym in 0..WSPR_SYMBOL_COUNT { + let tone = SYNC_VECTOR[sym]; // just sync, no data + let freq = base_hz + tone as f32 * TONE_SPACING_HZ; + let begin = sym * WSPR_SYMBOL_SAMPLES; + for i in 0..WSPR_SYMBOL_SAMPLES { + let t = i as f32 / WSPR_SAMPLE_RATE as f32; + signal[begin + i] = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.2; + } + } + + let correct_score = sync_correlation_score(&signal, base_hz); + let wrong_score = sync_correlation_score(&signal, wrong_hz); + assert!( + correct_score > wrong_score, + "correct={correct_score}, wrong={wrong_score}" + ); } }