Compare commits

...

10 Commits

Author SHA1 Message Date
sjg da799a1d1f [fix](trx-frontend): widen main panel on large screens
Allow the main frontend card to grow past the previous 1280px cap
on large screens while keeping side gutters for bookmark stacks.

Only widen the desktop layout at 1440px+ and cap it at 1600px so the
main content stays readable with room for side bookmarks.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:07:36 +01:00
sjg bcd3255ad7 [fix](trx-wspr): fix reversed deinterleaving that prevented all decodes
The deinterleave function had its indices swapped: it wrote
out[j] = symbols[p] instead of out[p] = symbols[j]. This fed
completely scrambled data to the Fano decoder, making convergence
impossible. Matched against the reference implementation in
raptor/lib/wsprd/wsprd_utils.c which does tmp[p] = sym[j].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:48:06 +01:00
sjg 46415fa307 [fix](trx-ft8): add hard-error verification to FT2 OSD decoder
The OSD-lite decoder was the source of FT2 false positives. It tries
685 CRC-14 checks across 5 passes (1 + 16 + 120 per pass), giving a
~4% chance of accepting random noise as a valid decode.

The reference implementation (decode174_91) verifies OSD results
against the received signal; the trx-rs OSD-lite only checked CRC.

Add ft2_count_hard_errors_vs_llr() which counts how many of the 174
coded bits in an OSD candidate disagree with the received hard
decisions. A legitimate correction disagrees in very few positions;
a false CRC match on noise disagrees in ~40-50 parity positions.
Reject OSD results with more than 36 hard errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:44:21 +01:00
sjg 8aa1884d2d [fix](trx-ft8): align FT2 decoder thresholds and LLR scaling with reference
The FT2 decoder was failing to decode valid signals due to thresholds
and LLR parameters that were significantly stricter than the reference
implementation (Decodium/raptor ft2_triggered_decode).

Thresholds aligned with reference at depth>=3:
- Peak detection: 1.08 -> 1.03 (reference uses 0.50 on normalized spectrum)
- Scan sync: 0.60 -> 0.50 (reference at depth>=3)
- Decode sync: 0.80 -> 0.65 (reference at depth>=3)
- Sync quality min: 13 -> 10 (reference at depth>=3)

LLR parameters aligned:
- Scale factor: 2.83 -> 3.2 (matches reference scalefac)
- Normalization: sqrt(24/var) -> 1/sigma (matches reference normalizebmet)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:38:48 +01:00
sjg 7844cb65c8 [fix](trx-ft8): add missing encode.c to build
The ft8_wrapper.c references ft4_encode and ft8_encode from encode.c,
but encode.c was not included in build.rs, causing linker errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:29:42 +01:00
sjg c71fc58e3e [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>
2026-03-17 21:24:50 +01:00
sjg 4464fa3735 [fix](trx-ft8): replace adjacent-bin SNR with post-decode estimation
The previous SNR formula (cand->score * 0.5 - 29.0) used the adjacent
tone bin as a noise reference. On a crowded FT8 band that bin is often
occupied by another station, inflating the apparent noise floor by
10-15 dB and capping reported SNR at around -10 dB even for strong
signals.

Replace with ftx_post_decode_snr(): re-encode the decoded message to
obtain the exact per-symbol tone sequence, compare each signal bin
against the minimum of the remaining (noise-only) bins, average over
all valid symbols, and apply the WSJT-X 2500 Hz bandwidth correction
dynamically per protocol. This produces accurate SNR estimates for both
FT8 and FT4 regardless of band occupancy.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:06:43 +01:00
sjg e1a9a8717f [style](trx-frontend-http): fix picker widths in scheduler entry form
Primary bookmark was cramped at one grid column; Extra channels was
unnecessarily spanning the full row. Swap their grid-column spans so
the bookmark select gets full width and Extra channels sits in a
normal column alongside Label and Interleave.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:43:21 +01:00
sjg 49659a5ce7 [style](trx-frontend-http): increase spacing in scheduler entry form
Row gap 0.5→0.75rem and label-to-input gap 0.2→0.35rem, scoped
to #sch-entry-form so the bookmark form is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:42:42 +01:00
sjg d6d7c7d1f0 [style](trx-frontend-http): make scheduler entry Edit/Remove buttons smaller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:41:57 +01:00
6 changed files with 344 additions and 72 deletions
+3
View File
@@ -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");
+125 -16
View File
@@ -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];
+177 -50
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;
}
// 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}"
);
}
}
+5 -1
View File
@@ -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;
}