[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:
2026-03-19 23:51:17 +01:00
parent de0bc89705
commit bb18d90cbe
23 changed files with 1231 additions and 1149 deletions
@@ -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;
@@ -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);
+350
View File
@@ -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
}
+146
View File
@@ -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;
+23
View File
@@ -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() {
-684
View File
@@ -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
}
+7 -5
View File
@@ -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;
-411
View File
@@ -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;
+167
View File
@@ -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;
+257
View File
@@ -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);
}
}
+240
View File
@@ -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);
}
}
+3 -21
View File
@@ -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};