[fix](trx-wspr): add sync vector correlation and multi-candidate decoding

The WSPR decoder was producing almost no valid decodes despite audible
signals. Three root causes:

- No sync vector: the 162-bit WSPR sync pattern was not used, so signal
  detection relied on raw peak power which is unreliable.
- Coarse frequency search: 4 Hz steps with 1.465 Hz tone spacing could
  miss signals entirely. Now uses 2 Hz coarse + 0.25 Hz fine refinement.
- Fixed timing: assumed signal starts exactly 1s into the slot. Now
  searches +/-2s in 0.5s steps to handle real-world timing jitter.

Also evaluates up to 8 frequency/timing candidates per slot and reports
the actual measured timing offset in dt_s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-17 21:24:50 +01:00
parent 4464fa3735
commit c71fc58e3e
+179 -52
View File
@@ -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<f32> {
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}"
);
}
}