Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da799a1d1f | |||
| bcd3255ad7 | |||
| 46415fa307 | |||
| 8aa1884d2d | |||
| 7844cb65c8 | |||
| c71fc58e3e | |||
| 4464fa3735 | |||
| e1a9a8717f | |||
| 49659a5ce7 | |||
| d6d7c7d1f0 |
@@ -19,6 +19,7 @@ fn main() {
|
||||
.file(format!("{base}/ft8/constants.c"))
|
||||
.file(format!("{base}/ft8/crc.c"))
|
||||
.file(format!("{base}/ft8/decode.c"))
|
||||
.file(format!("{base}/ft8/encode.c"))
|
||||
.file(format!("{base}/ft8/ldpc.c"))
|
||||
.file(format!("{base}/ft8/message.c"))
|
||||
.file(format!("{base}/ft8/text.c"))
|
||||
@@ -42,6 +43,8 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed={base}/ft8/crc.h");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/decode.h");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/encode.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/encode.h");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/ldpc.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/ldpc.h");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/message.c");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#include <ft8/decode.h>
|
||||
#include <ft8/encode.h>
|
||||
#include <ft8/ldpc.h>
|
||||
#include <ft8/crc.h>
|
||||
#include <ft8/message.h>
|
||||
@@ -367,7 +368,7 @@ static int ft2_find_frequency_peaks(
|
||||
if (baseline[bin] <= 0.0f)
|
||||
continue;
|
||||
float value = smooth[bin] / baseline[bin];
|
||||
if (value < 1.08f)
|
||||
if (value < 1.03f)
|
||||
continue;
|
||||
if (!(value >= (smooth[bin - 1] / fmaxf(baseline[bin - 1], 1e-9f)) &&
|
||||
value >= (smooth[bin + 1] / fmaxf(baseline[bin + 1], 1e-9f))))
|
||||
@@ -675,7 +676,7 @@ static int ft2_find_scan_hits(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best_score < 0.60f)
|
||||
if (best_score < 0.50f)
|
||||
continue;
|
||||
|
||||
for (int idf = best_idf - 4; idf <= best_idf + 4; ++idf)
|
||||
@@ -693,7 +694,7 @@ static int ft2_find_scan_hits(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best_score < 0.60f)
|
||||
if (best_score < 0.50f)
|
||||
continue;
|
||||
|
||||
out[count].freq_hz = peaks[peak].freq_hz;
|
||||
@@ -754,9 +755,9 @@ static void ft2_normalize_log174(float* log174)
|
||||
if (variance <= 1.0e-12f)
|
||||
return;
|
||||
|
||||
float norm_factor = sqrtf(24.0f / variance);
|
||||
float sigma = sqrtf(variance);
|
||||
for (int i = 0; i < FTX_LDPC_N; ++i)
|
||||
log174[i] *= norm_factor;
|
||||
log174[i] /= sigma;
|
||||
}
|
||||
|
||||
static bool ft2_extract_bitmetrics_raw(const float complex* signal, float bitmetrics[2 * FT2_FRAME_SYMBOLS][3])
|
||||
@@ -979,11 +980,40 @@ static void ft2_encode_codeword_from_a91(const uint8_t a91[FTX_LDPC_K_BYTES], ui
|
||||
}
|
||||
|
||||
|
||||
static bool ft2_try_crc_candidate(const uint8_t a91[FTX_LDPC_K_BYTES], ftx_message_t* message)
|
||||
// Count how many of the 174 coded bits in a candidate codeword disagree
|
||||
// with the received hard decisions (sign of LLRs). A legitimate OSD
|
||||
// correction should disagree in very few positions; a false CRC match on
|
||||
// noise will disagree in ~half of the 83 parity positions.
|
||||
static int ft2_count_hard_errors_vs_llr(const float log174[FTX_LDPC_N], const uint8_t codeword[FTX_LDPC_N])
|
||||
{
|
||||
int errors = 0;
|
||||
for (int i = 0; i < FTX_LDPC_N; ++i)
|
||||
{
|
||||
uint8_t received = (log174[i] >= 0.0f) ? 1 : 0;
|
||||
if (received != codeword[i])
|
||||
++errors;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Maximum hard-error count for accepting an OSD result.
|
||||
// The (174,91) code has minimum distance ~20, so a legitimate near-threshold
|
||||
// decode should disagree in far fewer than 36 positions. Random CRC matches
|
||||
// typically disagree in ~40-50 parity positions alone.
|
||||
#define FT2_OSD_MAX_HARD_ERRORS 36
|
||||
|
||||
static bool ft2_try_crc_candidate(const uint8_t a91[FTX_LDPC_K_BYTES],
|
||||
const float log174[FTX_LDPC_N],
|
||||
ftx_message_t* message)
|
||||
{
|
||||
uint8_t codeword[FTX_LDPC_N];
|
||||
ft2_encode_codeword_from_a91(a91, codeword);
|
||||
return ft2_unpack_message(codeword, message);
|
||||
if (!ft2_unpack_message(codeword, message))
|
||||
return false;
|
||||
// Verify the codeword is consistent with what we actually received.
|
||||
if (log174 && ft2_count_hard_errors_vs_llr(log174, codeword) > FT2_OSD_MAX_HARD_ERRORS)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ft2_osd_lite_decode(const float log174[FTX_LDPC_N], ftx_message_t* message)
|
||||
@@ -996,7 +1026,7 @@ static bool ft2_osd_lite_decode(const float log174[FTX_LDPC_N], ftx_message_t* m
|
||||
base_a91[i / 8] |= (uint8_t)(0x80u >> (i % 8));
|
||||
}
|
||||
|
||||
if (ft2_try_crc_candidate(base_a91, message))
|
||||
if (ft2_try_crc_candidate(base_a91, log174, message))
|
||||
return true;
|
||||
|
||||
ft2_reliability_t reliabilities[FTX_LDPC_K];
|
||||
@@ -1017,7 +1047,7 @@ static bool ft2_osd_lite_decode(const float log174[FTX_LDPC_N], ftx_message_t* m
|
||||
memcpy(trial_a91, base_a91, sizeof(trial_a91));
|
||||
int b0 = reliabilities[i].index;
|
||||
trial_a91[b0 / 8] ^= (uint8_t)(0x80u >> (b0 % 8));
|
||||
if (ft2_try_crc_candidate(trial_a91, message))
|
||||
if (ft2_try_crc_candidate(trial_a91, log174, message))
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1031,7 +1061,7 @@ static bool ft2_osd_lite_decode(const float log174[FTX_LDPC_N], ftx_message_t* m
|
||||
int b1 = reliabilities[j].index;
|
||||
trial_a91[b0 / 8] ^= (uint8_t)(0x80u >> (b0 % 8));
|
||||
trial_a91[b1 / 8] ^= (uint8_t)(0x80u >> (b1 % 8));
|
||||
if (ft2_try_crc_candidate(trial_a91, message))
|
||||
if (ft2_try_crc_candidate(trial_a91, log174, message))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1059,6 +1089,85 @@ static bool ft2_unpack_message(const uint8_t plain174[], ftx_message_t* message)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compute post-decode SNR using all decoded symbols.
|
||||
//
|
||||
// For each symbol we know the exact transmitted tone (by re-encoding), so we
|
||||
// compare the signal-bin power against the minimum power of the remaining
|
||||
// (noise-only) bins. This avoids the systematic under-reporting caused by
|
||||
// using only the adjacent bin as a noise reference: on a crowded band that
|
||||
// adjacent bin is often occupied by another station, inflating the apparent
|
||||
// noise floor.
|
||||
//
|
||||
// The per-symbol dB differences are averaged across all valid symbols, then
|
||||
// converted to the WSJT-X convention (signal power relative to noise in a
|
||||
// 2500 Hz reference bandwidth).
|
||||
static float ftx_post_decode_snr(
|
||||
const ftx_waterfall_t* wf,
|
||||
const ftx_candidate_t* cand,
|
||||
const ftx_message_t* message)
|
||||
{
|
||||
int is_ft4 = ftx_protocol_uses_ft4_layout(wf->protocol);
|
||||
int nn = is_ft4 ? FT4_NN : FT8_NN;
|
||||
int num_tones = is_ft4 ? 4 : 8;
|
||||
|
||||
uint8_t tones[FT4_NN]; // FT4_NN (105) >= FT8_NN (79)
|
||||
if (is_ft4)
|
||||
ft4_encode(message->payload, tones);
|
||||
else
|
||||
ft8_encode(message->payload, tones);
|
||||
|
||||
// Replicate get_cand_mag() from decode.c (which is static there).
|
||||
int offset = cand->time_offset;
|
||||
offset = (offset * wf->time_osr) + cand->time_sub;
|
||||
offset = (offset * wf->freq_osr) + cand->freq_sub;
|
||||
offset = (offset * wf->num_bins) + cand->freq_offset;
|
||||
const WF_ELEM_T* mag_cand = wf->mag + offset;
|
||||
|
||||
float sum_snr = 0.0f;
|
||||
int n_valid = 0;
|
||||
|
||||
for (int sym = 0; sym < nn; sym++)
|
||||
{
|
||||
int block_abs = cand->time_offset + sym;
|
||||
if (block_abs < 0 || block_abs >= wf->num_blocks)
|
||||
continue;
|
||||
|
||||
const WF_ELEM_T* p = mag_cand + (sym * wf->block_stride);
|
||||
float sig_db = WF_ELEM_MAG(p[tones[sym]]);
|
||||
|
||||
float noise_min = 0.0f;
|
||||
int found_noise = 0;
|
||||
for (int t = 0; t < num_tones; t++)
|
||||
{
|
||||
if (t == tones[sym])
|
||||
continue;
|
||||
float db = WF_ELEM_MAG(p[t]);
|
||||
if (!found_noise || db < noise_min)
|
||||
{
|
||||
noise_min = db;
|
||||
found_noise = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (found_noise)
|
||||
{
|
||||
sum_snr += sig_db - noise_min;
|
||||
n_valid++;
|
||||
}
|
||||
}
|
||||
|
||||
if (n_valid == 0)
|
||||
return cand->score * 0.5f - 29.0f;
|
||||
|
||||
// bin_width_hz = 1 / (symbol_period * freq_osr)
|
||||
// bw_correction = 10*log10(2500 / bin_width_hz)
|
||||
// = 10*log10(2500 * symbol_period * freq_osr)
|
||||
float symbol_period = ftx_protocol_symbol_period(wf->protocol);
|
||||
float bw_correction = 10.0f * log10f(2500.0f * symbol_period * (float)wf->freq_osr);
|
||||
|
||||
return (sum_snr / (float)n_valid) - bw_correction;
|
||||
}
|
||||
|
||||
static int decode_from_waterfall_candidates(
|
||||
const ft8_decoder_t* dec,
|
||||
ft8_decode_result_t* out,
|
||||
@@ -1129,7 +1238,7 @@ static int decode_from_waterfall_candidates(
|
||||
dst->text[sizeof(dst->text) - 1] = '\0';
|
||||
dst->dt_s = time_sec;
|
||||
dst->freq_hz = freq_hz;
|
||||
dst->snr_db = cand->score * 0.5f - 29.0f;
|
||||
dst->snr_db = ftx_post_decode_snr(wf, cand, &message);
|
||||
|
||||
num_decoded++;
|
||||
}
|
||||
@@ -1206,7 +1315,7 @@ static bool ft2_decode_hit(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best_score < 0.80f)
|
||||
if (best_score < 0.65f)
|
||||
{
|
||||
if (fail_stage)
|
||||
*fail_stage = FT2_FAIL_REFINED_SYNC;
|
||||
@@ -1258,7 +1367,7 @@ static bool ft2_decode_hit(
|
||||
sync_qual += ((bitmetrics[132 + i][0] >= 0.0f) ? 1 : 0) == sync_bits_c[i];
|
||||
sync_qual += ((bitmetrics[198 + i][0] >= 0.0f) ? 1 : 0) == sync_bits_d[i];
|
||||
}
|
||||
if (sync_qual < 13)
|
||||
if (sync_qual < 10)
|
||||
{
|
||||
if (fail_stage)
|
||||
*fail_stage = FT2_FAIL_SYNC_QUAL;
|
||||
@@ -1282,9 +1391,9 @@ static bool ft2_decode_hit(
|
||||
}
|
||||
for (int i = 0; i < FTX_LDPC_N; ++i)
|
||||
{
|
||||
llr_passes[0][i] *= 2.83f;
|
||||
llr_passes[1][i] *= 2.83f;
|
||||
llr_passes[2][i] *= 2.83f;
|
||||
llr_passes[0][i] *= 3.2f;
|
||||
llr_passes[1][i] *= 3.2f;
|
||||
llr_passes[2][i] *= 3.2f;
|
||||
float a = llr_passes[0][i];
|
||||
float b = llr_passes[1][i];
|
||||
float c = llr_passes[2][i];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
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 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());
|
||||
};
|
||||
|
||||
Ok(vec![WsprDecodeResult {
|
||||
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: 0.0,
|
||||
freq_hz: base_hz,
|
||||
}])
|
||||
dt_s,
|
||||
freq_hz: freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,40 +151,42 @@ 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,17 @@ fn rev8(mut b: u8) -> u8 {
|
||||
///
|
||||
/// In WSPR: symbol = sync_bit + 2*data_bit, so data_bit = symbol >> 1.
|
||||
/// The 162 data bits are reordered via bit-reversal of 8-bit indices.
|
||||
///
|
||||
/// The interleaving places coded bit p at transmitted position j = rev8(i)
|
||||
/// (for each i in 0..255 where rev8(i) < 162). Deinterleaving reverses
|
||||
/// this: coded[p] = transmitted[j] >> 1.
|
||||
fn deinterleave(symbols: &[u8]) -> [u8; NSYMS] {
|
||||
let mut out = [0u8; NSYMS];
|
||||
let mut p = 0usize;
|
||||
for i in 0u8..=255 {
|
||||
let j = rev8(i) as usize;
|
||||
if j < NSYMS {
|
||||
out[j] = symbols[p] >> 1;
|
||||
out[p] = symbols[j] >> 1;
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,10 +872,10 @@
|
||||
<label class="bm-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
|
||||
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" />
|
||||
</label>
|
||||
<label class="bm-label">Primary bookmark
|
||||
<label class="bm-label bm-label-wide">Primary bookmark
|
||||
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
||||
</label>
|
||||
<label class="bm-label bm-label-wide">Extra channels (virtual)
|
||||
<label class="bm-label">Extra channels (virtual)
|
||||
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
|
||||
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
|
||||
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
--spectrum-plot-height: 160px;
|
||||
--jog-wheel-size: 83.2px;
|
||||
--header-waterfall-overlap: 0rem;
|
||||
--card-base-max-width: 1280px;
|
||||
--card-max-width: 1600px;
|
||||
--card-bookmark-gutter: 4rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -75,7 +78,7 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.card {
|
||||
width: min(100%, 1280px);
|
||||
width: min(100%, var(--card-base-max-width));
|
||||
margin: 0 auto;
|
||||
padding: 0.85rem 1.25rem 1.5rem;
|
||||
background: transparent;
|
||||
@@ -86,6 +89,15 @@ body {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.card {
|
||||
width: min(
|
||||
var(--card-max-width),
|
||||
calc(100vw - (var(--card-bookmark-gutter) * 2))
|
||||
);
|
||||
}
|
||||
}
|
||||
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||
#tab-main .label > span {
|
||||
display: inline-block;
|
||||
@@ -3426,6 +3438,12 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#sch-entry-form .bm-form-grid {
|
||||
gap: 0.75rem 0.75rem;
|
||||
}
|
||||
#sch-entry-form .bm-label {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
#bm-form-wrap,
|
||||
#sch-entry-form-wrap {
|
||||
position: fixed;
|
||||
@@ -3944,6 +3962,17 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sch-ts-table td:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sch-ts-table td:last-child button {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.sch-ts-table td:last-child button:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.sch-add-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user