[refactor](trx-ftx): reorganize into common/, ft8/, ft4/, ft2/ modules
Split flat src/ layout into protocol-oriented directory structure: - common/: shared types, constants, LDPC/OSD decoders, monitor, message, CRC - ft8/: FT8-specific sync scoring, likelihood extraction, tone encoding - ft4/: FT4-specific sync scoring, likelihood extraction, tone encoding - ft2/: FT2 pipeline, waterfall decode, bitmetrics, downsample, sync - Top-level: lib.rs (mod declarations) and decoder.rs (public API) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
//! This is a pure Rust port of the callsign hash table from
|
//! This is a pure Rust port of the callsign hash table from
|
||||||
//! `ft8_lib/ft8/ft8_wrapper.c`.
|
//! `ft8_lib/ft8/ft8_wrapper.c`.
|
||||||
|
|
||||||
use crate::text::{nchar, CharTable};
|
use super::text::{nchar, CharTable};
|
||||||
|
|
||||||
/// Size of the callsign hash table (number of slots).
|
/// Size of the callsign hash table (number of slots).
|
||||||
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
|
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
use crate::protocol::{FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
use super::protocol::{FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||||
|
|
||||||
/// Costas sync tone pattern for FT8 (7 tones).
|
/// Costas sync tone pattern for FT8 (7 tones).
|
||||||
pub const FT8_COSTAS_PATTERN: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
|
pub const FT8_COSTAS_PATTERN: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
use crate::protocol::{FT8_CRC_POLYNOMIAL, FT8_CRC_WIDTH};
|
use super::protocol::{FT8_CRC_POLYNOMIAL, FT8_CRC_WIDTH};
|
||||||
|
|
||||||
const TOPBIT: u16 = 1 << (FT8_CRC_WIDTH - 1);
|
const TOPBIT: u16 = 1 << (FT8_CRC_WIDTH - 1);
|
||||||
|
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Candidate search, shared decode helpers, and dispatcher functions for FTx decoding.
|
||||||
|
//!
|
||||||
|
//! Ports `decode.c` from ft8_lib.
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
|
||||||
|
use super::constants::*;
|
||||||
|
use super::monitor::{Waterfall, WfElem};
|
||||||
|
use super::protocol::*;
|
||||||
|
|
||||||
|
/// Candidate position in time and frequency.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct Candidate {
|
||||||
|
pub score: i16,
|
||||||
|
pub time_offset: i16,
|
||||||
|
pub freq_offset: i16,
|
||||||
|
pub time_sub: u8,
|
||||||
|
pub freq_sub: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode status information.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DecodeStatus {
|
||||||
|
pub ldpc_errors: i32,
|
||||||
|
pub crc_extracted: u16,
|
||||||
|
pub crc_calculated: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message payload (77 bits packed into 10 bytes) with dedup hash.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct FtxMessage {
|
||||||
|
pub payload: [u8; FTX_PAYLOAD_LENGTH_BYTES],
|
||||||
|
pub hash: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wf_elem_to_complex(elem: WfElem) -> Complex32 {
|
||||||
|
Complex32::new(elem.re, elem.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_cand_offset(wf: &Waterfall, cand: &Candidate) -> usize {
|
||||||
|
let offset = cand.time_offset as isize;
|
||||||
|
let offset = offset * wf.time_osr as isize + cand.time_sub as isize;
|
||||||
|
let offset = offset * wf.freq_osr as isize + cand.freq_sub as isize;
|
||||||
|
let offset = offset * wf.num_bins as isize + cand.freq_offset as isize;
|
||||||
|
offset.max(0) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wf_mag_at(wf: &Waterfall, base: usize, idx: isize) -> &WfElem {
|
||||||
|
let i = (base as isize + idx).max(0) as usize;
|
||||||
|
if i < wf.mag.len() {
|
||||||
|
&wf.mag[i]
|
||||||
|
} else {
|
||||||
|
&DEFAULT_WF_ELEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaked reference for out-of-bounds default
|
||||||
|
pub(crate) static DEFAULT_WF_ELEM: WfElem = WfElem {
|
||||||
|
mag: -120.0,
|
||||||
|
phase: 0.0,
|
||||||
|
re: 0.0,
|
||||||
|
im: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn wf_mag_safe(wf: &Waterfall, idx: usize) -> &WfElem {
|
||||||
|
if idx < wf.mag.len() {
|
||||||
|
&wf.mag[idx]
|
||||||
|
} else {
|
||||||
|
&DEFAULT_WF_ELEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Min-heap operations for candidate list.
|
||||||
|
fn heapify_down(heap: &mut [Candidate], size: usize) {
|
||||||
|
let mut current = 0;
|
||||||
|
loop {
|
||||||
|
let left = 2 * current + 1;
|
||||||
|
let right = left + 1;
|
||||||
|
let mut smallest = current;
|
||||||
|
if left < size && heap[left].score < heap[smallest].score {
|
||||||
|
smallest = left;
|
||||||
|
}
|
||||||
|
if right < size && heap[right].score < heap[smallest].score {
|
||||||
|
smallest = right;
|
||||||
|
}
|
||||||
|
if smallest == current {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
heap.swap(current, smallest);
|
||||||
|
current = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heapify_up(heap: &mut [Candidate], size: usize) {
|
||||||
|
let mut current = size - 1;
|
||||||
|
while current > 0 {
|
||||||
|
let parent = (current - 1) / 2;
|
||||||
|
if heap[current].score >= heap[parent].score {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
heap.swap(current, parent);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find candidate signals in the waterfall. Returns sorted candidates (best first).
|
||||||
|
pub fn ftx_find_candidates(
|
||||||
|
wf: &Waterfall,
|
||||||
|
max_candidates: usize,
|
||||||
|
min_score: i32,
|
||||||
|
) -> Vec<Candidate> {
|
||||||
|
let is_ft2 = wf.protocol == FtxProtocol::Ft2;
|
||||||
|
let num_tones = if wf.protocol.uses_ft4_layout() { 4 } else { 8 };
|
||||||
|
|
||||||
|
let (time_offset_min, time_offset_max) = if is_ft2 {
|
||||||
|
let max = (wf.num_blocks as i32 - FT2_NN as i32 + 2).max(-1);
|
||||||
|
(-2i16, max as i16)
|
||||||
|
} else if wf.protocol == FtxProtocol::Ft4 {
|
||||||
|
let max = (wf.num_blocks as i32 - FT4_NN as i32 + 34).max(-33);
|
||||||
|
(-34i16, max as i16)
|
||||||
|
} else {
|
||||||
|
(-10i16, 20i16)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut heap = vec![Candidate::default(); max_candidates];
|
||||||
|
let mut heap_size = 0;
|
||||||
|
|
||||||
|
for time_sub in 0..wf.time_osr as u8 {
|
||||||
|
for freq_sub in 0..wf.freq_osr as u8 {
|
||||||
|
let mut time_offset = time_offset_min;
|
||||||
|
while time_offset < time_offset_max {
|
||||||
|
let mut freq_offset: i16 = 0;
|
||||||
|
while (freq_offset as usize + num_tones - 1) < wf.num_bins {
|
||||||
|
let cand = Candidate {
|
||||||
|
score: 0,
|
||||||
|
time_offset,
|
||||||
|
freq_offset,
|
||||||
|
time_sub,
|
||||||
|
freq_sub,
|
||||||
|
};
|
||||||
|
|
||||||
|
let score = if is_ft2 {
|
||||||
|
crate::ft2::ft2_sync_score(wf, &cand)
|
||||||
|
} else if wf.protocol.uses_ft4_layout() {
|
||||||
|
crate::ft4::ft4_sync_score(wf, &cand)
|
||||||
|
} else {
|
||||||
|
crate::ft8::ft8_sync_score(wf, &cand)
|
||||||
|
};
|
||||||
|
|
||||||
|
if score >= min_score {
|
||||||
|
if heap_size == max_candidates && score > heap[0].score as i32 {
|
||||||
|
heap_size -= 1;
|
||||||
|
heap[0] = heap[heap_size];
|
||||||
|
heapify_down(&mut heap, heap_size);
|
||||||
|
}
|
||||||
|
if heap_size < max_candidates {
|
||||||
|
heap[heap_size] = Candidate {
|
||||||
|
score: score as i16,
|
||||||
|
time_offset,
|
||||||
|
freq_offset,
|
||||||
|
time_sub,
|
||||||
|
freq_sub,
|
||||||
|
};
|
||||||
|
heap_size += 1;
|
||||||
|
heapify_up(&mut heap, heap_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
freq_offset += 1;
|
||||||
|
}
|
||||||
|
time_offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by descending score (heap sort)
|
||||||
|
let mut len_unsorted = heap_size;
|
||||||
|
while len_unsorted > 1 {
|
||||||
|
heap.swap(0, len_unsorted - 1);
|
||||||
|
len_unsorted -= 1;
|
||||||
|
heapify_down(&mut heap, len_unsorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
heap.truncate(heap_size);
|
||||||
|
heap
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify CRC of a 174-bit plaintext and build an FtxMessage.
|
||||||
|
///
|
||||||
|
/// `plain174`: decoded LDPC codeword (174 bits, each 0 or 1).
|
||||||
|
/// `uses_xor`: true for FT4/FT2 (apply XOR sequence), false for FT8.
|
||||||
|
///
|
||||||
|
/// Returns `None` if CRC check fails.
|
||||||
|
pub(crate) fn verify_crc_and_build_message(
|
||||||
|
plain174: &[u8; FTX_LDPC_N],
|
||||||
|
uses_xor: bool,
|
||||||
|
) -> Option<FtxMessage> {
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits(plain174, FTX_LDPC_K, &mut a91);
|
||||||
|
|
||||||
|
let a91_orig = a91;
|
||||||
|
let crc_extracted = super::crc::ftx_extract_crc(&a91);
|
||||||
|
a91[9] &= 0xF8;
|
||||||
|
a91[10] = 0x00;
|
||||||
|
let crc_calculated = super::crc::ftx_compute_crc(&a91, 96 - 14);
|
||||||
|
|
||||||
|
if crc_extracted != crc_calculated {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let a91 = a91_orig;
|
||||||
|
|
||||||
|
let mut message = FtxMessage {
|
||||||
|
hash: crc_calculated,
|
||||||
|
payload: [0; FTX_PAYLOAD_LENGTH_BYTES],
|
||||||
|
};
|
||||||
|
|
||||||
|
if uses_xor {
|
||||||
|
for i in 0..10 {
|
||||||
|
message.payload[i] = a91[i] ^ FT4_XOR_SEQUENCE[i];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.payload[..10].copy_from_slice(&a91[..10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize log-likelihoods.
|
||||||
|
fn ftx_normalize_logl(log174: &mut [f32; FTX_LDPC_N]) {
|
||||||
|
let mut sum = 0.0f32;
|
||||||
|
let mut sum2 = 0.0f32;
|
||||||
|
for &v in log174.iter() {
|
||||||
|
sum += v;
|
||||||
|
sum2 += v * v;
|
||||||
|
}
|
||||||
|
let inv_n = 1.0 / FTX_LDPC_N as f32;
|
||||||
|
let variance = (sum2 - sum * sum * inv_n) * inv_n;
|
||||||
|
if variance > 0.0 {
|
||||||
|
let norm_factor = (24.0 / variance).sqrt();
|
||||||
|
for v in log174.iter_mut() {
|
||||||
|
*v *= norm_factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack bits into bytes (MSB first).
|
||||||
|
pub fn pack_bits(bit_array: &[u8], num_bits: usize, packed: &mut [u8]) {
|
||||||
|
let num_bytes = num_bits.div_ceil(8);
|
||||||
|
for b in packed[..num_bytes].iter_mut() {
|
||||||
|
*b = 0;
|
||||||
|
}
|
||||||
|
let mut mask: u8 = 0x80;
|
||||||
|
let mut byte_idx = 0;
|
||||||
|
for i in 0..num_bits {
|
||||||
|
if bit_array[i] != 0 {
|
||||||
|
packed[byte_idx] |= mask;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
byte_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to decode a candidate. Returns decoded message or None.
|
||||||
|
pub fn ftx_decode_candidate(
|
||||||
|
wf: &Waterfall,
|
||||||
|
cand: &Candidate,
|
||||||
|
max_iterations: usize,
|
||||||
|
) -> Option<FtxMessage> {
|
||||||
|
let mut log174 = [0.0f32; FTX_LDPC_N];
|
||||||
|
|
||||||
|
if wf.protocol == FtxProtocol::Ft2 {
|
||||||
|
crate::ft2::ft2_extract_likelihood(wf, cand, &mut log174);
|
||||||
|
} else if wf.protocol.uses_ft4_layout() {
|
||||||
|
crate::ft4::ft4_extract_likelihood(wf, cand, &mut log174);
|
||||||
|
} else {
|
||||||
|
crate::ft8::ft8_extract_likelihood(wf, cand, &mut log174);
|
||||||
|
}
|
||||||
|
|
||||||
|
ftx_normalize_logl(&mut log174);
|
||||||
|
|
||||||
|
let mut plain174 = [0u8; FTX_LDPC_N];
|
||||||
|
let errors = super::ldpc::bp_decode(&log174, max_iterations, &mut plain174);
|
||||||
|
if errors > 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_crc_and_build_message(&plain174, wf.protocol.uses_ft4_layout())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute post-decode SNR.
|
||||||
|
pub fn ftx_post_decode_snr(wf: &Waterfall, cand: &Candidate, message: &FtxMessage) -> f32 {
|
||||||
|
let is_ft4 = wf.protocol.uses_ft4_layout();
|
||||||
|
let nn = if is_ft4 { FT4_NN } else { FT8_NN };
|
||||||
|
let num_tones = if is_ft4 { 4 } else { 8 };
|
||||||
|
|
||||||
|
let mut tones = [0u8; FT4_NN]; // FT4_NN >= FT8_NN
|
||||||
|
if is_ft4 {
|
||||||
|
crate::ft4::ft4_encode(&message.payload, &mut tones);
|
||||||
|
} else {
|
||||||
|
crate::ft8::ft8_encode(&message.payload, &mut tones);
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
let mut sum_snr = 0.0f32;
|
||||||
|
let mut n_valid = 0;
|
||||||
|
|
||||||
|
for sym in 0..nn {
|
||||||
|
let block_abs = cand.time_offset as i32 + sym as i32;
|
||||||
|
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_offset = base + sym * wf.block_stride;
|
||||||
|
let sig_db = wf_mag_safe(wf, p_offset + tones[sym] as usize).mag;
|
||||||
|
|
||||||
|
let mut noise_min = 0.0f32;
|
||||||
|
let mut found_noise = false;
|
||||||
|
for t in 0..num_tones {
|
||||||
|
if t == tones[sym] as usize {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let db = wf_mag_safe(wf, p_offset + t).mag;
|
||||||
|
if !found_noise || db < noise_min {
|
||||||
|
noise_min = db;
|
||||||
|
found_noise = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_noise {
|
||||||
|
sum_snr += sig_db - noise_min;
|
||||||
|
n_valid += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n_valid == 0 {
|
||||||
|
return cand.score as f32 * 0.5 - 29.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbol_period = wf.protocol.symbol_period();
|
||||||
|
let bw_correction = 10.0 * (2500.0 * symbol_period * wf.freq_osr as f32).log10();
|
||||||
|
sum_snr / n_valid as f32 - bw_correction
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Shared LDPC encoding functions used by all FTx protocols.
|
||||||
|
|
||||||
|
use super::constants::FTX_LDPC_GENERATOR;
|
||||||
|
use super::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N_BYTES};
|
||||||
|
|
||||||
|
/// Returns 1 if an odd number of bits are set in `x`, zero otherwise.
|
||||||
|
pub(crate) fn parity8(x: u8) -> u8 {
|
||||||
|
let x = x ^ (x >> 4);
|
||||||
|
let x = x ^ (x >> 2);
|
||||||
|
let x = x ^ (x >> 1);
|
||||||
|
x & 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode via LDPC a 91-bit message and return a 174-bit codeword.
|
||||||
|
///
|
||||||
|
/// The generator matrix has dimensions (83, 91).
|
||||||
|
/// The code is a (174, 91) regular LDPC code with column weight 3.
|
||||||
|
///
|
||||||
|
/// `message` must be at least `FTX_LDPC_K_BYTES` (12) bytes.
|
||||||
|
/// `codeword` must be at least `FTX_LDPC_N_BYTES` (22) bytes.
|
||||||
|
pub(crate) fn encode174(message: &[u8], codeword: &mut [u8]) {
|
||||||
|
// Fill the codeword with message and zeros
|
||||||
|
for j in 0..FTX_LDPC_N_BYTES {
|
||||||
|
codeword[j] = if j < FTX_LDPC_K_BYTES { message[j] } else { 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the byte index and bit mask for the first checksum bit
|
||||||
|
let mut col_mask: u8 = 0x80u8 >> (FTX_LDPC_K % 8);
|
||||||
|
let mut col_idx: usize = FTX_LDPC_K_BYTES - 1;
|
||||||
|
|
||||||
|
// Compute the LDPC checksum bits and store them in codeword
|
||||||
|
for i in 0..FTX_LDPC_M {
|
||||||
|
let mut nsum: u8 = 0;
|
||||||
|
for j in 0..FTX_LDPC_K_BYTES {
|
||||||
|
nsum ^= parity8(message[j] & FTX_LDPC_GENERATOR[i][j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !nsum.is_multiple_of(2) {
|
||||||
|
codeword[col_idx] |= col_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
col_mask >>= 1;
|
||||||
|
if col_mask == 0 {
|
||||||
|
col_mask = 0x80u8;
|
||||||
|
col_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a packed 91-bit message into a 174-bit codeword (bit array).
|
||||||
|
///
|
||||||
|
/// Each element of the returned array is 0 or 1.
|
||||||
|
/// Uses the same (174, 91) LDPC generator as `encode174`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn encode174_to_bits(a91: &[u8; FTX_LDPC_K_BYTES]) -> [u8; super::protocol::FTX_LDPC_N] {
|
||||||
|
use super::protocol::FTX_LDPC_N;
|
||||||
|
let mut codeword_packed = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(a91, &mut codeword_packed);
|
||||||
|
|
||||||
|
let mut bits = [0u8; FTX_LDPC_N];
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
bits[i] = (codeword_packed[i / 8] >> (7 - (i % 8))) & 0x01;
|
||||||
|
}
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity8_basic() {
|
||||||
|
assert_eq!(parity8(0x00), 0); // 0 bits set
|
||||||
|
assert_eq!(parity8(0x01), 1); // 1 bit set
|
||||||
|
assert_eq!(parity8(0x03), 0); // 2 bits set
|
||||||
|
assert_eq!(parity8(0x07), 1); // 3 bits set
|
||||||
|
assert_eq!(parity8(0xFF), 0); // 8 bits set
|
||||||
|
assert_eq!(parity8(0xFE), 1); // 7 bits set
|
||||||
|
assert_eq!(parity8(0x80), 1); // 1 bit set
|
||||||
|
assert_eq!(parity8(0xA5), 0); // 4 bits set (10100101)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode174_systematic() {
|
||||||
|
// The first K_BYTES of the codeword should match the message
|
||||||
|
let message = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(&message, &mut codeword);
|
||||||
|
|
||||||
|
// All-zero message should produce all-zero codeword
|
||||||
|
for byte in &codeword {
|
||||||
|
assert_eq!(*byte, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode174_preserves_message() {
|
||||||
|
// The codeword should start with the message bytes (systematic code).
|
||||||
|
// Byte 11 shares bits between the last 3 message bits and the first
|
||||||
|
// parity bits, so only check bytes 0..10 for exact match.
|
||||||
|
let message: [u8; FTX_LDPC_K_BYTES] = [
|
||||||
|
0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x40,
|
||||||
|
];
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(&message, &mut codeword);
|
||||||
|
|
||||||
|
// First 11 bytes are pure message data
|
||||||
|
for j in 0..(FTX_LDPC_K_BYTES - 1) {
|
||||||
|
assert_eq!(codeword[j], message[j]);
|
||||||
|
}
|
||||||
|
// Byte 11: top 3 bits are message, lower 5 bits may have parity
|
||||||
|
assert_eq!(codeword[11] & 0xE0, message[11] & 0xE0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode174_nonzero_parity() {
|
||||||
|
// A non-zero message should produce non-zero parity bits
|
||||||
|
let message: [u8; FTX_LDPC_K_BYTES] = [
|
||||||
|
0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xE0,
|
||||||
|
];
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(&message, &mut codeword);
|
||||||
|
|
||||||
|
// Parity portion should not be all zeros
|
||||||
|
let parity_nonzero = codeword[FTX_LDPC_K_BYTES..FTX_LDPC_N_BYTES]
|
||||||
|
.iter()
|
||||||
|
.any(|&b| b != 0);
|
||||||
|
assert!(
|
||||||
|
parity_nonzero,
|
||||||
|
"Parity bits should be non-zero for non-zero input"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode174_to_bits_all_zeros() {
|
||||||
|
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
let cw = encode174_to_bits(&a91);
|
||||||
|
for &b in &cw {
|
||||||
|
assert_eq!(b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
//! log-likelihood ratios (log(P(x=0)/P(x=1))), returns a corrected 174-bit
|
//! log-likelihood ratios (log(P(x=0)/P(x=1))), returns a corrected 174-bit
|
||||||
//! codeword. The last 87 bits are the systematic plain-text.
|
//! codeword. The last 87 bits are the systematic plain-text.
|
||||||
|
|
||||||
use crate::constants::{FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
use super::constants::{FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||||
use crate::protocol::{FTX_LDPC_M, FTX_LDPC_N};
|
use super::protocol::{FTX_LDPC_M, FTX_LDPC_N};
|
||||||
|
|
||||||
/// Fast rational approximation of `tanh(x)`, clamped at +/-4.97.
|
/// Fast rational approximation of `tanh(x)`, clamped at +/-4.97.
|
||||||
pub(crate) fn fast_tanh(x: f32) -> f32 {
|
pub(crate) fn fast_tanh(x: f32) -> f32 {
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
//!
|
//!
|
||||||
//! This is a pure Rust port of `ft8_lib/ft8/message.c`.
|
//! This is a pure Rust port of `ft8_lib/ft8/message.c`.
|
||||||
|
|
||||||
use crate::callsign_hash::{compute_callsign_hash, CallsignHashTable, HashType};
|
use super::callsign_hash::{compute_callsign_hash, CallsignHashTable, HashType};
|
||||||
use crate::protocol::FTX_PAYLOAD_LENGTH_BYTES;
|
use super::protocol::FTX_PAYLOAD_LENGTH_BYTES;
|
||||||
use crate::text::{charn, dd_to_int, int_to_dd, nchar, CharTable};
|
use super::text::{charn, dd_to_int, int_to_dd, nchar, CharTable};
|
||||||
|
|
||||||
/// Maximum 22-bit hash value.
|
/// Maximum 22-bit hash value.
|
||||||
const MAX22: u32 = 4_194_304;
|
const MAX22: u32 = 4_194_304;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Common types, constants, and shared functions used across all FTx protocols.
|
||||||
|
|
||||||
|
pub mod callsign_hash;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod crc;
|
||||||
|
#[allow(dead_code, clippy::needless_range_loop)]
|
||||||
|
pub mod decode;
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
pub mod encode;
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
pub mod ldpc;
|
||||||
|
#[allow(clippy::explicit_counter_loop, clippy::needless_range_loop)]
|
||||||
|
pub mod message;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub mod monitor;
|
||||||
|
#[allow(dead_code, clippy::needless_range_loop, clippy::too_many_arguments)]
|
||||||
|
pub mod osd;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod text;
|
||||||
@@ -10,7 +10,7 @@ use num_complex::Complex32;
|
|||||||
use realfft::RealFftPlanner;
|
use realfft::RealFftPlanner;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
use crate::protocol::FtxProtocol;
|
use super::protocol::FtxProtocol;
|
||||||
|
|
||||||
/// Waterfall element storing magnitude (dB), phase (radians), and raw complex components.
|
/// Waterfall element storing magnitude (dB), phase (radians), and raw complex components.
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use crate::constants::{FTX_LDPC_GENERATOR, FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
use super::constants::{FTX_LDPC_GENERATOR, FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||||
use crate::crc::{ftx_compute_crc, ftx_extract_crc};
|
use super::crc::{ftx_compute_crc, ftx_extract_crc};
|
||||||
use crate::decode::pack_bits;
|
use super::decode::pack_bits;
|
||||||
use crate::encode::parity8;
|
use super::encode::parity8;
|
||||||
use crate::ldpc::ldpc_check;
|
use super::ldpc::ldpc_check;
|
||||||
use crate::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
use super::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||||
|
|
||||||
/// Piecewise linear approximation of `atanh(x)` used in BP message passing.
|
/// Piecewise linear approximation of `atanh(x)` used in BP message passing.
|
||||||
fn platanh(x: f32) -> f32 {
|
fn platanh(x: f32) -> f32 {
|
||||||
@@ -793,7 +793,7 @@ pub fn ft2_decode174_91_osd(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ldpc::fast_atanh;
|
use crate::common::ldpc::fast_atanh;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ldpc_check_all_zeros() {
|
fn ldpc_check_all_zeros() {
|
||||||
@@ -1,684 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
//! Candidate search, sync scoring, and likelihood extraction for FTx decoding.
|
|
||||||
//!
|
|
||||||
//! Ports `decode.c` from ft8_lib.
|
|
||||||
|
|
||||||
use num_complex::Complex32;
|
|
||||||
|
|
||||||
use crate::constants::*;
|
|
||||||
use crate::monitor::{Waterfall, WfElem};
|
|
||||||
use crate::protocol::*;
|
|
||||||
|
|
||||||
/// Candidate position in time and frequency.
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
pub struct Candidate {
|
|
||||||
pub score: i16,
|
|
||||||
pub time_offset: i16,
|
|
||||||
pub freq_offset: i16,
|
|
||||||
pub time_sub: u8,
|
|
||||||
pub freq_sub: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode status information.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct DecodeStatus {
|
|
||||||
pub ldpc_errors: i32,
|
|
||||||
pub crc_extracted: u16,
|
|
||||||
pub crc_calculated: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Message payload (77 bits packed into 10 bytes) with dedup hash.
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct FtxMessage {
|
|
||||||
pub payload: [u8; FTX_PAYLOAD_LENGTH_BYTES],
|
|
||||||
pub hash: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wf_elem_to_complex(elem: WfElem) -> Complex32 {
|
|
||||||
Complex32::new(elem.re, elem.im)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_cand_offset(wf: &Waterfall, cand: &Candidate) -> usize {
|
|
||||||
let offset = cand.time_offset as isize;
|
|
||||||
let offset = offset * wf.time_osr as isize + cand.time_sub as isize;
|
|
||||||
let offset = offset * wf.freq_osr as isize + cand.freq_sub as isize;
|
|
||||||
let offset = offset * wf.num_bins as isize + cand.freq_offset as isize;
|
|
||||||
offset.max(0) as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wf_mag_at(wf: &Waterfall, base: usize, idx: isize) -> &WfElem {
|
|
||||||
let i = (base as isize + idx).max(0) as usize;
|
|
||||||
if i < wf.mag.len() {
|
|
||||||
&wf.mag[i]
|
|
||||||
} else {
|
|
||||||
&DEFAULT_WF_ELEM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leaked reference for out-of-bounds default
|
|
||||||
static DEFAULT_WF_ELEM: WfElem = WfElem {
|
|
||||||
mag: -120.0,
|
|
||||||
phase: 0.0,
|
|
||||||
re: 0.0,
|
|
||||||
im: 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn wf_mag_safe(wf: &Waterfall, idx: usize) -> &WfElem {
|
|
||||||
if idx < wf.mag.len() {
|
|
||||||
&wf.mag[idx]
|
|
||||||
} else {
|
|
||||||
&DEFAULT_WF_ELEM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft8_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
let mut score: i32 = 0;
|
|
||||||
let mut num_average: i32 = 0;
|
|
||||||
|
|
||||||
for m in 0..FT8_NUM_SYNC {
|
|
||||||
for k in 0..FT8_LENGTH_SYNC {
|
|
||||||
let block = FT8_SYNC_OFFSET * m + k;
|
|
||||||
let block_abs = cand.time_offset as i32 + block as i32;
|
|
||||||
if block_abs < 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if block_abs >= wf.num_blocks as i32 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let p_offset = base + block * wf.block_stride;
|
|
||||||
let sm = FT8_COSTAS_PATTERN[k] as usize;
|
|
||||||
|
|
||||||
if sm > 0 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if sm < 7 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if k > 0 && block_abs > 0 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
|
||||||
let b = if b_idx < wf.mag.len() {
|
|
||||||
wf.mag[b_idx].mag_int()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if k + 1 < FT8_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if num_average > 0 {
|
|
||||||
score / num_average
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft4_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
let mut score: i32 = 0;
|
|
||||||
let mut num_average: i32 = 0;
|
|
||||||
|
|
||||||
for m in 0..FT4_NUM_SYNC {
|
|
||||||
for k in 0..FT4_LENGTH_SYNC {
|
|
||||||
let block = 1 + FT4_SYNC_OFFSET * m + k;
|
|
||||||
let block_abs = cand.time_offset as i32 + block as i32;
|
|
||||||
if block_abs < 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if block_abs >= wf.num_blocks as i32 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let p_offset = base + block * wf.block_stride;
|
|
||||||
let sm = FT4_COSTAS_PATTERN[m][k] as usize;
|
|
||||||
|
|
||||||
if sm > 0 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if sm < 3 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if k > 0 && block_abs > 0 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
|
||||||
let b = if b_idx < wf.mag.len() {
|
|
||||||
wf.mag[b_idx].mag_int()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
if k + 1 < FT4_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
|
||||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
|
||||||
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
|
||||||
score += a - b;
|
|
||||||
num_average += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if num_average > 0 {
|
|
||||||
score / num_average
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft2_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
let mut score_f: f32 = 0.0;
|
|
||||||
let mut groups = 0;
|
|
||||||
|
|
||||||
for m in 0..FT2_NUM_SYNC {
|
|
||||||
let mut sum = Complex32::new(0.0, 0.0);
|
|
||||||
let mut complete = true;
|
|
||||||
for k in 0..FT2_LENGTH_SYNC {
|
|
||||||
let block = 1 + FT2_SYNC_OFFSET * m + k;
|
|
||||||
let block_abs = cand.time_offset as i32 + block as i32;
|
|
||||||
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
|
||||||
complete = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let sym_offset = base + block * wf.block_stride;
|
|
||||||
let tone = FT4_COSTAS_PATTERN[m][k] as usize;
|
|
||||||
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
|
||||||
sum += wf_elem_to_complex(elem);
|
|
||||||
}
|
|
||||||
if !complete {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
score_f += sum.norm();
|
|
||||||
groups += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if groups == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(score_f / groups as f32 * 8.0).round() as i32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Min-heap operations for candidate list.
|
|
||||||
fn heapify_down(heap: &mut [Candidate], size: usize) {
|
|
||||||
let mut current = 0;
|
|
||||||
loop {
|
|
||||||
let left = 2 * current + 1;
|
|
||||||
let right = left + 1;
|
|
||||||
let mut smallest = current;
|
|
||||||
if left < size && heap[left].score < heap[smallest].score {
|
|
||||||
smallest = left;
|
|
||||||
}
|
|
||||||
if right < size && heap[right].score < heap[smallest].score {
|
|
||||||
smallest = right;
|
|
||||||
}
|
|
||||||
if smallest == current {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
heap.swap(current, smallest);
|
|
||||||
current = smallest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heapify_up(heap: &mut [Candidate], size: usize) {
|
|
||||||
let mut current = size - 1;
|
|
||||||
while current > 0 {
|
|
||||||
let parent = (current - 1) / 2;
|
|
||||||
if heap[current].score >= heap[parent].score {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
heap.swap(current, parent);
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find candidate signals in the waterfall. Returns sorted candidates (best first).
|
|
||||||
pub fn ftx_find_candidates(
|
|
||||||
wf: &Waterfall,
|
|
||||||
max_candidates: usize,
|
|
||||||
min_score: i32,
|
|
||||||
) -> Vec<Candidate> {
|
|
||||||
let is_ft2 = wf.protocol == FtxProtocol::Ft2;
|
|
||||||
let num_tones = if wf.protocol.uses_ft4_layout() { 4 } else { 8 };
|
|
||||||
|
|
||||||
let (time_offset_min, time_offset_max) = if is_ft2 {
|
|
||||||
let max = (wf.num_blocks as i32 - FT2_NN as i32 + 2).max(-1);
|
|
||||||
(-2i16, max as i16)
|
|
||||||
} else if wf.protocol == FtxProtocol::Ft4 {
|
|
||||||
let max = (wf.num_blocks as i32 - FT4_NN as i32 + 34).max(-33);
|
|
||||||
(-34i16, max as i16)
|
|
||||||
} else {
|
|
||||||
(-10i16, 20i16)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut heap = vec![Candidate::default(); max_candidates];
|
|
||||||
let mut heap_size = 0;
|
|
||||||
|
|
||||||
for time_sub in 0..wf.time_osr as u8 {
|
|
||||||
for freq_sub in 0..wf.freq_osr as u8 {
|
|
||||||
let mut time_offset = time_offset_min;
|
|
||||||
while time_offset < time_offset_max {
|
|
||||||
let mut freq_offset: i16 = 0;
|
|
||||||
while (freq_offset as usize + num_tones - 1) < wf.num_bins {
|
|
||||||
let cand = Candidate {
|
|
||||||
score: 0,
|
|
||||||
time_offset,
|
|
||||||
freq_offset,
|
|
||||||
time_sub,
|
|
||||||
freq_sub,
|
|
||||||
};
|
|
||||||
|
|
||||||
let score = if is_ft2 {
|
|
||||||
ft2_sync_score(wf, &cand)
|
|
||||||
} else if wf.protocol.uses_ft4_layout() {
|
|
||||||
ft4_sync_score(wf, &cand)
|
|
||||||
} else {
|
|
||||||
ft8_sync_score(wf, &cand)
|
|
||||||
};
|
|
||||||
|
|
||||||
if score >= min_score {
|
|
||||||
if heap_size == max_candidates && score > heap[0].score as i32 {
|
|
||||||
heap_size -= 1;
|
|
||||||
heap[0] = heap[heap_size];
|
|
||||||
heapify_down(&mut heap, heap_size);
|
|
||||||
}
|
|
||||||
if heap_size < max_candidates {
|
|
||||||
heap[heap_size] = Candidate {
|
|
||||||
score: score as i16,
|
|
||||||
time_offset,
|
|
||||||
freq_offset,
|
|
||||||
time_sub,
|
|
||||||
freq_sub,
|
|
||||||
};
|
|
||||||
heap_size += 1;
|
|
||||||
heapify_up(&mut heap, heap_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
freq_offset += 1;
|
|
||||||
}
|
|
||||||
time_offset += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by descending score (heap sort)
|
|
||||||
let mut len_unsorted = heap_size;
|
|
||||||
while len_unsorted > 1 {
|
|
||||||
heap.swap(0, len_unsorted - 1);
|
|
||||||
len_unsorted -= 1;
|
|
||||||
heapify_down(&mut heap, len_unsorted);
|
|
||||||
}
|
|
||||||
|
|
||||||
heap.truncate(heap_size);
|
|
||||||
heap
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract log-likelihood ratios for FT8 symbols.
|
|
||||||
fn ft8_extract_likelihood(wf: &Waterfall, cand: &Candidate, log174: &mut [f32; FTX_LDPC_N]) {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
|
|
||||||
for k in 0..FT8_ND {
|
|
||||||
let sym_idx = k + if k < 29 { 7 } else { 14 };
|
|
||||||
let bit_idx = 3 * k;
|
|
||||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
|
||||||
|
|
||||||
if block < 0 || block >= wf.num_blocks as i32 {
|
|
||||||
log174[bit_idx] = 0.0;
|
|
||||||
log174[bit_idx + 1] = 0.0;
|
|
||||||
log174[bit_idx + 2] = 0.0;
|
|
||||||
} else {
|
|
||||||
let p_offset = base + sym_idx * wf.block_stride;
|
|
||||||
ft8_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft8_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
|
||||||
let mut s2 = [0.0f32; 8];
|
|
||||||
for j in 0..8 {
|
|
||||||
s2[j] = wf_mag_safe(wf, offset + FT8_GRAY_MAP[j] as usize).mag;
|
|
||||||
}
|
|
||||||
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
|
|
||||||
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
|
|
||||||
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract log-likelihood ratios for FT4 symbols.
|
|
||||||
fn ft4_extract_likelihood(wf: &Waterfall, cand: &Candidate, log174: &mut [f32; FTX_LDPC_N]) {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
|
|
||||||
for k in 0..FT4_ND {
|
|
||||||
let sym_idx = k + if k < 29 {
|
|
||||||
5
|
|
||||||
} else if k < 58 {
|
|
||||||
9
|
|
||||||
} else {
|
|
||||||
13
|
|
||||||
};
|
|
||||||
let bit_idx = 2 * k;
|
|
||||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
|
||||||
|
|
||||||
if block < 0 || block >= wf.num_blocks as i32 {
|
|
||||||
log174[bit_idx] = 0.0;
|
|
||||||
log174[bit_idx + 1] = 0.0;
|
|
||||||
} else {
|
|
||||||
let p_offset = base + sym_idx * wf.block_stride;
|
|
||||||
ft4_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft4_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
|
||||||
let mut s2 = [0.0f32; 4];
|
|
||||||
for j in 0..4 {
|
|
||||||
s2[j] = wf_mag_safe(wf, offset + FT4_GRAY_MAP[j] as usize).mag;
|
|
||||||
}
|
|
||||||
logl[0] = s2[2].max(s2[3]) - s2[0].max(s2[1]);
|
|
||||||
logl[1] = s2[1].max(s2[3]) - s2[0].max(s2[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract log-likelihood ratios for FT2 symbols (multi-scale coherent).
|
|
||||||
fn ft2_extract_likelihood(wf: &Waterfall, cand: &Candidate, log174: &mut [f32; FTX_LDPC_N]) {
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
let frame_syms = FT2_NN - FT2_NR;
|
|
||||||
|
|
||||||
// Collect complex symbols
|
|
||||||
let mut symbols = [[Complex32::new(0.0, 0.0); 103]; 4]; // FT2_NN - FT2_NR = 103
|
|
||||||
for frame_sym in 0..frame_syms {
|
|
||||||
let sym_idx = frame_sym + 1; // skip ramp-up
|
|
||||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
|
||||||
if block < 0 || block >= wf.num_blocks as i32 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let sym_offset = base + sym_idx * wf.block_stride;
|
|
||||||
for tone in 0..4 {
|
|
||||||
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
|
||||||
symbols[tone][frame_sym] = wf_elem_to_complex(elem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-scale metrics
|
|
||||||
let mut metric1 = vec![0.0f32; 2 * frame_syms];
|
|
||||||
let mut metric2 = vec![0.0f32; 2 * frame_syms];
|
|
||||||
let mut metric4 = vec![0.0f32; 2 * frame_syms];
|
|
||||||
|
|
||||||
for start in 0..frame_syms {
|
|
||||||
ft2_extract_logl_seq(&symbols, start, 1, &mut metric1[2 * start..]);
|
|
||||||
}
|
|
||||||
let mut start = 0;
|
|
||||||
while start + 1 < frame_syms {
|
|
||||||
ft2_extract_logl_seq(&symbols, start, 2, &mut metric2[2 * start..]);
|
|
||||||
start += 2;
|
|
||||||
}
|
|
||||||
start = 0;
|
|
||||||
while start + 3 < frame_syms {
|
|
||||||
ft2_extract_logl_seq(&symbols, start, 4, &mut metric4[2 * start..]);
|
|
||||||
start += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch boundaries
|
|
||||||
if 2 * frame_syms >= 206 {
|
|
||||||
metric2[204] = metric1[204];
|
|
||||||
metric2[205] = metric1[205];
|
|
||||||
metric4[200] = metric2[200];
|
|
||||||
metric4[201] = metric2[201];
|
|
||||||
metric4[202] = metric2[202];
|
|
||||||
metric4[203] = metric2[203];
|
|
||||||
metric4[204] = metric1[204];
|
|
||||||
metric4[205] = metric1[205];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map to 174 data bits, selecting max-magnitude metric
|
|
||||||
for data_sym in 0..FT2_ND {
|
|
||||||
let frame_sym = data_sym
|
|
||||||
+ if data_sym < 29 {
|
|
||||||
4
|
|
||||||
} else if data_sym < 58 {
|
|
||||||
8
|
|
||||||
} else {
|
|
||||||
12
|
|
||||||
};
|
|
||||||
let src_bit = 2 * frame_sym;
|
|
||||||
let dst_bit = 2 * data_sym;
|
|
||||||
|
|
||||||
for b in 0..2 {
|
|
||||||
let a = metric1[src_bit + b];
|
|
||||||
let bv = metric2[src_bit + b];
|
|
||||||
let c = metric4[src_bit + b];
|
|
||||||
log174[dst_bit + b] = if a.abs() >= bv.abs() && a.abs() >= c.abs() {
|
|
||||||
a
|
|
||||||
} else if bv.abs() >= c.abs() {
|
|
||||||
bv
|
|
||||||
} else {
|
|
||||||
c
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ft2_extract_logl_seq(
|
|
||||||
symbols: &[[Complex32; 103]; 4],
|
|
||||||
start_sym: usize,
|
|
||||||
n_syms: usize,
|
|
||||||
metrics: &mut [f32],
|
|
||||||
) {
|
|
||||||
let n_bits = 2 * n_syms;
|
|
||||||
let n_sequences = 1 << n_bits;
|
|
||||||
|
|
||||||
for bit in 0..n_bits {
|
|
||||||
let mut max_zero = f32::NEG_INFINITY;
|
|
||||||
let mut max_one = f32::NEG_INFINITY;
|
|
||||||
for seq in 0..n_sequences {
|
|
||||||
let mut sum = Complex32::new(0.0, 0.0);
|
|
||||||
for sym in 0..n_syms {
|
|
||||||
let shift = 2 * (n_syms - sym - 1);
|
|
||||||
let dibit = (seq >> shift) & 0x3;
|
|
||||||
let tone = FT4_GRAY_MAP[dibit] as usize;
|
|
||||||
if start_sym + sym < 103 {
|
|
||||||
sum += symbols[tone][start_sym + sym];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let strength = sum.norm();
|
|
||||||
let mask_bit = n_bits - bit - 1;
|
|
||||||
if (seq >> mask_bit) & 1 != 0 {
|
|
||||||
if strength > max_one {
|
|
||||||
max_one = strength;
|
|
||||||
}
|
|
||||||
} else if strength > max_zero {
|
|
||||||
max_zero = strength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bit < metrics.len() {
|
|
||||||
metrics[bit] = max_one - max_zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify CRC of a 174-bit plaintext and build an FtxMessage.
|
|
||||||
///
|
|
||||||
/// `plain174`: decoded LDPC codeword (174 bits, each 0 or 1).
|
|
||||||
/// `uses_xor`: true for FT4/FT2 (apply XOR sequence), false for FT8.
|
|
||||||
///
|
|
||||||
/// Returns `None` if CRC check fails.
|
|
||||||
pub(crate) fn verify_crc_and_build_message(
|
|
||||||
plain174: &[u8; FTX_LDPC_N],
|
|
||||||
uses_xor: bool,
|
|
||||||
) -> Option<FtxMessage> {
|
|
||||||
let mut a91 = [0u8; crate::protocol::FTX_LDPC_K_BYTES];
|
|
||||||
pack_bits(plain174, crate::protocol::FTX_LDPC_K, &mut a91);
|
|
||||||
|
|
||||||
let a91_orig = a91;
|
|
||||||
let crc_extracted = crate::crc::ftx_extract_crc(&a91);
|
|
||||||
a91[9] &= 0xF8;
|
|
||||||
a91[10] = 0x00;
|
|
||||||
let crc_calculated = crate::crc::ftx_compute_crc(&a91, 96 - 14);
|
|
||||||
|
|
||||||
if crc_extracted != crc_calculated {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let a91 = a91_orig;
|
|
||||||
|
|
||||||
let mut message = FtxMessage {
|
|
||||||
hash: crc_calculated,
|
|
||||||
payload: [0; FTX_PAYLOAD_LENGTH_BYTES],
|
|
||||||
};
|
|
||||||
|
|
||||||
if uses_xor {
|
|
||||||
for i in 0..10 {
|
|
||||||
message.payload[i] = a91[i] ^ FT4_XOR_SEQUENCE[i];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.payload[..10].copy_from_slice(&a91[..10]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize log-likelihoods.
|
|
||||||
fn ftx_normalize_logl(log174: &mut [f32; FTX_LDPC_N]) {
|
|
||||||
let mut sum = 0.0f32;
|
|
||||||
let mut sum2 = 0.0f32;
|
|
||||||
for &v in log174.iter() {
|
|
||||||
sum += v;
|
|
||||||
sum2 += v * v;
|
|
||||||
}
|
|
||||||
let inv_n = 1.0 / FTX_LDPC_N as f32;
|
|
||||||
let variance = (sum2 - sum * sum * inv_n) * inv_n;
|
|
||||||
if variance > 0.0 {
|
|
||||||
let norm_factor = (24.0 / variance).sqrt();
|
|
||||||
for v in log174.iter_mut() {
|
|
||||||
*v *= norm_factor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pack bits into bytes (MSB first).
|
|
||||||
pub fn pack_bits(bit_array: &[u8], num_bits: usize, packed: &mut [u8]) {
|
|
||||||
let num_bytes = num_bits.div_ceil(8);
|
|
||||||
for b in packed[..num_bytes].iter_mut() {
|
|
||||||
*b = 0;
|
|
||||||
}
|
|
||||||
let mut mask: u8 = 0x80;
|
|
||||||
let mut byte_idx = 0;
|
|
||||||
for i in 0..num_bits {
|
|
||||||
if bit_array[i] != 0 {
|
|
||||||
packed[byte_idx] |= mask;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
byte_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to decode a candidate. Returns decoded message or None.
|
|
||||||
pub fn ftx_decode_candidate(
|
|
||||||
wf: &Waterfall,
|
|
||||||
cand: &Candidate,
|
|
||||||
max_iterations: usize,
|
|
||||||
) -> Option<FtxMessage> {
|
|
||||||
let mut log174 = [0.0f32; FTX_LDPC_N];
|
|
||||||
|
|
||||||
if wf.protocol == FtxProtocol::Ft2 {
|
|
||||||
ft2_extract_likelihood(wf, cand, &mut log174);
|
|
||||||
} else if wf.protocol.uses_ft4_layout() {
|
|
||||||
ft4_extract_likelihood(wf, cand, &mut log174);
|
|
||||||
} else {
|
|
||||||
ft8_extract_likelihood(wf, cand, &mut log174);
|
|
||||||
}
|
|
||||||
|
|
||||||
ftx_normalize_logl(&mut log174);
|
|
||||||
|
|
||||||
let mut plain174 = [0u8; FTX_LDPC_N];
|
|
||||||
let errors = crate::ldpc::bp_decode(&log174, max_iterations, &mut plain174);
|
|
||||||
if errors > 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_crc_and_build_message(&plain174, wf.protocol.uses_ft4_layout())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max4(a: f32, b: f32, c: f32, d: f32) -> f32 {
|
|
||||||
a.max(b).max(c.max(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute post-decode SNR.
|
|
||||||
pub fn ftx_post_decode_snr(wf: &Waterfall, cand: &Candidate, message: &FtxMessage) -> f32 {
|
|
||||||
let is_ft4 = wf.protocol.uses_ft4_layout();
|
|
||||||
let nn = if is_ft4 { FT4_NN } else { FT8_NN };
|
|
||||||
let num_tones = if is_ft4 { 4 } else { 8 };
|
|
||||||
|
|
||||||
let mut tones = [0u8; FT4_NN]; // FT4_NN >= FT8_NN
|
|
||||||
if is_ft4 {
|
|
||||||
crate::encode::ft4_encode(&message.payload, &mut tones);
|
|
||||||
} else {
|
|
||||||
crate::encode::ft8_encode(&message.payload, &mut tones);
|
|
||||||
}
|
|
||||||
|
|
||||||
let base = get_cand_offset(wf, cand);
|
|
||||||
let mut sum_snr = 0.0f32;
|
|
||||||
let mut n_valid = 0;
|
|
||||||
|
|
||||||
for sym in 0..nn {
|
|
||||||
let block_abs = cand.time_offset as i32 + sym as i32;
|
|
||||||
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let p_offset = base + sym * wf.block_stride;
|
|
||||||
let sig_db = wf_mag_safe(wf, p_offset + tones[sym] as usize).mag;
|
|
||||||
|
|
||||||
let mut noise_min = 0.0f32;
|
|
||||||
let mut found_noise = false;
|
|
||||||
for t in 0..num_tones {
|
|
||||||
if t == tones[sym] as usize {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let db = wf_mag_safe(wf, p_offset + t).mag;
|
|
||||||
if !found_noise || db < noise_min {
|
|
||||||
noise_min = db;
|
|
||||||
found_noise = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if found_noise {
|
|
||||||
sum_snr += sig_db - noise_min;
|
|
||||||
n_valid += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if n_valid == 0 {
|
|
||||||
return cand.score as f32 * 0.5 - 29.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let symbol_period = wf.protocol.symbol_period();
|
|
||||||
let bw_correction = 10.0 * (2500.0 * symbol_period * wf.freq_osr as f32).log10();
|
|
||||||
sum_snr / n_valid as f32 - bw_correction
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
//! Top-level FTx decoder matching the `trx-ft8` public API.
|
//! Top-level FTx decoder matching the `trx-ft8` public API.
|
||||||
|
|
||||||
use crate::callsign_hash::CallsignHashTable;
|
use crate::common::callsign_hash::CallsignHashTable;
|
||||||
use crate::decode::{ftx_decode_candidate, ftx_find_candidates, ftx_post_decode_snr, FtxMessage};
|
use crate::common::decode::{
|
||||||
use crate::message;
|
ftx_decode_candidate, ftx_find_candidates, ftx_post_decode_snr, FtxMessage,
|
||||||
use crate::monitor::{Monitor, MonitorConfig};
|
};
|
||||||
use crate::protocol::*;
|
use crate::common::message;
|
||||||
|
use crate::common::monitor::{Monitor, MonitorConfig};
|
||||||
|
use crate::common::protocol::*;
|
||||||
|
|
||||||
const DEFAULT_F_MIN_HZ: f32 = 200.0;
|
const DEFAULT_F_MIN_HZ: f32 = 200.0;
|
||||||
const DEFAULT_F_MAX_HZ: f32 = 3000.0;
|
const DEFAULT_F_MAX_HZ: f32 = 3000.0;
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
use crate::constants::{
|
|
||||||
FT4_COSTAS_PATTERN, FT4_GRAY_MAP, FT4_XOR_SEQUENCE, FT8_COSTAS_PATTERN, FT8_GRAY_MAP,
|
|
||||||
FTX_LDPC_GENERATOR,
|
|
||||||
};
|
|
||||||
use crate::crc::ftx_add_crc;
|
|
||||||
use crate::protocol::{FT4_NN, FT8_NN, FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N_BYTES};
|
|
||||||
|
|
||||||
/// Returns 1 if an odd number of bits are set in `x`, zero otherwise.
|
|
||||||
pub(crate) fn parity8(x: u8) -> u8 {
|
|
||||||
let x = x ^ (x >> 4);
|
|
||||||
let x = x ^ (x >> 2);
|
|
||||||
let x = x ^ (x >> 1);
|
|
||||||
x & 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode via LDPC a 91-bit message and return a 174-bit codeword.
|
|
||||||
///
|
|
||||||
/// The generator matrix has dimensions (83, 91).
|
|
||||||
/// The code is a (174, 91) regular LDPC code with column weight 3.
|
|
||||||
///
|
|
||||||
/// `message` must be at least `FTX_LDPC_K_BYTES` (12) bytes.
|
|
||||||
/// `codeword` must be at least `FTX_LDPC_N_BYTES` (22) bytes.
|
|
||||||
pub(crate) fn encode174(message: &[u8], codeword: &mut [u8]) {
|
|
||||||
// Fill the codeword with message and zeros
|
|
||||||
for j in 0..FTX_LDPC_N_BYTES {
|
|
||||||
codeword[j] = if j < FTX_LDPC_K_BYTES { message[j] } else { 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the byte index and bit mask for the first checksum bit
|
|
||||||
let mut col_mask: u8 = 0x80u8 >> (FTX_LDPC_K % 8);
|
|
||||||
let mut col_idx: usize = FTX_LDPC_K_BYTES - 1;
|
|
||||||
|
|
||||||
// Compute the LDPC checksum bits and store them in codeword
|
|
||||||
for i in 0..FTX_LDPC_M {
|
|
||||||
let mut nsum: u8 = 0;
|
|
||||||
for j in 0..FTX_LDPC_K_BYTES {
|
|
||||||
nsum ^= parity8(message[j] & FTX_LDPC_GENERATOR[i][j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !nsum.is_multiple_of(2) {
|
|
||||||
codeword[col_idx] |= col_mask;
|
|
||||||
}
|
|
||||||
|
|
||||||
col_mask >>= 1;
|
|
||||||
if col_mask == 0 {
|
|
||||||
col_mask = 0x80u8;
|
|
||||||
col_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a packed 91-bit message into a 174-bit codeword (bit array).
|
|
||||||
///
|
|
||||||
/// Each element of the returned array is 0 or 1.
|
|
||||||
/// Uses the same (174, 91) LDPC generator as `encode174`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn encode174_to_bits(a91: &[u8; FTX_LDPC_K_BYTES]) -> [u8; crate::protocol::FTX_LDPC_N] {
|
|
||||||
use crate::protocol::FTX_LDPC_N;
|
|
||||||
let mut codeword_packed = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(a91, &mut codeword_packed);
|
|
||||||
|
|
||||||
let mut bits = [0u8; FTX_LDPC_N];
|
|
||||||
for i in 0..FTX_LDPC_N {
|
|
||||||
bits[i] = (codeword_packed[i / 8] >> (7 - (i % 8))) & 0x01;
|
|
||||||
}
|
|
||||||
bits
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate FT8 tone sequence from payload data.
|
|
||||||
///
|
|
||||||
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
|
||||||
/// `tones` is an array of `FT8_NN` (79) bytes to store the generated tones (encoded as 0..7).
|
|
||||||
///
|
|
||||||
/// Message structure: S7 D29 S7 D29 S7
|
|
||||||
pub fn ft8_encode(payload: &[u8], tones: &mut [u8]) {
|
|
||||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
|
||||||
|
|
||||||
// Compute and add CRC at the end of the message
|
|
||||||
ftx_add_crc(payload, &mut a91);
|
|
||||||
|
|
||||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(&a91, &mut codeword);
|
|
||||||
|
|
||||||
let mut mask: u8 = 0x80;
|
|
||||||
let mut i_byte: usize = 0;
|
|
||||||
|
|
||||||
for i_tone in 0..FT8_NN {
|
|
||||||
if i_tone < 7 {
|
|
||||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone];
|
|
||||||
} else if (36..43).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 36];
|
|
||||||
} else if (72..79).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 72];
|
|
||||||
} else {
|
|
||||||
// Extract 3 bits from codeword
|
|
||||||
let mut bits3: u8 = 0;
|
|
||||||
|
|
||||||
if codeword[i_byte] & mask != 0 {
|
|
||||||
bits3 |= 4;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
i_byte += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if codeword[i_byte] & mask != 0 {
|
|
||||||
bits3 |= 2;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
i_byte += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if codeword[i_byte] & mask != 0 {
|
|
||||||
bits3 |= 1;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
i_byte += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tones[i_tone] = FT8_GRAY_MAP[bits3 as usize];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate FT4 tone sequence from payload data.
|
|
||||||
///
|
|
||||||
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
|
||||||
/// `tones` is an array of `FT4_NN` (105) bytes to store the generated tones (encoded as 0..3).
|
|
||||||
///
|
|
||||||
/// The payload is XOR'd with `FT4_XOR_SEQUENCE` before CRC computation to avoid
|
|
||||||
/// transmitting long runs of zeros when sending CQ messages.
|
|
||||||
///
|
|
||||||
/// Message structure: R S4_1 D29 S4_2 D29 S4_3 D29 S4_4 R
|
|
||||||
pub fn ft4_encode(payload: &[u8], tones: &mut [u8]) {
|
|
||||||
let mut payload_xor = [0u8; 10];
|
|
||||||
|
|
||||||
// XOR payload with pseudorandom sequence
|
|
||||||
for i in 0..10 {
|
|
||||||
payload_xor[i] = payload[i] ^ FT4_XOR_SEQUENCE[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
|
||||||
|
|
||||||
// Compute and add CRC at the end of the message
|
|
||||||
ftx_add_crc(&payload_xor, &mut a91);
|
|
||||||
|
|
||||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(&a91, &mut codeword);
|
|
||||||
|
|
||||||
let mut mask: u8 = 0x80;
|
|
||||||
let mut i_byte: usize = 0;
|
|
||||||
|
|
||||||
for i_tone in 0..FT4_NN {
|
|
||||||
if i_tone == 0 || i_tone == 104 {
|
|
||||||
tones[i_tone] = 0; // R (ramp) symbol
|
|
||||||
} else if (1..5).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT4_COSTAS_PATTERN[0][i_tone - 1];
|
|
||||||
} else if (34..38).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT4_COSTAS_PATTERN[1][i_tone - 34];
|
|
||||||
} else if (67..71).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT4_COSTAS_PATTERN[2][i_tone - 67];
|
|
||||||
} else if (100..104).contains(&i_tone) {
|
|
||||||
tones[i_tone] = FT4_COSTAS_PATTERN[3][i_tone - 100];
|
|
||||||
} else {
|
|
||||||
// Extract 2 bits from codeword
|
|
||||||
let mut bits2: u8 = 0;
|
|
||||||
|
|
||||||
if codeword[i_byte] & mask != 0 {
|
|
||||||
bits2 |= 2;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
i_byte += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if codeword[i_byte] & mask != 0 {
|
|
||||||
bits2 |= 1;
|
|
||||||
}
|
|
||||||
mask >>= 1;
|
|
||||||
if mask == 0 {
|
|
||||||
mask = 0x80;
|
|
||||||
i_byte += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tones[i_tone] = FT4_GRAY_MAP[bits2 as usize];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate FT2 tone sequence from payload data.
|
|
||||||
///
|
|
||||||
/// FT2 uses the FT4 framing with a doubled symbol rate.
|
|
||||||
///
|
|
||||||
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
|
||||||
/// `tones` is an array of `FT4_NN` (105) bytes to store the generated tones (encoded as 0..3).
|
|
||||||
pub fn ft2_encode(payload: &[u8], tones: &mut [u8]) {
|
|
||||||
ft4_encode(payload, tones);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parity8_basic() {
|
|
||||||
assert_eq!(parity8(0x00), 0); // 0 bits set
|
|
||||||
assert_eq!(parity8(0x01), 1); // 1 bit set
|
|
||||||
assert_eq!(parity8(0x03), 0); // 2 bits set
|
|
||||||
assert_eq!(parity8(0x07), 1); // 3 bits set
|
|
||||||
assert_eq!(parity8(0xFF), 0); // 8 bits set
|
|
||||||
assert_eq!(parity8(0xFE), 1); // 7 bits set
|
|
||||||
assert_eq!(parity8(0x80), 1); // 1 bit set
|
|
||||||
assert_eq!(parity8(0xA5), 0); // 4 bits set (10100101)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode174_systematic() {
|
|
||||||
// The first K_BYTES of the codeword should match the message
|
|
||||||
let message = [0u8; FTX_LDPC_K_BYTES];
|
|
||||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(&message, &mut codeword);
|
|
||||||
|
|
||||||
// All-zero message should produce all-zero codeword
|
|
||||||
for byte in &codeword {
|
|
||||||
assert_eq!(*byte, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode174_preserves_message() {
|
|
||||||
// The codeword should start with the message bytes (systematic code).
|
|
||||||
// Byte 11 shares bits between the last 3 message bits and the first
|
|
||||||
// parity bits, so only check bytes 0..10 for exact match.
|
|
||||||
let message: [u8; FTX_LDPC_K_BYTES] = [
|
|
||||||
0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x40,
|
|
||||||
];
|
|
||||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(&message, &mut codeword);
|
|
||||||
|
|
||||||
// First 11 bytes are pure message data
|
|
||||||
for j in 0..(FTX_LDPC_K_BYTES - 1) {
|
|
||||||
assert_eq!(codeword[j], message[j]);
|
|
||||||
}
|
|
||||||
// Byte 11: top 3 bits are message, lower 5 bits may have parity
|
|
||||||
assert_eq!(codeword[11] & 0xE0, message[11] & 0xE0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode174_nonzero_parity() {
|
|
||||||
// A non-zero message should produce non-zero parity bits
|
|
||||||
let message: [u8; FTX_LDPC_K_BYTES] = [
|
|
||||||
0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xE0,
|
|
||||||
];
|
|
||||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
|
||||||
encode174(&message, &mut codeword);
|
|
||||||
|
|
||||||
// Parity portion should not be all zeros
|
|
||||||
let parity_nonzero = codeword[FTX_LDPC_K_BYTES..FTX_LDPC_N_BYTES]
|
|
||||||
.iter()
|
|
||||||
.any(|&b| b != 0);
|
|
||||||
assert!(
|
|
||||||
parity_nonzero,
|
|
||||||
"Parity bits should be non-zero for non-zero input"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft8_encode_length() {
|
|
||||||
let payload = [0u8; 10];
|
|
||||||
let mut tones = [0u8; FT8_NN];
|
|
||||||
ft8_encode(&payload, &mut tones);
|
|
||||||
assert_eq!(tones.len(), 79);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft8_encode_costas_sync() {
|
|
||||||
let payload = [0u8; 10];
|
|
||||||
let mut tones = [0u8; FT8_NN];
|
|
||||||
ft8_encode(&payload, &mut tones);
|
|
||||||
|
|
||||||
// Verify the three Costas sync patterns at positions 0..7, 36..43, 72..79
|
|
||||||
for i in 0..7 {
|
|
||||||
assert_eq!(tones[i], FT8_COSTAS_PATTERN[i], "Costas S1 mismatch at {i}");
|
|
||||||
assert_eq!(
|
|
||||||
tones[36 + i],
|
|
||||||
FT8_COSTAS_PATTERN[i],
|
|
||||||
"Costas S2 mismatch at {}",
|
|
||||||
36 + i
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tones[72 + i],
|
|
||||||
FT8_COSTAS_PATTERN[i],
|
|
||||||
"Costas S3 mismatch at {}",
|
|
||||||
72 + i
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft8_encode_tones_in_range() {
|
|
||||||
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
|
||||||
let mut tones = [0u8; FT8_NN];
|
|
||||||
ft8_encode(&payload, &mut tones);
|
|
||||||
|
|
||||||
for (i, &t) in tones.iter().enumerate() {
|
|
||||||
assert!(t < 8, "FT8 tone at position {i} out of range: {t}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft4_encode_length() {
|
|
||||||
let payload = [0u8; 10];
|
|
||||||
let mut tones = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones);
|
|
||||||
assert_eq!(tones.len(), 105);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft4_encode_ramp_symbols() {
|
|
||||||
let payload = [0u8; 10];
|
|
||||||
let mut tones = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones);
|
|
||||||
|
|
||||||
assert_eq!(tones[0], 0, "First ramp symbol should be 0");
|
|
||||||
assert_eq!(tones[104], 0, "Last ramp symbol should be 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft4_encode_costas_sync() {
|
|
||||||
let payload = [0u8; 10];
|
|
||||||
let mut tones = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones);
|
|
||||||
|
|
||||||
// Verify four Costas sync groups
|
|
||||||
for i in 0..4 {
|
|
||||||
assert_eq!(tones[1 + i], FT4_COSTAS_PATTERN[0][i], "S4_1 at {i}");
|
|
||||||
}
|
|
||||||
for i in 0..4 {
|
|
||||||
assert_eq!(tones[34 + i], FT4_COSTAS_PATTERN[1][i], "S4_2 at {i}");
|
|
||||||
}
|
|
||||||
for i in 0..4 {
|
|
||||||
assert_eq!(tones[67 + i], FT4_COSTAS_PATTERN[2][i], "S4_3 at {i}");
|
|
||||||
}
|
|
||||||
for i in 0..4 {
|
|
||||||
assert_eq!(tones[100 + i], FT4_COSTAS_PATTERN[3][i], "S4_4 at {i}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft4_encode_tones_in_range() {
|
|
||||||
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
|
||||||
let mut tones = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones);
|
|
||||||
|
|
||||||
for (i, &t) in tones.iter().enumerate() {
|
|
||||||
assert!(t < 4, "FT4 tone at position {i} out of range: {t}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft2_encode_matches_ft4() {
|
|
||||||
let payload = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x20];
|
|
||||||
let mut tones_ft4 = [0u8; FT4_NN];
|
|
||||||
let mut tones_ft2 = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones_ft4);
|
|
||||||
ft2_encode(&payload, &mut tones_ft2);
|
|
||||||
assert_eq!(tones_ft4, tones_ft2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft8_encode_deterministic() {
|
|
||||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
|
||||||
let mut tones1 = [0u8; FT8_NN];
|
|
||||||
let mut tones2 = [0u8; FT8_NN];
|
|
||||||
ft8_encode(&payload, &mut tones1);
|
|
||||||
ft8_encode(&payload, &mut tones2);
|
|
||||||
assert_eq!(tones1, tones2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft4_encode_deterministic() {
|
|
||||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
|
||||||
let mut tones1 = [0u8; FT4_NN];
|
|
||||||
let mut tones2 = [0u8; FT4_NN];
|
|
||||||
ft4_encode(&payload, &mut tones1);
|
|
||||||
ft4_encode(&payload, &mut tones2);
|
|
||||||
assert_eq!(tones1, tones2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ft8_encode_different_payloads_differ() {
|
|
||||||
let payload1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
|
||||||
let payload2 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0];
|
|
||||||
let mut tones1 = [0u8; FT8_NN];
|
|
||||||
let mut tones2 = [0u8; FT8_NN];
|
|
||||||
ft8_encode(&payload1, &mut tones1);
|
|
||||||
ft8_encode(&payload2, &mut tones2);
|
|
||||||
// Data tones should differ (sync tones are the same)
|
|
||||||
assert_ne!(tones1, tones2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
use num_complex::Complex32;
|
use num_complex::Complex32;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
use crate::constants::{FT4_COSTAS_PATTERN, FT4_GRAY_MAP};
|
use crate::common::constants::{FT4_COSTAS_PATTERN, FT4_GRAY_MAP};
|
||||||
|
|
||||||
use crate::ft2::{FT2_FRAME_SYMBOLS, FT2_NSS};
|
use super::{FT2_FRAME_SYMBOLS, FT2_NSS};
|
||||||
|
|
||||||
const N_METRICS: usize = 2 * FT2_FRAME_SYMBOLS;
|
const N_METRICS: usize = 2 * FT2_FRAME_SYMBOLS;
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! FT2-specific waterfall sync scoring and likelihood extraction.
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
|
||||||
|
use crate::common::constants::*;
|
||||||
|
use crate::common::decode::{get_cand_offset, wf_elem_to_complex, wf_mag_safe, Candidate};
|
||||||
|
use crate::common::monitor::Waterfall;
|
||||||
|
use crate::common::protocol::*;
|
||||||
|
|
||||||
|
/// Compute FT2 sync score for a candidate (coherent multi-tone).
|
||||||
|
pub(crate) fn ft2_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
let mut score_f: f32 = 0.0;
|
||||||
|
let mut groups = 0;
|
||||||
|
|
||||||
|
for m in 0..FT2_NUM_SYNC {
|
||||||
|
let mut sum = Complex32::new(0.0, 0.0);
|
||||||
|
let mut complete = true;
|
||||||
|
for k in 0..FT2_LENGTH_SYNC {
|
||||||
|
let block = 1 + FT2_SYNC_OFFSET * m + k;
|
||||||
|
let block_abs = cand.time_offset as i32 + block as i32;
|
||||||
|
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let sym_offset = base + block * wf.block_stride;
|
||||||
|
let tone = FT4_COSTAS_PATTERN[m][k] as usize;
|
||||||
|
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
||||||
|
sum += wf_elem_to_complex(elem);
|
||||||
|
}
|
||||||
|
if !complete {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
score_f += sum.norm();
|
||||||
|
groups += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if groups == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(score_f / groups as f32 * 8.0).round() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract log-likelihood ratios for FT2 symbols (multi-scale coherent).
|
||||||
|
pub(crate) fn ft2_extract_likelihood(
|
||||||
|
wf: &Waterfall,
|
||||||
|
cand: &Candidate,
|
||||||
|
log174: &mut [f32; FTX_LDPC_N],
|
||||||
|
) {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
let frame_syms = FT2_NN - FT2_NR;
|
||||||
|
|
||||||
|
// Collect complex symbols
|
||||||
|
let mut symbols = [[Complex32::new(0.0, 0.0); 103]; 4]; // FT2_NN - FT2_NR = 103
|
||||||
|
for frame_sym in 0..frame_syms {
|
||||||
|
let sym_idx = frame_sym + 1; // skip ramp-up
|
||||||
|
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||||
|
if block < 0 || block >= wf.num_blocks as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sym_offset = base + sym_idx * wf.block_stride;
|
||||||
|
for tone in 0..4 {
|
||||||
|
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
||||||
|
symbols[tone][frame_sym] = wf_elem_to_complex(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-scale metrics
|
||||||
|
let mut metric1 = vec![0.0f32; 2 * frame_syms];
|
||||||
|
let mut metric2 = vec![0.0f32; 2 * frame_syms];
|
||||||
|
let mut metric4 = vec![0.0f32; 2 * frame_syms];
|
||||||
|
|
||||||
|
for start in 0..frame_syms {
|
||||||
|
ft2_extract_logl_seq(&symbols, start, 1, &mut metric1[2 * start..]);
|
||||||
|
}
|
||||||
|
let mut start = 0;
|
||||||
|
while start + 1 < frame_syms {
|
||||||
|
ft2_extract_logl_seq(&symbols, start, 2, &mut metric2[2 * start..]);
|
||||||
|
start += 2;
|
||||||
|
}
|
||||||
|
start = 0;
|
||||||
|
while start + 3 < frame_syms {
|
||||||
|
ft2_extract_logl_seq(&symbols, start, 4, &mut metric4[2 * start..]);
|
||||||
|
start += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch boundaries
|
||||||
|
if 2 * frame_syms >= 206 {
|
||||||
|
metric2[204] = metric1[204];
|
||||||
|
metric2[205] = metric1[205];
|
||||||
|
metric4[200] = metric2[200];
|
||||||
|
metric4[201] = metric2[201];
|
||||||
|
metric4[202] = metric2[202];
|
||||||
|
metric4[203] = metric2[203];
|
||||||
|
metric4[204] = metric1[204];
|
||||||
|
metric4[205] = metric1[205];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to 174 data bits, selecting max-magnitude metric
|
||||||
|
for data_sym in 0..FT2_ND {
|
||||||
|
let frame_sym = data_sym
|
||||||
|
+ if data_sym < 29 {
|
||||||
|
4
|
||||||
|
} else if data_sym < 58 {
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
12
|
||||||
|
};
|
||||||
|
let src_bit = 2 * frame_sym;
|
||||||
|
let dst_bit = 2 * data_sym;
|
||||||
|
|
||||||
|
for b in 0..2 {
|
||||||
|
let a = metric1[src_bit + b];
|
||||||
|
let bv = metric2[src_bit + b];
|
||||||
|
let c = metric4[src_bit + b];
|
||||||
|
log174[dst_bit + b] = if a.abs() >= bv.abs() && a.abs() >= c.abs() {
|
||||||
|
a
|
||||||
|
} else if bv.abs() >= c.abs() {
|
||||||
|
bv
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ft2_extract_logl_seq(
|
||||||
|
symbols: &[[Complex32; 103]; 4],
|
||||||
|
start_sym: usize,
|
||||||
|
n_syms: usize,
|
||||||
|
metrics: &mut [f32],
|
||||||
|
) {
|
||||||
|
let n_bits = 2 * n_syms;
|
||||||
|
let n_sequences = 1 << n_bits;
|
||||||
|
|
||||||
|
for bit in 0..n_bits {
|
||||||
|
let mut max_zero = f32::NEG_INFINITY;
|
||||||
|
let mut max_one = f32::NEG_INFINITY;
|
||||||
|
for seq in 0..n_sequences {
|
||||||
|
let mut sum = Complex32::new(0.0, 0.0);
|
||||||
|
for sym in 0..n_syms {
|
||||||
|
let shift = 2 * (n_syms - sym - 1);
|
||||||
|
let dibit = (seq >> shift) & 0x3;
|
||||||
|
let tone = FT4_GRAY_MAP[dibit] as usize;
|
||||||
|
if start_sym + sym < 103 {
|
||||||
|
sum += symbols[tone][start_sym + sym];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let strength = sum.norm();
|
||||||
|
let mask_bit = n_bits - bit - 1;
|
||||||
|
if (seq >> mask_bit) & 1 != 0 {
|
||||||
|
if strength > max_one {
|
||||||
|
max_one = strength;
|
||||||
|
}
|
||||||
|
} else if strength > max_zero {
|
||||||
|
max_zero = strength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bit < metrics.len() {
|
||||||
|
metrics[bit] = max_one - max_zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
|||||||
use num_complex::Complex32;
|
use num_complex::Complex32;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
use crate::ft2::{FT2_NDOWN, FT2_SYMBOL_PERIOD_F};
|
use super::{FT2_NDOWN, FT2_SYMBOL_PERIOD_F};
|
||||||
|
|
||||||
/// Reusable scratch buffers for frequency-domain downsampling.
|
/// Reusable scratch buffers for frequency-domain downsampling.
|
||||||
pub struct DownsampleWorkspace {
|
pub struct DownsampleWorkspace {
|
||||||
@@ -8,17 +8,27 @@
|
|||||||
//! peaks in the averaged spectrum, downsample each candidate, compute 2D sync
|
//! peaks in the averaged spectrum, downsample each candidate, compute 2D sync
|
||||||
//! scores, extract bit metrics, and run multi-pass LDPC + OSD decode.
|
//! scores, extract bit metrics, and run multi-pass LDPC + OSD decode.
|
||||||
|
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
pub mod bitmetrics;
|
||||||
|
pub(crate) mod decode;
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
pub mod downsample;
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
pub(crate) use self::decode::{ft2_extract_likelihood, ft2_sync_score};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use num_complex::Complex32;
|
use num_complex::Complex32;
|
||||||
use realfft::RealFftPlanner;
|
use realfft::RealFftPlanner;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
use crate::bitmetrics::BitMetricsWorkspace;
|
use self::bitmetrics::BitMetricsWorkspace;
|
||||||
use crate::decode::{verify_crc_and_build_message, FtxMessage};
|
use self::downsample::{DownsampleContext, DownsampleWorkspace};
|
||||||
use crate::downsample::{DownsampleContext, DownsampleWorkspace};
|
use self::sync::{prepare_sync_waveforms, sync2d_score, SyncWaveforms};
|
||||||
use crate::ft2_sync::{prepare_sync_waveforms, sync2d_score, SyncWaveforms};
|
use crate::common::decode::{verify_crc_and_build_message, FtxMessage};
|
||||||
use crate::protocol::*;
|
use crate::common::protocol::*;
|
||||||
|
|
||||||
// FT2 DSP constants
|
// FT2 DSP constants
|
||||||
pub const FT2_NDOWN: usize = 9;
|
pub const FT2_NDOWN: usize = 9;
|
||||||
@@ -649,7 +659,7 @@ impl Ft2Pipeline {
|
|||||||
let mut nharderror = -1i32;
|
let mut nharderror = -1i32;
|
||||||
let mut dmin = 0.0f32;
|
let mut dmin = 0.0f32;
|
||||||
|
|
||||||
crate::osd::ft2_decode174_91_osd(
|
crate::common::osd::ft2_decode174_91_osd(
|
||||||
&mut log174,
|
&mut log174,
|
||||||
FTX_LDPC_K,
|
FTX_LDPC_K,
|
||||||
4,
|
4,
|
||||||
@@ -807,7 +817,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn encode174_to_bits_all_zeros() {
|
fn encode174_to_bits_all_zeros() {
|
||||||
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
let cw = crate::encode::encode174_to_bits(&a91);
|
let cw = crate::common::encode::encode174_to_bits(&a91);
|
||||||
for &b in &cw {
|
for &b in &cw {
|
||||||
assert_eq!(b, 0);
|
assert_eq!(b, 0);
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
use num_complex::Complex32;
|
use num_complex::Complex32;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use crate::constants::FT4_COSTAS_PATTERN;
|
use crate::common::constants::FT4_COSTAS_PATTERN;
|
||||||
|
|
||||||
use crate::ft2::{FT2_NDOWN, FT2_NSS, FT2_SYMBOL_PERIOD_F, FT2_SYNC_TWEAK_MAX, FT2_SYNC_TWEAK_MIN};
|
use super::{FT2_NDOWN, FT2_NSS, FT2_SYMBOL_PERIOD_F, FT2_SYNC_TWEAK_MAX, FT2_SYNC_TWEAK_MIN};
|
||||||
|
|
||||||
/// Number of frequency tweak entries.
|
/// Number of frequency tweak entries.
|
||||||
const NUM_TWEAKS: usize = (FT2_SYNC_TWEAK_MAX - FT2_SYNC_TWEAK_MIN) as usize + 1;
|
const NUM_TWEAKS: usize = (FT2_SYNC_TWEAK_MAX - FT2_SYNC_TWEAK_MIN) as usize + 1;
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! FT4-specific sync scoring, likelihood extraction, and tone encoding.
|
||||||
|
|
||||||
|
use crate::common::constants::*;
|
||||||
|
use crate::common::crc::ftx_add_crc;
|
||||||
|
use crate::common::decode::{get_cand_offset, wf_mag_safe, Candidate};
|
||||||
|
use crate::common::encode::encode174;
|
||||||
|
use crate::common::monitor::Waterfall;
|
||||||
|
use crate::common::protocol::*;
|
||||||
|
|
||||||
|
/// Compute FT4 sync score for a candidate.
|
||||||
|
pub(crate) fn ft4_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
let mut score: i32 = 0;
|
||||||
|
let mut num_average: i32 = 0;
|
||||||
|
|
||||||
|
for m in 0..FT4_NUM_SYNC {
|
||||||
|
for k in 0..FT4_LENGTH_SYNC {
|
||||||
|
let block = 1 + FT4_SYNC_OFFSET * m + k;
|
||||||
|
let block_abs = cand.time_offset as i32 + block as i32;
|
||||||
|
if block_abs < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if block_abs >= wf.num_blocks as i32 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_offset = base + block * wf.block_stride;
|
||||||
|
let sm = FT4_COSTAS_PATTERN[m][k] as usize;
|
||||||
|
|
||||||
|
if sm > 0 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if sm < 3 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if k > 0 && block_abs > 0 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
||||||
|
let b = if b_idx < wf.mag.len() {
|
||||||
|
wf.mag[b_idx].mag_int()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if k + 1 < FT4_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if num_average > 0 {
|
||||||
|
score / num_average
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract log-likelihood ratios for FT4 symbols.
|
||||||
|
pub(crate) fn ft4_extract_likelihood(
|
||||||
|
wf: &Waterfall,
|
||||||
|
cand: &Candidate,
|
||||||
|
log174: &mut [f32; FTX_LDPC_N],
|
||||||
|
) {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
|
||||||
|
for k in 0..FT4_ND {
|
||||||
|
let sym_idx = k + if k < 29 {
|
||||||
|
5
|
||||||
|
} else if k < 58 {
|
||||||
|
9
|
||||||
|
} else {
|
||||||
|
13
|
||||||
|
};
|
||||||
|
let bit_idx = 2 * k;
|
||||||
|
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||||
|
|
||||||
|
if block < 0 || block >= wf.num_blocks as i32 {
|
||||||
|
log174[bit_idx] = 0.0;
|
||||||
|
log174[bit_idx + 1] = 0.0;
|
||||||
|
} else {
|
||||||
|
let p_offset = base + sym_idx * wf.block_stride;
|
||||||
|
ft4_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ft4_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
||||||
|
let mut s2 = [0.0f32; 4];
|
||||||
|
for j in 0..4 {
|
||||||
|
s2[j] = wf_mag_safe(wf, offset + FT4_GRAY_MAP[j] as usize).mag;
|
||||||
|
}
|
||||||
|
logl[0] = s2[2].max(s2[3]) - s2[0].max(s2[1]);
|
||||||
|
logl[1] = s2[1].max(s2[3]) - s2[0].max(s2[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate FT4 tone sequence from payload data.
|
||||||
|
///
|
||||||
|
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
||||||
|
/// `tones` is an array of `FT4_NN` (105) bytes to store the generated tones (encoded as 0..3).
|
||||||
|
///
|
||||||
|
/// The payload is XOR'd with `FT4_XOR_SEQUENCE` before CRC computation to avoid
|
||||||
|
/// transmitting long runs of zeros when sending CQ messages.
|
||||||
|
///
|
||||||
|
/// Message structure: R S4_1 D29 S4_2 D29 S4_3 D29 S4_4 R
|
||||||
|
pub fn ft4_encode(payload: &[u8], tones: &mut [u8]) {
|
||||||
|
let mut payload_xor = [0u8; 10];
|
||||||
|
|
||||||
|
// XOR payload with pseudorandom sequence
|
||||||
|
for i in 0..10 {
|
||||||
|
payload_xor[i] = payload[i] ^ FT4_XOR_SEQUENCE[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
|
||||||
|
// Compute and add CRC at the end of the message
|
||||||
|
ftx_add_crc(&payload_xor, &mut a91);
|
||||||
|
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(&a91, &mut codeword);
|
||||||
|
|
||||||
|
let mut mask: u8 = 0x80;
|
||||||
|
let mut i_byte: usize = 0;
|
||||||
|
|
||||||
|
for i_tone in 0..FT4_NN {
|
||||||
|
if i_tone == 0 || i_tone == 104 {
|
||||||
|
tones[i_tone] = 0; // R (ramp) symbol
|
||||||
|
} else if (1..5).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT4_COSTAS_PATTERN[0][i_tone - 1];
|
||||||
|
} else if (34..38).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT4_COSTAS_PATTERN[1][i_tone - 34];
|
||||||
|
} else if (67..71).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT4_COSTAS_PATTERN[2][i_tone - 67];
|
||||||
|
} else if (100..104).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT4_COSTAS_PATTERN[3][i_tone - 100];
|
||||||
|
} else {
|
||||||
|
// Extract 2 bits from codeword
|
||||||
|
let mut bits2: u8 = 0;
|
||||||
|
|
||||||
|
if codeword[i_byte] & mask != 0 {
|
||||||
|
bits2 |= 2;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
i_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if codeword[i_byte] & mask != 0 {
|
||||||
|
bits2 |= 1;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
i_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tones[i_tone] = FT4_GRAY_MAP[bits2 as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate FT2 tone sequence from payload data.
|
||||||
|
///
|
||||||
|
/// FT2 uses the FT4 framing with a doubled symbol rate.
|
||||||
|
pub fn ft2_encode(payload: &[u8], tones: &mut [u8]) {
|
||||||
|
ft4_encode(payload, tones);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_encode_length() {
|
||||||
|
let payload = [0u8; 10];
|
||||||
|
let mut tones = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones);
|
||||||
|
assert_eq!(tones.len(), 105);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_encode_ramp_symbols() {
|
||||||
|
let payload = [0u8; 10];
|
||||||
|
let mut tones = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones);
|
||||||
|
|
||||||
|
assert_eq!(tones[0], 0, "First ramp symbol should be 0");
|
||||||
|
assert_eq!(tones[104], 0, "Last ramp symbol should be 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_encode_costas_sync() {
|
||||||
|
let payload = [0u8; 10];
|
||||||
|
let mut tones = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones);
|
||||||
|
|
||||||
|
// Verify four Costas sync groups
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(tones[1 + i], FT4_COSTAS_PATTERN[0][i], "S4_1 at {i}");
|
||||||
|
}
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(tones[34 + i], FT4_COSTAS_PATTERN[1][i], "S4_2 at {i}");
|
||||||
|
}
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(tones[67 + i], FT4_COSTAS_PATTERN[2][i], "S4_3 at {i}");
|
||||||
|
}
|
||||||
|
for i in 0..4 {
|
||||||
|
assert_eq!(tones[100 + i], FT4_COSTAS_PATTERN[3][i], "S4_4 at {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_encode_tones_in_range() {
|
||||||
|
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||||
|
let mut tones = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones);
|
||||||
|
|
||||||
|
for (i, &t) in tones.iter().enumerate() {
|
||||||
|
assert!(t < 4, "FT4 tone at position {i} out of range: {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_encode_deterministic() {
|
||||||
|
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
||||||
|
let mut tones1 = [0u8; FT4_NN];
|
||||||
|
let mut tones2 = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones1);
|
||||||
|
ft4_encode(&payload, &mut tones2);
|
||||||
|
assert_eq!(tones1, tones2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft2_encode_matches_ft4() {
|
||||||
|
let payload = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x20];
|
||||||
|
let mut tones_ft4 = [0u8; FT4_NN];
|
||||||
|
let mut tones_ft2 = [0u8; FT4_NN];
|
||||||
|
ft4_encode(&payload, &mut tones_ft4);
|
||||||
|
ft2_encode(&payload, &mut tones_ft2);
|
||||||
|
assert_eq!(tones_ft4, tones_ft2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! FT8-specific sync scoring, likelihood extraction, and tone encoding.
|
||||||
|
|
||||||
|
use crate::common::constants::*;
|
||||||
|
use crate::common::crc::ftx_add_crc;
|
||||||
|
use crate::common::decode::{get_cand_offset, wf_mag_safe, Candidate};
|
||||||
|
use crate::common::encode::encode174;
|
||||||
|
use crate::common::monitor::Waterfall;
|
||||||
|
use crate::common::protocol::*;
|
||||||
|
|
||||||
|
/// Compute FT8 sync score for a candidate.
|
||||||
|
pub(crate) fn ft8_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
let mut score: i32 = 0;
|
||||||
|
let mut num_average: i32 = 0;
|
||||||
|
|
||||||
|
for m in 0..FT8_NUM_SYNC {
|
||||||
|
for k in 0..FT8_LENGTH_SYNC {
|
||||||
|
let block = FT8_SYNC_OFFSET * m + k;
|
||||||
|
let block_abs = cand.time_offset as i32 + block as i32;
|
||||||
|
if block_abs < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if block_abs >= wf.num_blocks as i32 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_offset = base + block * wf.block_stride;
|
||||||
|
let sm = FT8_COSTAS_PATTERN[k] as usize;
|
||||||
|
|
||||||
|
if sm > 0 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if sm < 7 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if k > 0 && block_abs > 0 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
||||||
|
let b = if b_idx < wf.mag.len() {
|
||||||
|
wf.mag[b_idx].mag_int()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
if k + 1 < FT8_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
||||||
|
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||||
|
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
||||||
|
score += a - b;
|
||||||
|
num_average += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if num_average > 0 {
|
||||||
|
score / num_average
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract log-likelihood ratios for FT8 symbols.
|
||||||
|
pub(crate) fn ft8_extract_likelihood(
|
||||||
|
wf: &Waterfall,
|
||||||
|
cand: &Candidate,
|
||||||
|
log174: &mut [f32; FTX_LDPC_N],
|
||||||
|
) {
|
||||||
|
let base = get_cand_offset(wf, cand);
|
||||||
|
|
||||||
|
for k in 0..FT8_ND {
|
||||||
|
let sym_idx = k + if k < 29 { 7 } else { 14 };
|
||||||
|
let bit_idx = 3 * k;
|
||||||
|
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||||
|
|
||||||
|
if block < 0 || block >= wf.num_blocks as i32 {
|
||||||
|
log174[bit_idx] = 0.0;
|
||||||
|
log174[bit_idx + 1] = 0.0;
|
||||||
|
log174[bit_idx + 2] = 0.0;
|
||||||
|
} else {
|
||||||
|
let p_offset = base + sym_idx * wf.block_stride;
|
||||||
|
ft8_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ft8_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
||||||
|
let mut s2 = [0.0f32; 8];
|
||||||
|
for j in 0..8 {
|
||||||
|
s2[j] = wf_mag_safe(wf, offset + FT8_GRAY_MAP[j] as usize).mag;
|
||||||
|
}
|
||||||
|
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
|
||||||
|
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
|
||||||
|
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max4(a: f32, b: f32, c: f32, d: f32) -> f32 {
|
||||||
|
a.max(b).max(c.max(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate FT8 tone sequence from payload data.
|
||||||
|
///
|
||||||
|
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
||||||
|
/// `tones` is an array of `FT8_NN` (79) bytes to store the generated tones (encoded as 0..7).
|
||||||
|
///
|
||||||
|
/// Message structure: S7 D29 S7 D29 S7
|
||||||
|
pub fn ft8_encode(payload: &[u8], tones: &mut [u8]) {
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
|
||||||
|
// Compute and add CRC at the end of the message
|
||||||
|
ftx_add_crc(payload, &mut a91);
|
||||||
|
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||||
|
encode174(&a91, &mut codeword);
|
||||||
|
|
||||||
|
let mut mask: u8 = 0x80;
|
||||||
|
let mut i_byte: usize = 0;
|
||||||
|
|
||||||
|
for i_tone in 0..FT8_NN {
|
||||||
|
if i_tone < 7 {
|
||||||
|
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone];
|
||||||
|
} else if (36..43).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 36];
|
||||||
|
} else if (72..79).contains(&i_tone) {
|
||||||
|
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 72];
|
||||||
|
} else {
|
||||||
|
// Extract 3 bits from codeword
|
||||||
|
let mut bits3: u8 = 0;
|
||||||
|
|
||||||
|
if codeword[i_byte] & mask != 0 {
|
||||||
|
bits3 |= 4;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
i_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if codeword[i_byte] & mask != 0 {
|
||||||
|
bits3 |= 2;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
i_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if codeword[i_byte] & mask != 0 {
|
||||||
|
bits3 |= 1;
|
||||||
|
}
|
||||||
|
mask >>= 1;
|
||||||
|
if mask == 0 {
|
||||||
|
mask = 0x80;
|
||||||
|
i_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tones[i_tone] = FT8_GRAY_MAP[bits3 as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_encode_length() {
|
||||||
|
let payload = [0u8; 10];
|
||||||
|
let mut tones = [0u8; FT8_NN];
|
||||||
|
ft8_encode(&payload, &mut tones);
|
||||||
|
assert_eq!(tones.len(), 79);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_encode_costas_sync() {
|
||||||
|
let payload = [0u8; 10];
|
||||||
|
let mut tones = [0u8; FT8_NN];
|
||||||
|
ft8_encode(&payload, &mut tones);
|
||||||
|
|
||||||
|
// Verify the three Costas sync patterns at positions 0..7, 36..43, 72..79
|
||||||
|
for i in 0..7 {
|
||||||
|
assert_eq!(tones[i], FT8_COSTAS_PATTERN[i], "Costas S1 mismatch at {i}");
|
||||||
|
assert_eq!(
|
||||||
|
tones[36 + i],
|
||||||
|
FT8_COSTAS_PATTERN[i],
|
||||||
|
"Costas S2 mismatch at {}",
|
||||||
|
36 + i
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tones[72 + i],
|
||||||
|
FT8_COSTAS_PATTERN[i],
|
||||||
|
"Costas S3 mismatch at {}",
|
||||||
|
72 + i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_encode_tones_in_range() {
|
||||||
|
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||||
|
let mut tones = [0u8; FT8_NN];
|
||||||
|
ft8_encode(&payload, &mut tones);
|
||||||
|
|
||||||
|
for (i, &t) in tones.iter().enumerate() {
|
||||||
|
assert!(t < 8, "FT8 tone at position {i} out of range: {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_encode_deterministic() {
|
||||||
|
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
||||||
|
let mut tones1 = [0u8; FT8_NN];
|
||||||
|
let mut tones2 = [0u8; FT8_NN];
|
||||||
|
ft8_encode(&payload, &mut tones1);
|
||||||
|
ft8_encode(&payload, &mut tones2);
|
||||||
|
assert_eq!(tones1, tones2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_encode_different_payloads_differ() {
|
||||||
|
let payload1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
|
let payload2 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0];
|
||||||
|
let mut tones1 = [0u8; FT8_NN];
|
||||||
|
let mut tones2 = [0u8; FT8_NN];
|
||||||
|
ft8_encode(&payload1, &mut tones1);
|
||||||
|
ft8_encode(&payload2, &mut tones2);
|
||||||
|
// Data tones should differ (sync tones are the same)
|
||||||
|
assert_ne!(tones1, tones2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,31 +2,13 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
pub mod common;
|
||||||
pub mod bitmetrics;
|
|
||||||
pub mod callsign_hash;
|
|
||||||
pub mod constants;
|
|
||||||
pub mod crc;
|
|
||||||
#[allow(dead_code, clippy::needless_range_loop)]
|
|
||||||
pub mod decode;
|
|
||||||
mod decoder;
|
mod decoder;
|
||||||
#[allow(clippy::needless_range_loop)]
|
#[allow(clippy::needless_range_loop)]
|
||||||
pub mod downsample;
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
pub mod encode;
|
|
||||||
#[allow(dead_code, clippy::needless_range_loop, clippy::too_many_arguments)]
|
|
||||||
pub mod ft2;
|
pub mod ft2;
|
||||||
#[allow(clippy::needless_range_loop)]
|
#[allow(clippy::needless_range_loop)]
|
||||||
pub mod ft2_sync;
|
pub mod ft4;
|
||||||
#[allow(clippy::needless_range_loop)]
|
#[allow(clippy::needless_range_loop)]
|
||||||
pub mod ldpc;
|
pub mod ft8;
|
||||||
#[allow(clippy::explicit_counter_loop, clippy::needless_range_loop)]
|
|
||||||
pub mod message;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub mod monitor;
|
|
||||||
#[allow(dead_code, clippy::needless_range_loop, clippy::too_many_arguments)]
|
|
||||||
pub mod osd;
|
|
||||||
pub mod protocol;
|
|
||||||
pub mod text;
|
|
||||||
|
|
||||||
pub use decoder::{Ft8DecodeResult, Ft8Decoder};
|
pub use decoder::{Ft8DecodeResult, Ft8Decoder};
|
||||||
|
|||||||
Reference in New Issue
Block a user