[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
|
||||
//! `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).
|
||||
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// 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).
|
||||
pub const FT8_COSTAS_PATTERN: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// 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);
|
||||
|
||||
@@ -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
|
||||
//! codeword. The last 87 bits are the systematic plain-text.
|
||||
|
||||
use crate::constants::{FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use crate::protocol::{FTX_LDPC_M, FTX_LDPC_N};
|
||||
use super::constants::{FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use super::protocol::{FTX_LDPC_M, FTX_LDPC_N};
|
||||
|
||||
/// Fast rational approximation of `tanh(x)`, clamped at +/-4.97.
|
||||
pub(crate) fn fast_tanh(x: f32) -> f32 {
|
||||
@@ -6,9 +6,9 @@
|
||||
//!
|
||||
//! This is a pure Rust port of `ft8_lib/ft8/message.c`.
|
||||
|
||||
use crate::callsign_hash::{compute_callsign_hash, CallsignHashTable, HashType};
|
||||
use crate::protocol::FTX_PAYLOAD_LENGTH_BYTES;
|
||||
use crate::text::{charn, dd_to_int, int_to_dd, nchar, CharTable};
|
||||
use super::callsign_hash::{compute_callsign_hash, CallsignHashTable, HashType};
|
||||
use super::protocol::FTX_PAYLOAD_LENGTH_BYTES;
|
||||
use super::text::{charn, dd_to_int, int_to_dd, nchar, CharTable};
|
||||
|
||||
/// Maximum 22-bit hash value.
|
||||
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 rustfft::FftPlanner;
|
||||
|
||||
use crate::protocol::FtxProtocol;
|
||||
use super::protocol::FtxProtocol;
|
||||
|
||||
/// Waterfall element storing magnitude (dB), phase (radians), and raw complex components.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::constants::{FTX_LDPC_GENERATOR, FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use crate::crc::{ftx_compute_crc, ftx_extract_crc};
|
||||
use crate::decode::pack_bits;
|
||||
use crate::encode::parity8;
|
||||
use crate::ldpc::ldpc_check;
|
||||
use crate::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||
use super::constants::{FTX_LDPC_GENERATOR, FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use super::crc::{ftx_compute_crc, ftx_extract_crc};
|
||||
use super::decode::pack_bits;
|
||||
use super::encode::parity8;
|
||||
use super::ldpc::ldpc_check;
|
||||
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.
|
||||
fn platanh(x: f32) -> f32 {
|
||||
@@ -793,7 +793,7 @@ pub fn ft2_decode174_91_osd(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ldpc::fast_atanh;
|
||||
use crate::common::ldpc::fast_atanh;
|
||||
|
||||
#[test]
|
||||
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.
|
||||
|
||||
use crate::callsign_hash::CallsignHashTable;
|
||||
use crate::decode::{ftx_decode_candidate, ftx_find_candidates, ftx_post_decode_snr, FtxMessage};
|
||||
use crate::message;
|
||||
use crate::monitor::{Monitor, MonitorConfig};
|
||||
use crate::protocol::*;
|
||||
use crate::common::callsign_hash::CallsignHashTable;
|
||||
use crate::common::decode::{
|
||||
ftx_decode_candidate, ftx_find_candidates, ftx_post_decode_snr, FtxMessage,
|
||||
};
|
||||
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_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 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;
|
||||
|
||||
@@ -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 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.
|
||||
pub struct DownsampleWorkspace {
|
||||
@@ -8,17 +8,27 @@
|
||||
//! peaks in the averaged spectrum, downsample each candidate, compute 2D sync
|
||||
//! 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 num_complex::Complex32;
|
||||
use realfft::RealFftPlanner;
|
||||
use rustfft::FftPlanner;
|
||||
|
||||
use crate::bitmetrics::BitMetricsWorkspace;
|
||||
use crate::decode::{verify_crc_and_build_message, FtxMessage};
|
||||
use crate::downsample::{DownsampleContext, DownsampleWorkspace};
|
||||
use crate::ft2_sync::{prepare_sync_waveforms, sync2d_score, SyncWaveforms};
|
||||
use crate::protocol::*;
|
||||
use self::bitmetrics::BitMetricsWorkspace;
|
||||
use self::downsample::{DownsampleContext, DownsampleWorkspace};
|
||||
use self::sync::{prepare_sync_waveforms, sync2d_score, SyncWaveforms};
|
||||
use crate::common::decode::{verify_crc_and_build_message, FtxMessage};
|
||||
use crate::common::protocol::*;
|
||||
|
||||
// FT2 DSP constants
|
||||
pub const FT2_NDOWN: usize = 9;
|
||||
@@ -649,7 +659,7 @@ impl Ft2Pipeline {
|
||||
let mut nharderror = -1i32;
|
||||
let mut dmin = 0.0f32;
|
||||
|
||||
crate::osd::ft2_decode174_91_osd(
|
||||
crate::common::osd::ft2_decode174_91_osd(
|
||||
&mut log174,
|
||||
FTX_LDPC_K,
|
||||
4,
|
||||
@@ -807,7 +817,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encode174_to_bits_all_zeros() {
|
||||
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 {
|
||||
assert_eq!(b, 0);
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
use num_complex::Complex32;
|
||||
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.
|
||||
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
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub mod bitmetrics;
|
||||
pub mod callsign_hash;
|
||||
pub mod constants;
|
||||
pub mod crc;
|
||||
#[allow(dead_code, clippy::needless_range_loop)]
|
||||
pub mod decode;
|
||||
pub mod common;
|
||||
mod decoder;
|
||||
#[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;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub mod ft2_sync;
|
||||
pub mod ft4;
|
||||
#[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;
|
||||
pub mod ft8;
|
||||
|
||||
pub use decoder::{Ft8DecodeResult, Ft8Decoder};
|
||||
|
||||
Reference in New Issue
Block a user