[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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user