Compare commits
10 Commits
8da4c49d1d
...
da799a1d1f
| 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/constants.c"))
|
||||||
.file(format!("{base}/ft8/crc.c"))
|
.file(format!("{base}/ft8/crc.c"))
|
||||||
.file(format!("{base}/ft8/decode.c"))
|
.file(format!("{base}/ft8/decode.c"))
|
||||||
|
.file(format!("{base}/ft8/encode.c"))
|
||||||
.file(format!("{base}/ft8/ldpc.c"))
|
.file(format!("{base}/ft8/ldpc.c"))
|
||||||
.file(format!("{base}/ft8/message.c"))
|
.file(format!("{base}/ft8/message.c"))
|
||||||
.file(format!("{base}/ft8/text.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/crc.h");
|
||||||
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
|
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/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.c");
|
||||||
println!("cargo:rerun-if-changed={base}/ft8/ldpc.h");
|
println!("cargo:rerun-if-changed={base}/ft8/ldpc.h");
|
||||||
println!("cargo:rerun-if-changed={base}/ft8/message.c");
|
println!("cargo:rerun-if-changed={base}/ft8/message.c");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
#include <ft8/decode.h>
|
#include <ft8/decode.h>
|
||||||
|
#include <ft8/encode.h>
|
||||||
#include <ft8/ldpc.h>
|
#include <ft8/ldpc.h>
|
||||||
#include <ft8/crc.h>
|
#include <ft8/crc.h>
|
||||||
#include <ft8/message.h>
|
#include <ft8/message.h>
|
||||||
@@ -367,7 +368,7 @@ static int ft2_find_frequency_peaks(
|
|||||||
if (baseline[bin] <= 0.0f)
|
if (baseline[bin] <= 0.0f)
|
||||||
continue;
|
continue;
|
||||||
float value = smooth[bin] / baseline[bin];
|
float value = smooth[bin] / baseline[bin];
|
||||||
if (value < 1.08f)
|
if (value < 1.03f)
|
||||||
continue;
|
continue;
|
||||||
if (!(value >= (smooth[bin - 1] / fmaxf(baseline[bin - 1], 1e-9f)) &&
|
if (!(value >= (smooth[bin - 1] / fmaxf(baseline[bin - 1], 1e-9f)) &&
|
||||||
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;
|
continue;
|
||||||
|
|
||||||
for (int idf = best_idf - 4; idf <= best_idf + 4; ++idf)
|
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;
|
continue;
|
||||||
|
|
||||||
out[count].freq_hz = peaks[peak].freq_hz;
|
out[count].freq_hz = peaks[peak].freq_hz;
|
||||||
@@ -754,9 +755,9 @@ static void ft2_normalize_log174(float* log174)
|
|||||||
if (variance <= 1.0e-12f)
|
if (variance <= 1.0e-12f)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
float norm_factor = sqrtf(24.0f / variance);
|
float sigma = sqrtf(variance);
|
||||||
for (int i = 0; i < FTX_LDPC_N; ++i)
|
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])
|
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];
|
uint8_t codeword[FTX_LDPC_N];
|
||||||
ft2_encode_codeword_from_a91(a91, codeword);
|
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)
|
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));
|
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;
|
return true;
|
||||||
|
|
||||||
ft2_reliability_t reliabilities[FTX_LDPC_K];
|
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));
|
memcpy(trial_a91, base_a91, sizeof(trial_a91));
|
||||||
int b0 = reliabilities[i].index;
|
int b0 = reliabilities[i].index;
|
||||||
trial_a91[b0 / 8] ^= (uint8_t)(0x80u >> (b0 % 8));
|
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;
|
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;
|
int b1 = reliabilities[j].index;
|
||||||
trial_a91[b0 / 8] ^= (uint8_t)(0x80u >> (b0 % 8));
|
trial_a91[b0 / 8] ^= (uint8_t)(0x80u >> (b0 % 8));
|
||||||
trial_a91[b1 / 8] ^= (uint8_t)(0x80u >> (b1 % 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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1089,85 @@ static bool ft2_unpack_message(const uint8_t plain174[], ftx_message_t* message)
|
|||||||
return true;
|
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(
|
static int decode_from_waterfall_candidates(
|
||||||
const ft8_decoder_t* dec,
|
const ft8_decoder_t* dec,
|
||||||
ft8_decode_result_t* out,
|
ft8_decode_result_t* out,
|
||||||
@@ -1129,7 +1238,7 @@ static int decode_from_waterfall_candidates(
|
|||||||
dst->text[sizeof(dst->text) - 1] = '\0';
|
dst->text[sizeof(dst->text) - 1] = '\0';
|
||||||
dst->dt_s = time_sec;
|
dst->dt_s = time_sec;
|
||||||
dst->freq_hz = freq_hz;
|
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++;
|
num_decoded++;
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1315,7 @@ static bool ft2_decode_hit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (best_score < 0.80f)
|
if (best_score < 0.65f)
|
||||||
{
|
{
|
||||||
if (fail_stage)
|
if (fail_stage)
|
||||||
*fail_stage = FT2_FAIL_REFINED_SYNC;
|
*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[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];
|
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)
|
if (fail_stage)
|
||||||
*fail_stage = FT2_FAIL_SYNC_QUAL;
|
*fail_stage = FT2_FAIL_SYNC_QUAL;
|
||||||
@@ -1282,9 +1391,9 @@ static bool ft2_decode_hit(
|
|||||||
}
|
}
|
||||||
for (int i = 0; i < FTX_LDPC_N; ++i)
|
for (int i = 0; i < FTX_LDPC_N; ++i)
|
||||||
{
|
{
|
||||||
llr_passes[0][i] *= 2.83f;
|
llr_passes[0][i] *= 3.2f;
|
||||||
llr_passes[1][i] *= 2.83f;
|
llr_passes[1][i] *= 3.2f;
|
||||||
llr_passes[2][i] *= 2.83f;
|
llr_passes[2][i] *= 3.2f;
|
||||||
float a = llr_passes[0][i];
|
float a = llr_passes[0][i];
|
||||||
float b = llr_passes[1][i];
|
float b = llr_passes[1][i];
|
||||||
float c = llr_passes[2][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.
|
// Coarse search range for base tone. This matches common WSPR audio passband.
|
||||||
const BASE_SEARCH_MIN_HZ: f32 = 1200.0;
|
const BASE_SEARCH_MIN_HZ: f32 = 1200.0;
|
||||||
const BASE_SEARCH_MAX_HZ: f32 = 1800.0;
|
const BASE_SEARCH_MAX_HZ: f32 = 1800.0;
|
||||||
const BASE_SEARCH_STEP_HZ: f32 = 4.0;
|
const BASE_SEARCH_STEP_HZ: f32 = 2.0;
|
||||||
const COARSE_SYMBOLS: usize = 48;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WsprDecodeResult {
|
pub struct WsprDecodeResult {
|
||||||
@@ -57,26 +76,72 @@ impl WsprDecoder {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = EXPECTED_SIGNAL_START_SAMPLES;
|
// Collect top frequency candidates across timing offsets
|
||||||
if start + WSPR_SIGNAL_SAMPLES > samples.len() {
|
let mut candidates: Vec<(f32, isize, f32)> = Vec::new(); // (freq, dt_samples, score)
|
||||||
return Ok(Vec::new());
|
|
||||||
|
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 {
|
// Sort candidates by score (best first) and try to decode each
|
||||||
return Ok(Vec::new());
|
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
};
|
|
||||||
let demod = demodulate_symbols(signal, base_hz);
|
|
||||||
let Some(decoded) = protocol::decode_symbols(&demod.symbols) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(vec![WsprDecodeResult {
|
let mut results = Vec::new();
|
||||||
message: decoded.message,
|
let mut seen_messages = std::collections::HashSet::new();
|
||||||
snr_db: demod.snr_db,
|
|
||||||
dt_s: 0.0,
|
for &(freq, dt_samples, _) in candidates.iter().take(MAX_FREQ_CANDIDATES) {
|
||||||
freq_hz: base_hz,
|
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,
|
snr_db: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn estimate_base_tone_hz(signal: &[f32]) -> Option<f32> {
|
/// Score a candidate base frequency by correlating detected symbol LSBs with
|
||||||
if signal.len() < WSPR_SYMBOL_SAMPLES * COARSE_SYMBOLS {
|
/// the known WSPR sync vector. Higher score = better match.
|
||||||
return None;
|
fn sync_correlation_score(signal: &[f32], base_hz: f32) -> f32 {
|
||||||
}
|
let nsyms = WSPR_SYMBOL_COUNT.min(signal.len() / WSPR_SYMBOL_SAMPLES);
|
||||||
|
|
||||||
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 {
|
|
||||||
let mut score = 0.0_f32;
|
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 off = sym * WSPR_SYMBOL_SAMPLES;
|
||||||
let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES];
|
let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES];
|
||||||
let mut best = 0.0_f32;
|
// Sum power in even tones (0,2) vs odd tones (1,3)
|
||||||
for tone in 0..4 {
|
let p0 = goertzel_power(frame, base_hz, WSPR_SAMPLE_RATE as f32);
|
||||||
let hz = base_hz + tone as f32 * TONE_SPACING_HZ;
|
let p2 = goertzel_power(
|
||||||
let p = goertzel_power(frame, hz, WSPR_SAMPLE_RATE as f32);
|
frame,
|
||||||
if p > best {
|
base_hz + 2.0 * TONE_SPACING_HZ,
|
||||||
best = p;
|
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
|
score
|
||||||
}
|
}
|
||||||
@@ -220,8 +287,8 @@ mod tests {
|
|||||||
let start = EXPECTED_SIGNAL_START_SAMPLES;
|
let start = EXPECTED_SIGNAL_START_SAMPLES;
|
||||||
|
|
||||||
for sym in 0..WSPR_SYMBOL_COUNT {
|
for sym in 0..WSPR_SYMBOL_COUNT {
|
||||||
let tone = (sym % 4) as f32;
|
let tone = SYNC_VECTOR[sym] + 2 * ((sym % 2) as u8);
|
||||||
let freq = base_hz + tone * TONE_SPACING_HZ;
|
let freq = base_hz + tone as f32 * TONE_SPACING_HZ;
|
||||||
let begin = start + sym * WSPR_SYMBOL_SAMPLES;
|
let begin = start + sym * WSPR_SYMBOL_SAMPLES;
|
||||||
for i in 0..WSPR_SYMBOL_SAMPLES {
|
for i in 0..WSPR_SYMBOL_SAMPLES {
|
||||||
let t = i as f32 / WSPR_SAMPLE_RATE as f32;
|
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 signal = &slot[start..start + WSPR_SIGNAL_SAMPLES];
|
||||||
let estimated = estimate_base_tone_hz(signal).expect("base tone");
|
let candidates = find_candidates(signal);
|
||||||
assert!((estimated - base_hz).abs() <= BASE_SEARCH_STEP_HZ);
|
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.
|
/// 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 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] {
|
fn deinterleave(symbols: &[u8]) -> [u8; NSYMS] {
|
||||||
let mut out = [0u8; NSYMS];
|
let mut out = [0u8; NSYMS];
|
||||||
let mut p = 0usize;
|
let mut p = 0usize;
|
||||||
for i in 0u8..=255 {
|
for i in 0u8..=255 {
|
||||||
let j = rev8(i) as usize;
|
let j = rev8(i) as usize;
|
||||||
if j < NSYMS {
|
if j < NSYMS {
|
||||||
out[j] = symbols[p] >> 1;
|
out[p] = symbols[j] >> 1;
|
||||||
p += 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)
|
<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" />
|
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" />
|
||||||
</label>
|
</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>
|
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
||||||
</label>
|
</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 id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
|
||||||
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
|
<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>
|
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
--spectrum-plot-height: 160px;
|
--spectrum-plot-height: 160px;
|
||||||
--jog-wheel-size: 83.2px;
|
--jog-wheel-size: 83.2px;
|
||||||
--header-waterfall-overlap: 0rem;
|
--header-waterfall-overlap: 0rem;
|
||||||
|
--card-base-max-width: 1280px;
|
||||||
|
--card-max-width: 1600px;
|
||||||
|
--card-bookmark-gutter: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
@@ -75,7 +78,7 @@ body {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
width: min(100%, 1280px);
|
width: min(100%, var(--card-base-max-width));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.85rem 1.25rem 1.5rem;
|
padding: 0.85rem 1.25rem 1.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -86,6 +89,15 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: visible;
|
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; }
|
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||||
#tab-main .label > span {
|
#tab-main .label > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -3426,6 +3438,12 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
white-space: nowrap;
|
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,
|
#bm-form-wrap,
|
||||||
#sch-entry-form-wrap {
|
#sch-entry-form-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3944,6 +3962,17 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
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 {
|
.sch-add-row {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user