From bb18d90cbe4cb09093eadf1bc4f5bab20984a087 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 19 Mar 2026 23:51:17 +0100 Subject: [PATCH] [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 Signed-off-by: Stan Grams --- .../trx-ftx/src/{ => common}/callsign_hash.rs | 2 +- .../trx-ftx/src/{ => common}/constants.rs | 2 +- src/decoders/trx-ftx/src/{ => common}/crc.rs | 2 +- src/decoders/trx-ftx/src/common/decode.rs | 350 +++++++++ src/decoders/trx-ftx/src/common/encode.rs | 146 ++++ src/decoders/trx-ftx/src/{ => common}/ldpc.rs | 4 +- .../trx-ftx/src/{ => common}/message.rs | 6 +- src/decoders/trx-ftx/src/common/mod.rs | 23 + .../trx-ftx/src/{ => common}/monitor.rs | 2 +- src/decoders/trx-ftx/src/{ => common}/osd.rs | 14 +- .../trx-ftx/src/{ => common}/protocol.rs | 0 src/decoders/trx-ftx/src/{ => common}/text.rs | 0 src/decoders/trx-ftx/src/decode.rs | 684 ------------------ src/decoders/trx-ftx/src/decoder.rs | 12 +- src/decoders/trx-ftx/src/encode.rs | 411 ----------- .../trx-ftx/src/{ => ft2}/bitmetrics.rs | 4 +- src/decoders/trx-ftx/src/ft2/decode.rs | 167 +++++ .../trx-ftx/src/{ => ft2}/downsample.rs | 2 +- .../trx-ftx/src/{ft2.rs => ft2/mod.rs} | 24 +- .../trx-ftx/src/{ft2_sync.rs => ft2/sync.rs} | 4 +- src/decoders/trx-ftx/src/ft4/mod.rs | 257 +++++++ src/decoders/trx-ftx/src/ft8/mod.rs | 240 ++++++ src/decoders/trx-ftx/src/lib.rs | 24 +- 23 files changed, 1231 insertions(+), 1149 deletions(-) rename src/decoders/trx-ftx/src/{ => common}/callsign_hash.rs (99%) rename src/decoders/trx-ftx/src/{ => common}/constants.rs (99%) rename src/decoders/trx-ftx/src/{ => common}/crc.rs (97%) create mode 100644 src/decoders/trx-ftx/src/common/decode.rs create mode 100644 src/decoders/trx-ftx/src/common/encode.rs rename src/decoders/trx-ftx/src/{ => common}/ldpc.rs (98%) rename src/decoders/trx-ftx/src/{ => common}/message.rs (99%) create mode 100644 src/decoders/trx-ftx/src/common/mod.rs rename src/decoders/trx-ftx/src/{ => common}/monitor.rs (99%) rename src/decoders/trx-ftx/src/{ => common}/osd.rs (98%) rename src/decoders/trx-ftx/src/{ => common}/protocol.rs (100%) rename src/decoders/trx-ftx/src/{ => common}/text.rs (100%) delete mode 100644 src/decoders/trx-ftx/src/decode.rs delete mode 100644 src/decoders/trx-ftx/src/encode.rs rename src/decoders/trx-ftx/src/{ => ft2}/bitmetrics.rs (98%) create mode 100644 src/decoders/trx-ftx/src/ft2/decode.rs rename src/decoders/trx-ftx/src/{ => ft2}/downsample.rs (99%) rename src/decoders/trx-ftx/src/{ft2.rs => ft2/mod.rs} (97%) rename src/decoders/trx-ftx/src/{ft2_sync.rs => ft2/sync.rs} (98%) create mode 100644 src/decoders/trx-ftx/src/ft4/mod.rs create mode 100644 src/decoders/trx-ftx/src/ft8/mod.rs diff --git a/src/decoders/trx-ftx/src/callsign_hash.rs b/src/decoders/trx-ftx/src/common/callsign_hash.rs similarity index 99% rename from src/decoders/trx-ftx/src/callsign_hash.rs rename to src/decoders/trx-ftx/src/common/callsign_hash.rs index 44d8176..4822077 100644 --- a/src/decoders/trx-ftx/src/callsign_hash.rs +++ b/src/decoders/trx-ftx/src/common/callsign_hash.rs @@ -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; diff --git a/src/decoders/trx-ftx/src/constants.rs b/src/decoders/trx-ftx/src/common/constants.rs similarity index 99% rename from src/decoders/trx-ftx/src/constants.rs rename to src/decoders/trx-ftx/src/common/constants.rs index 9d65bda..ada474c 100644 --- a/src/decoders/trx-ftx/src/constants.rs +++ b/src/decoders/trx-ftx/src/common/constants.rs @@ -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]; diff --git a/src/decoders/trx-ftx/src/crc.rs b/src/decoders/trx-ftx/src/common/crc.rs similarity index 97% rename from src/decoders/trx-ftx/src/crc.rs rename to src/decoders/trx-ftx/src/common/crc.rs index 4d9a172..b508898 100644 --- a/src/decoders/trx-ftx/src/crc.rs +++ b/src/decoders/trx-ftx/src/common/crc.rs @@ -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); diff --git a/src/decoders/trx-ftx/src/common/decode.rs b/src/decoders/trx-ftx/src/common/decode.rs new file mode 100644 index 0000000..640f131 --- /dev/null +++ b/src/decoders/trx-ftx/src/common/decode.rs @@ -0,0 +1,350 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 { + 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 { + 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 { + 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 +} diff --git a/src/decoders/trx-ftx/src/common/encode.rs b/src/decoders/trx-ftx/src/common/encode.rs new file mode 100644 index 0000000..dee0f9f --- /dev/null +++ b/src/decoders/trx-ftx/src/common/encode.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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); + } + } +} diff --git a/src/decoders/trx-ftx/src/ldpc.rs b/src/decoders/trx-ftx/src/common/ldpc.rs similarity index 98% rename from src/decoders/trx-ftx/src/ldpc.rs rename to src/decoders/trx-ftx/src/common/ldpc.rs index e91b91f..78c35f0 100644 --- a/src/decoders/trx-ftx/src/ldpc.rs +++ b/src/decoders/trx-ftx/src/common/ldpc.rs @@ -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 { diff --git a/src/decoders/trx-ftx/src/message.rs b/src/decoders/trx-ftx/src/common/message.rs similarity index 99% rename from src/decoders/trx-ftx/src/message.rs rename to src/decoders/trx-ftx/src/common/message.rs index b7c0222..45d2412 100644 --- a/src/decoders/trx-ftx/src/message.rs +++ b/src/decoders/trx-ftx/src/common/message.rs @@ -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; diff --git a/src/decoders/trx-ftx/src/common/mod.rs b/src/decoders/trx-ftx/src/common/mod.rs new file mode 100644 index 0000000..afddaef --- /dev/null +++ b/src/decoders/trx-ftx/src/common/mod.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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; diff --git a/src/decoders/trx-ftx/src/monitor.rs b/src/decoders/trx-ftx/src/common/monitor.rs similarity index 99% rename from src/decoders/trx-ftx/src/monitor.rs rename to src/decoders/trx-ftx/src/common/monitor.rs index 121c9cd..225678c 100644 --- a/src/decoders/trx-ftx/src/monitor.rs +++ b/src/decoders/trx-ftx/src/common/monitor.rs @@ -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)] diff --git a/src/decoders/trx-ftx/src/osd.rs b/src/decoders/trx-ftx/src/common/osd.rs similarity index 98% rename from src/decoders/trx-ftx/src/osd.rs rename to src/decoders/trx-ftx/src/common/osd.rs index 71bf9ac..23a28e6 100644 --- a/src/decoders/trx-ftx/src/osd.rs +++ b/src/decoders/trx-ftx/src/common/osd.rs @@ -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() { diff --git a/src/decoders/trx-ftx/src/protocol.rs b/src/decoders/trx-ftx/src/common/protocol.rs similarity index 100% rename from src/decoders/trx-ftx/src/protocol.rs rename to src/decoders/trx-ftx/src/common/protocol.rs diff --git a/src/decoders/trx-ftx/src/text.rs b/src/decoders/trx-ftx/src/common/text.rs similarity index 100% rename from src/decoders/trx-ftx/src/text.rs rename to src/decoders/trx-ftx/src/common/text.rs diff --git a/src/decoders/trx-ftx/src/decode.rs b/src/decoders/trx-ftx/src/decode.rs deleted file mode 100644 index d771b2b..0000000 --- a/src/decoders/trx-ftx/src/decode.rs +++ /dev/null @@ -1,684 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// 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 { - 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 { - 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 { - 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 -} diff --git a/src/decoders/trx-ftx/src/decoder.rs b/src/decoders/trx-ftx/src/decoder.rs index eef1578..df6cbb1 100644 --- a/src/decoders/trx-ftx/src/decoder.rs +++ b/src/decoders/trx-ftx/src/decoder.rs @@ -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; diff --git a/src/decoders/trx-ftx/src/encode.rs b/src/decoders/trx-ftx/src/encode.rs deleted file mode 100644 index d9f6d8d..0000000 --- a/src/decoders/trx-ftx/src/encode.rs +++ /dev/null @@ -1,411 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// 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); - } -} diff --git a/src/decoders/trx-ftx/src/bitmetrics.rs b/src/decoders/trx-ftx/src/ft2/bitmetrics.rs similarity index 98% rename from src/decoders/trx-ftx/src/bitmetrics.rs rename to src/decoders/trx-ftx/src/ft2/bitmetrics.rs index 537af0c..f9d5ac9 100644 --- a/src/decoders/trx-ftx/src/bitmetrics.rs +++ b/src/decoders/trx-ftx/src/ft2/bitmetrics.rs @@ -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; diff --git a/src/decoders/trx-ftx/src/ft2/decode.rs b/src/decoders/trx-ftx/src/ft2/decode.rs new file mode 100644 index 0000000..2c98f1e --- /dev/null +++ b/src/decoders/trx-ftx/src/ft2/decode.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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; + } + } +} diff --git a/src/decoders/trx-ftx/src/downsample.rs b/src/decoders/trx-ftx/src/ft2/downsample.rs similarity index 99% rename from src/decoders/trx-ftx/src/downsample.rs rename to src/decoders/trx-ftx/src/ft2/downsample.rs index b4fd784..e92a528 100644 --- a/src/decoders/trx-ftx/src/downsample.rs +++ b/src/decoders/trx-ftx/src/ft2/downsample.rs @@ -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 { diff --git a/src/decoders/trx-ftx/src/ft2.rs b/src/decoders/trx-ftx/src/ft2/mod.rs similarity index 97% rename from src/decoders/trx-ftx/src/ft2.rs rename to src/decoders/trx-ftx/src/ft2/mod.rs index c0227ed..73f565b 100644 --- a/src/decoders/trx-ftx/src/ft2.rs +++ b/src/decoders/trx-ftx/src/ft2/mod.rs @@ -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); } diff --git a/src/decoders/trx-ftx/src/ft2_sync.rs b/src/decoders/trx-ftx/src/ft2/sync.rs similarity index 98% rename from src/decoders/trx-ftx/src/ft2_sync.rs rename to src/decoders/trx-ftx/src/ft2/sync.rs index cb09ea5..447d99d 100644 --- a/src/decoders/trx-ftx/src/ft2_sync.rs +++ b/src/decoders/trx-ftx/src/ft2/sync.rs @@ -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; diff --git a/src/decoders/trx-ftx/src/ft4/mod.rs b/src/decoders/trx-ftx/src/ft4/mod.rs new file mode 100644 index 0000000..1ce0f9c --- /dev/null +++ b/src/decoders/trx-ftx/src/ft4/mod.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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); + } +} diff --git a/src/decoders/trx-ftx/src/ft8/mod.rs b/src/decoders/trx-ftx/src/ft8/mod.rs new file mode 100644 index 0000000..e923c6f --- /dev/null +++ b/src/decoders/trx-ftx/src/ft8/mod.rs @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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); + } +} diff --git a/src/decoders/trx-ftx/src/lib.rs b/src/decoders/trx-ftx/src/lib.rs index 9e2c8ba..2d3d17c 100644 --- a/src/decoders/trx-ftx/src/lib.rs +++ b/src/decoders/trx-ftx/src/lib.rs @@ -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};