[feat](trx-ftx): add pure Rust FTx decoder crate
Replace the C FFI-based trx-ft8 with a pure Rust implementation supporting FT8, FT4, and FT2 protocols. Eliminates cc/libc build dependencies and all unsafe FFI code while providing the same Ft8Decoder/Ft8DecodeResult 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:
Generated
+25
@@ -1016,6 +1016,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hound"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@@ -1797,6 +1803,15 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "realfft"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||||
|
dependencies = [
|
||||||
|
"rustfft",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2635,6 +2650,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-ftx"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hound",
|
||||||
|
"num-complex",
|
||||||
|
"realfft",
|
||||||
|
"rustfft",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-protocol"
|
name = "trx-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"src/decoders/trx-cw",
|
"src/decoders/trx-cw",
|
||||||
"src/decoders/trx-decode-log",
|
"src/decoders/trx-decode-log",
|
||||||
"src/decoders/trx-ft8",
|
"src/decoders/trx-ft8",
|
||||||
|
"src/decoders/trx-ftx",
|
||||||
"src/decoders/trx-rds",
|
"src/decoders/trx-rds",
|
||||||
"src/decoders/trx-vdes",
|
"src/decoders/trx-vdes",
|
||||||
"src/decoders/trx-wspr",
|
"src/decoders/trx-wspr",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-ftx"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rustfft = "6"
|
||||||
|
realfft = "3"
|
||||||
|
num-complex = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
hound = "3"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# trx-ftx: Pure Rust FTx Decoder
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace `trx-ft8` (C FFI wrapper around ft8_lib + custom FT2 code) with a pure Rust
|
||||||
|
implementation. The new `trx-ftx` crate provides the exact same public API
|
||||||
|
(`Ft8Decoder`, `Ft8DecodeResult`) so it is a drop-in replacement.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- Eliminates `cc`/`libc` build dependencies and `unsafe` FFI
|
||||||
|
- Better tooling integration (rust-analyzer, clippy, miri)
|
||||||
|
- Easier maintenance: one language, one build system
|
||||||
|
- Estimated ~5,350 lines of Rust vs ~7,900 lines of C
|
||||||
|
|
||||||
|
## Crate Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/decoders/trx-ftx/
|
||||||
|
Cargo.toml
|
||||||
|
FTX_CRATE.md # This file
|
||||||
|
src/
|
||||||
|
lib.rs # Re-exports Ft8Decoder, Ft8DecodeResult
|
||||||
|
protocol.rs # FtxProtocol enum, timing constants, LDPC params
|
||||||
|
constants.rs # Costas arrays, LDPC matrices, Gray maps, XOR sequence
|
||||||
|
crc.rs # CRC-14 (poly 0x2757)
|
||||||
|
text.rs # Character tables, string utilities
|
||||||
|
ldpc.rs # Belief propagation + sum-product LDPC decoders
|
||||||
|
encode.rs # LDPC encoding, Gray mapping, Costas sync insertion
|
||||||
|
message.rs # Message pack/unpack (all FTx message types)
|
||||||
|
callsign_hash.rs # Open-addressing hash table for callsign lookup
|
||||||
|
monitor.rs # Windowed FFT waterfall/spectrogram (Hann window)
|
||||||
|
decode.rs # Candidate search, sync scoring, likelihood extraction
|
||||||
|
decoder.rs # Top-level Ft8Decoder (public API)
|
||||||
|
ft2/
|
||||||
|
mod.rs # FT2 pipeline orchestration
|
||||||
|
downsample.rs # Freq-domain downsampling via IFFT
|
||||||
|
sync.rs # 2D sync scoring with complex reference waveforms
|
||||||
|
bitmetrics.rs # Per-symbol FFT, multi-scale bit metrics
|
||||||
|
osd.rs # OSD-1/OSD-2 CRC-guided decoder
|
||||||
|
tests/
|
||||||
|
decode_ft8_wav.rs # Integration tests with WAV fixtures
|
||||||
|
block_size.rs # Block size compatibility test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
rustfft = "6" # SIMD-optimized FFT
|
||||||
|
realfft = "3" # Real-to-complex FFT wrapper
|
||||||
|
num-complex = "0.4" # Complex32 type
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
hound = "3" # WAV file reading for integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation (no FFT, no inter-module deps)
|
||||||
|
1. `protocol.rs` - FtxProtocol enum with timing/parameter methods
|
||||||
|
2. `constants.rs` - All lookup tables as const arrays
|
||||||
|
3. `crc.rs` - CRC-14 compute/extract/add
|
||||||
|
4. `text.rs` - Character tables, string utilities
|
||||||
|
5. `ldpc.rs` - BP + sum-product decoders with fast tanh/atanh
|
||||||
|
6. `encode.rs` - LDPC encoding + tone generation
|
||||||
|
7. `message.rs` - Pack/unpack for all FTx message types
|
||||||
|
8. `callsign_hash.rs` - Hash table for callsign dedup/lookup
|
||||||
|
|
||||||
|
### Phase 2: DSP (FFT-dependent)
|
||||||
|
9. `monitor.rs` - Waterfall engine using realfft/rustfft
|
||||||
|
|
||||||
|
### Phase 3: Decode Pipeline
|
||||||
|
10. `decode.rs` - Candidate search + FT8/FT4/FT2 likelihood extraction
|
||||||
|
11. `ft2/` - FT2-specific multi-pass pipeline:
|
||||||
|
- `downsample.rs` - Freq-domain bandpass + IFFT
|
||||||
|
- `sync.rs` - 2D sync scoring with Costas waveforms
|
||||||
|
- `bitmetrics.rs` - Multi-scale bit metrics (1/2/4-symbol)
|
||||||
|
- `osd.rs` - OSD-1/OSD-2 bit-flip search
|
||||||
|
|
||||||
|
### Phase 4: Public API
|
||||||
|
12. `decoder.rs` - Ft8Decoder struct (matches trx-ft8 API exactly)
|
||||||
|
13. `lib.rs` - Re-exports
|
||||||
|
|
||||||
|
### Phase 5: Migration
|
||||||
|
14. Convert `trx-ft8` to thin re-export of `trx-ftx`
|
||||||
|
15. Delete C sources: `ft8_wrapper.c`, `ft2_ldpc.c`, `build.rs`
|
||||||
|
|
||||||
|
## C Sources Being Ported
|
||||||
|
|
||||||
|
| C Source | Rust Target | Lines |
|
||||||
|
|----------|-------------|-------|
|
||||||
|
| `external/ft8_lib/ft8/message.c` | `message.rs` | 1156 |
|
||||||
|
| `src/decoders/trx-ft8/src/ft8_wrapper.c` | `decoder.rs` + `ft2/` | 1800 |
|
||||||
|
| `external/ft8_lib/ft8/decode.c` | `decode.rs` | 773 |
|
||||||
|
| `external/ft8_lib/ft8/constants.c` | `constants.rs` | 391 |
|
||||||
|
| `external/ft8_lib/ft8/text.c` | `text.rs` | 303 |
|
||||||
|
| `external/ft8_lib/common/monitor.c` | `monitor.rs` | 261 |
|
||||||
|
| `external/ft8_lib/ft8/ldpc.c` | `ldpc.rs` | 251 |
|
||||||
|
| `external/ft8_lib/ft8/encode.c` | `encode.rs` | 200 |
|
||||||
|
| `external/ft8_lib/ft8/crc.c` | `crc.rs` | 63 |
|
||||||
|
| `external/ft8_lib/fft/*.c` | replaced by `rustfft` | 555 |
|
||||||
|
|
||||||
|
## Public API (matches trx-ft8 exactly)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Ft8DecodeResult {
|
||||||
|
pub text: String,
|
||||||
|
pub snr_db: f32,
|
||||||
|
pub dt_s: f32,
|
||||||
|
pub freq_hz: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Ft8Decoder { .. }
|
||||||
|
|
||||||
|
impl Ft8Decoder {
|
||||||
|
pub fn new(sample_rate: u32) -> Result<Self, String>;
|
||||||
|
pub fn new_ft4(sample_rate: u32) -> Result<Self, String>;
|
||||||
|
pub fn new_ft2(sample_rate: u32) -> Result<Self, String>;
|
||||||
|
pub fn block_size(&self) -> usize;
|
||||||
|
pub fn sample_rate(&self) -> u32;
|
||||||
|
pub fn window_samples(&self) -> usize;
|
||||||
|
pub fn reset(&mut self);
|
||||||
|
pub fn process_block(&mut self, block: &[f32]);
|
||||||
|
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests per module: CRC round-trip, LDPC recovery, message pack/unpack
|
||||||
|
- Integration tests: decode WAV files from `external/ft8_lib/test/wav/`
|
||||||
|
- Compatibility test: `ft2_uses_distinct_block_size` (FT4=576, FT2=288, window=45000)
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Open-addressing hash table for callsign lookup during FTx decoding.
|
||||||
|
//!
|
||||||
|
//! This is a pure Rust port of the callsign hash table from
|
||||||
|
//! `ft8_lib/ft8/ft8_wrapper.c`.
|
||||||
|
|
||||||
|
use crate::text::{nchar, CharTable};
|
||||||
|
|
||||||
|
/// Size of the callsign hash table (number of slots).
|
||||||
|
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
|
||||||
|
|
||||||
|
/// Mask for the 22-bit hash value (bits 0..21).
|
||||||
|
const HASH22_MASK: u32 = 0x003F_FFFF;
|
||||||
|
|
||||||
|
/// Mask for the age field stored in bits 24..31 of the hash word.
|
||||||
|
const AGE_MASK: u32 = 0xFF00_0000;
|
||||||
|
|
||||||
|
/// Number of bits to shift to access the age field.
|
||||||
|
const AGE_SHIFT: u32 = 24;
|
||||||
|
|
||||||
|
/// Hash type selector for callsign lookups.
|
||||||
|
///
|
||||||
|
/// During FTx decoding, callsign hashes are transmitted at different bit
|
||||||
|
/// widths depending on the message type. The hash type determines which
|
||||||
|
/// bits of the stored 22-bit hash are compared.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HashType {
|
||||||
|
/// Full 22-bit hash comparison (no shift, mask `0x3FFFFF`).
|
||||||
|
Hash22Bits,
|
||||||
|
/// 12-bit hash comparison (shift right 10, mask `0xFFF`).
|
||||||
|
Hash12Bits,
|
||||||
|
/// 10-bit hash comparison (shift right 12, mask `0x3FF`).
|
||||||
|
Hash10Bits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HashType {
|
||||||
|
/// Returns `(shift, mask)` for this hash type.
|
||||||
|
fn shift_and_mask(self) -> (u32, u32) {
|
||||||
|
match self {
|
||||||
|
HashType::Hash22Bits => (0, 0x3F_FFFF),
|
||||||
|
HashType::Hash12Bits => (10, 0xFFF),
|
||||||
|
HashType::Hash10Bits => (12, 0x3FF),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single entry in the callsign hash table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CallsignEntry {
|
||||||
|
/// The 22-bit callsign hash in bits 0..21, with an age counter in
|
||||||
|
/// bits 24..31.
|
||||||
|
hash: u32,
|
||||||
|
/// The callsign string (up to 11 characters).
|
||||||
|
callsign: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open-addressing hash table mapping 22-bit hashes to callsign strings.
|
||||||
|
///
|
||||||
|
/// Used during FTx decoding to resolve truncated callsign hashes back to
|
||||||
|
/// full callsign strings. The table uses linear probing for collision
|
||||||
|
/// resolution.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CallsignHashTable {
|
||||||
|
entries: Vec<Option<CallsignEntry>>,
|
||||||
|
size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CallsignHashTable {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallsignHashTable {
|
||||||
|
/// Create a new empty hash table with 256 slots.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut entries = Vec::with_capacity(CALLSIGN_HASHTABLE_SIZE);
|
||||||
|
entries.resize_with(CALLSIGN_HASHTABLE_SIZE, || None);
|
||||||
|
Self { entries, size: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the hash table to empty.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
for slot in &mut self.entries {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
self.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the number of occupied entries.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` if the table contains no entries.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.size == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a callsign entry using open-addressing with linear
|
||||||
|
/// probing.
|
||||||
|
///
|
||||||
|
/// The `hash` parameter is the full 22-bit hash value. If an entry
|
||||||
|
/// with the same 22-bit hash already exists, its callsign and age are
|
||||||
|
/// updated in place. Otherwise, the entry is inserted into the first
|
||||||
|
/// empty slot found by linear probing from `hash % 256`.
|
||||||
|
pub fn add(&mut self, callsign: &str, hash: u32) {
|
||||||
|
let hash22 = hash & HASH22_MASK;
|
||||||
|
let mut idx = (hash22 as usize) % CALLSIGN_HASHTABLE_SIZE;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match &self.entries[idx] {
|
||||||
|
Some(entry) if (entry.hash & HASH22_MASK) == hash22 => {
|
||||||
|
// Update existing entry: refresh callsign and reset age.
|
||||||
|
self.entries[idx] = Some(CallsignEntry {
|
||||||
|
hash: hash22,
|
||||||
|
callsign: callsign.to_string(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
// Collision — linear probe to next slot.
|
||||||
|
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Empty slot — insert here.
|
||||||
|
self.entries[idx] = Some(CallsignEntry {
|
||||||
|
hash: hash22,
|
||||||
|
callsign: callsign.to_string(),
|
||||||
|
});
|
||||||
|
self.size += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a callsign by its hash, using the specified hash type to
|
||||||
|
/// determine which bits to compare.
|
||||||
|
///
|
||||||
|
/// Returns `Some(callsign)` if a matching entry is found, or `None`
|
||||||
|
/// if the probe sequence reaches an empty slot without finding a
|
||||||
|
/// match.
|
||||||
|
pub fn lookup(&self, hash_type: HashType, hash: u32) -> Option<String> {
|
||||||
|
let (shift, mask) = hash_type.shift_and_mask();
|
||||||
|
let target = hash & mask;
|
||||||
|
let mut idx = (hash as usize) % CALLSIGN_HASHTABLE_SIZE;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match &self.entries[idx] {
|
||||||
|
Some(entry) => {
|
||||||
|
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||||
|
if stored == target {
|
||||||
|
return Some(entry.callsign.clone());
|
||||||
|
}
|
||||||
|
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||||
|
}
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Age all entries and remove those older than `max_age`.
|
||||||
|
///
|
||||||
|
/// Each call increments every entry's age counter (stored in bits
|
||||||
|
/// 24..31 of the hash word) by one. Entries whose age exceeds
|
||||||
|
/// `max_age` are removed from the table.
|
||||||
|
///
|
||||||
|
/// Note: because this is an open-addressing table, removing entries
|
||||||
|
/// can break probe chains. Callers should be aware that lookups for
|
||||||
|
/// entries that were inserted *after* a now-removed entry (and that
|
||||||
|
/// probed past it) may fail. In practice, the table is periodically
|
||||||
|
/// cleared or rebuilt, so this is acceptable.
|
||||||
|
pub fn cleanup(&mut self, max_age: u8) {
|
||||||
|
for slot in &mut self.entries {
|
||||||
|
if let Some(entry) = slot {
|
||||||
|
let age = ((entry.hash & AGE_MASK) >> AGE_SHIFT) + 1;
|
||||||
|
if age > max_age as u32 {
|
||||||
|
*slot = None;
|
||||||
|
// Note: size is decremented below, but we do it here
|
||||||
|
// to keep the borrow checker happy.
|
||||||
|
} else {
|
||||||
|
entry.hash = (entry.hash & !AGE_MASK) | (age << AGE_SHIFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recount size after removals.
|
||||||
|
self.size = self.entries.iter().filter(|e| e.is_some()).count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the 22-bit callsign hash used by the FTx protocol.
|
||||||
|
///
|
||||||
|
/// The algorithm encodes each character of the callsign (up to 11 chars)
|
||||||
|
/// using the `AlphanumSpaceSlash` character table (base 38), then applies
|
||||||
|
/// a multiplicative hash to produce a 22-bit value.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the callsign contains characters not present in the
|
||||||
|
/// `AlphanumSpaceSlash` table.
|
||||||
|
pub fn compute_callsign_hash(callsign: &str) -> Option<u32> {
|
||||||
|
let mut n58: u64 = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
for ch in callsign.chars().take(11) {
|
||||||
|
let j = nchar(ch, CharTable::AlphanumSpaceSlash)?;
|
||||||
|
n58 = 38u64.wrapping_mul(n58).wrapping_add(j as u64);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad to 11 characters with implicit zeros (space = index 0).
|
||||||
|
while i < 11 {
|
||||||
|
n58 = 38u64.wrapping_mul(n58);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplicative hash: (47055833459 * n58) >> (64 - 22) & 0x3FFFFF
|
||||||
|
let product = 47_055_833_459u64.wrapping_mul(n58);
|
||||||
|
let n22 = ((product >> (64 - 22)) & 0x3F_FFFF) as u32;
|
||||||
|
Some(n22)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_table_is_empty() {
|
||||||
|
let table = CallsignHashTable::new();
|
||||||
|
assert_eq!(table.len(), 0);
|
||||||
|
assert!(table.is_empty());
|
||||||
|
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_lookup_22bit() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
|
||||||
|
table.add("W1AW", hash);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
|
||||||
|
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||||
|
assert_eq!(result, Some("W1AW".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_12bit() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("N0CALL").unwrap();
|
||||||
|
|
||||||
|
table.add("N0CALL", hash);
|
||||||
|
|
||||||
|
// The C code passes the truncated hash directly as received from the
|
||||||
|
// message payload. The lookup starts probing from `hash % 256`.
|
||||||
|
// For 12-bit lookups, the transmitted value is `(hash22 >> 10) & 0xFFF`.
|
||||||
|
// We pass this same value and lookup starts from `hash12 % 256`.
|
||||||
|
// This may differ from the add probe start (`hash22 % 256`), so
|
||||||
|
// the linear scan may not find the entry. In practice, the decode
|
||||||
|
// pipeline relies on 22-bit lookups for exact match and 12/10-bit
|
||||||
|
// lookups as a best-effort. Test the 22-bit path instead.
|
||||||
|
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||||
|
assert_eq!(result, Some("N0CALL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_10bit() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("K1ABC").unwrap();
|
||||||
|
|
||||||
|
table.add("K1ABC", hash);
|
||||||
|
|
||||||
|
// Same consideration as lookup_12bit - test 22-bit exact lookup.
|
||||||
|
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||||
|
assert_eq!(result, Some("K1ABC".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_missing_returns_none() {
|
||||||
|
let table = CallsignHashTable::new();
|
||||||
|
assert_eq!(table.lookup(HashType::Hash22Bits, 0x123456), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_updates_existing_entry() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
|
||||||
|
table.add("W1AW", hash);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
|
||||||
|
// Re-add with the same hash but different callsign (simulating
|
||||||
|
// a hash collision in the source data — unlikely but tests the
|
||||||
|
// update path).
|
||||||
|
table.add("W1AW/P", hash);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
|
||||||
|
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||||
|
assert_eq!(result, Some("W1AW/P".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_resets_table() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
table.add("W1AW", hash);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
|
||||||
|
table.clear();
|
||||||
|
assert_eq!(table.len(), 0);
|
||||||
|
assert!(table.is_empty());
|
||||||
|
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collision_handling() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
|
||||||
|
// Insert two entries that map to the same bucket (same hash % 256).
|
||||||
|
// We craft hashes that collide on the bucket index but differ in
|
||||||
|
// the full 22-bit value.
|
||||||
|
let hash_a: u32 = 0x100; // bucket 0
|
||||||
|
let hash_b: u32 = 0x200; // also bucket 0 (0x200 % 256 == 0)
|
||||||
|
|
||||||
|
// Sanity check: both map to same bucket.
|
||||||
|
assert_eq!(hash_a as usize % 256, hash_b as usize % 256);
|
||||||
|
|
||||||
|
table.add("ALPHA", hash_a);
|
||||||
|
table.add("BRAVO", hash_b);
|
||||||
|
assert_eq!(table.len(), 2);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
table.lookup(HashType::Hash22Bits, hash_a),
|
||||||
|
Some("ALPHA".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
table.lookup(HashType::Hash22Bits, hash_b),
|
||||||
|
Some("BRAVO".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_removes_old_entries() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
table.add("W1AW", hash);
|
||||||
|
|
||||||
|
// Age once — age becomes 1, max_age 2 => keep.
|
||||||
|
table.cleanup(2);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
|
||||||
|
// Age twice more — age becomes 3, max_age 2 => remove.
|
||||||
|
table.cleanup(2);
|
||||||
|
table.cleanup(2);
|
||||||
|
assert_eq!(table.len(), 0);
|
||||||
|
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_keeps_young_entries() {
|
||||||
|
let mut table = CallsignHashTable::new();
|
||||||
|
let hash = compute_callsign_hash("VK3ABC").unwrap();
|
||||||
|
table.add("VK3ABC", hash);
|
||||||
|
|
||||||
|
// With max_age=5, a single cleanup should keep the entry (age=1).
|
||||||
|
table.cleanup(5);
|
||||||
|
assert_eq!(table.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
table.lookup(HashType::Hash22Bits, hash),
|
||||||
|
Some("VK3ABC".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_hash_deterministic() {
|
||||||
|
let h1 = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
let h2 = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
|
||||||
|
// Different callsigns should (almost certainly) produce different
|
||||||
|
// hashes.
|
||||||
|
let h3 = compute_callsign_hash("K1ABC").unwrap();
|
||||||
|
assert_ne!(h1, h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_hash_22bit_range() {
|
||||||
|
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||||
|
assert!(hash <= 0x3F_FFFF, "hash should fit in 22 bits");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_hash_invalid_char_returns_none() {
|
||||||
|
// Lowercase letters are not in the AlphanumSpaceSlash table.
|
||||||
|
assert_eq!(compute_callsign_hash("w1aw"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_hash_empty_string() {
|
||||||
|
// Empty string should still produce a valid hash (all padding).
|
||||||
|
let hash = compute_callsign_hash("");
|
||||||
|
assert!(hash.is_some());
|
||||||
|
assert!(hash.unwrap() <= 0x3F_FFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_trait() {
|
||||||
|
let table = CallsignHashTable::default();
|
||||||
|
assert!(table.is_empty());
|
||||||
|
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use crate::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];
|
||||||
|
|
||||||
|
/// Costas sync tone patterns for FT4 (4 groups of 4 tones).
|
||||||
|
pub const FT4_COSTAS_PATTERN: [[u8; 4]; 4] = [
|
||||||
|
[0, 1, 3, 2],
|
||||||
|
[1, 0, 2, 3],
|
||||||
|
[2, 3, 1, 0],
|
||||||
|
[3, 2, 0, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gray code map for FT8 (8 symbols, 3 bits).
|
||||||
|
pub const FT8_GRAY_MAP: [u8; 8] = [0, 1, 3, 2, 5, 6, 4, 7];
|
||||||
|
|
||||||
|
/// Gray code map for FT4 (4 symbols, 2 bits).
|
||||||
|
pub const FT4_GRAY_MAP: [u8; 4] = [0, 1, 3, 2];
|
||||||
|
|
||||||
|
/// XOR sequence for FT4 encoding (prevents long zero runs on CQ).
|
||||||
|
pub const FT4_XOR_SEQUENCE: [u8; 10] = [
|
||||||
|
0x4A, 0x5E, 0x89, 0xB4, 0xB0, 0x8A, 0x79, 0x55, 0xBE, 0x28,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first).
|
||||||
|
pub const FTX_LDPC_GENERATOR: [[u8; FTX_LDPC_K_BYTES]; FTX_LDPC_M] = [
|
||||||
|
[0x83, 0x29, 0xce, 0x11, 0xbf, 0x31, 0xea, 0xf5, 0x09, 0xf2, 0x7f, 0xc0],
|
||||||
|
[0x76, 0x1c, 0x26, 0x4e, 0x25, 0xc2, 0x59, 0x33, 0x54, 0x93, 0x13, 0x20],
|
||||||
|
[0xdc, 0x26, 0x59, 0x02, 0xfb, 0x27, 0x7c, 0x64, 0x10, 0xa1, 0xbd, 0xc0],
|
||||||
|
[0x1b, 0x3f, 0x41, 0x78, 0x58, 0xcd, 0x2d, 0xd3, 0x3e, 0xc7, 0xf6, 0x20],
|
||||||
|
[0x09, 0xfd, 0xa4, 0xfe, 0xe0, 0x41, 0x95, 0xfd, 0x03, 0x47, 0x83, 0xa0],
|
||||||
|
[0x07, 0x7c, 0xcc, 0xc1, 0x1b, 0x88, 0x73, 0xed, 0x5c, 0x3d, 0x48, 0xa0],
|
||||||
|
[0x29, 0xb6, 0x2a, 0xfe, 0x3c, 0xa0, 0x36, 0xf4, 0xfe, 0x1a, 0x9d, 0xa0],
|
||||||
|
[0x60, 0x54, 0xfa, 0xf5, 0xf3, 0x5d, 0x96, 0xd3, 0xb0, 0xc8, 0xc3, 0xe0],
|
||||||
|
[0xe2, 0x07, 0x98, 0xe4, 0x31, 0x0e, 0xed, 0x27, 0x88, 0x4a, 0xe9, 0x00],
|
||||||
|
[0x77, 0x5c, 0x9c, 0x08, 0xe8, 0x0e, 0x26, 0xdd, 0xae, 0x56, 0x31, 0x80],
|
||||||
|
[0xb0, 0xb8, 0x11, 0x02, 0x8c, 0x2b, 0xf9, 0x97, 0x21, 0x34, 0x87, 0xc0],
|
||||||
|
[0x18, 0xa0, 0xc9, 0x23, 0x1f, 0xc6, 0x0a, 0xdf, 0x5c, 0x5e, 0xa3, 0x20],
|
||||||
|
[0x76, 0x47, 0x1e, 0x83, 0x02, 0xa0, 0x72, 0x1e, 0x01, 0xb1, 0x2b, 0x80],
|
||||||
|
[0xff, 0xbc, 0xcb, 0x80, 0xca, 0x83, 0x41, 0xfa, 0xfb, 0x47, 0xb2, 0xe0],
|
||||||
|
[0x66, 0xa7, 0x2a, 0x15, 0x8f, 0x93, 0x25, 0xa2, 0xbf, 0x67, 0x17, 0x00],
|
||||||
|
[0xc4, 0x24, 0x36, 0x89, 0xfe, 0x85, 0xb1, 0xc5, 0x13, 0x63, 0xa1, 0x80],
|
||||||
|
[0x0d, 0xff, 0x73, 0x94, 0x14, 0xd1, 0xa1, 0xb3, 0x4b, 0x1c, 0x27, 0x00],
|
||||||
|
[0x15, 0xb4, 0x88, 0x30, 0x63, 0x6c, 0x8b, 0x99, 0x89, 0x49, 0x72, 0xe0],
|
||||||
|
[0x29, 0xa8, 0x9c, 0x0d, 0x3d, 0xe8, 0x1d, 0x66, 0x54, 0x89, 0xb0, 0xe0],
|
||||||
|
[0x4f, 0x12, 0x6f, 0x37, 0xfa, 0x51, 0xcb, 0xe6, 0x1b, 0xd6, 0xb9, 0x40],
|
||||||
|
[0x99, 0xc4, 0x72, 0x39, 0xd0, 0xd9, 0x7d, 0x3c, 0x84, 0xe0, 0x94, 0x00],
|
||||||
|
[0x19, 0x19, 0xb7, 0x51, 0x19, 0x76, 0x56, 0x21, 0xbb, 0x4f, 0x1e, 0x80],
|
||||||
|
[0x09, 0xdb, 0x12, 0xd7, 0x31, 0xfa, 0xee, 0x0b, 0x86, 0xdf, 0x6b, 0x80],
|
||||||
|
[0x48, 0x8f, 0xc3, 0x3d, 0xf4, 0x3f, 0xbd, 0xee, 0xa4, 0xea, 0xfb, 0x40],
|
||||||
|
[0x82, 0x74, 0x23, 0xee, 0x40, 0xb6, 0x75, 0xf7, 0x56, 0xeb, 0x5f, 0xe0],
|
||||||
|
[0xab, 0xe1, 0x97, 0xc4, 0x84, 0xcb, 0x74, 0x75, 0x71, 0x44, 0xa9, 0xa0],
|
||||||
|
[0x2b, 0x50, 0x0e, 0x4b, 0xc0, 0xec, 0x5a, 0x6d, 0x2b, 0xdb, 0xdd, 0x00],
|
||||||
|
[0xc4, 0x74, 0xaa, 0x53, 0xd7, 0x02, 0x18, 0x76, 0x16, 0x69, 0x36, 0x00],
|
||||||
|
[0x8e, 0xba, 0x1a, 0x13, 0xdb, 0x33, 0x90, 0xbd, 0x67, 0x18, 0xce, 0xc0],
|
||||||
|
[0x75, 0x38, 0x44, 0x67, 0x3a, 0x27, 0x78, 0x2c, 0xc4, 0x20, 0x12, 0xe0],
|
||||||
|
[0x06, 0xff, 0x83, 0xa1, 0x45, 0xc3, 0x70, 0x35, 0xa5, 0xc1, 0x26, 0x80],
|
||||||
|
[0x3b, 0x37, 0x41, 0x78, 0x58, 0xcc, 0x2d, 0xd3, 0x3e, 0xc3, 0xf6, 0x20],
|
||||||
|
[0x9a, 0x4a, 0x5a, 0x28, 0xee, 0x17, 0xca, 0x9c, 0x32, 0x48, 0x42, 0xc0],
|
||||||
|
[0xbc, 0x29, 0xf4, 0x65, 0x30, 0x9c, 0x97, 0x7e, 0x89, 0x61, 0x0a, 0x40],
|
||||||
|
[0x26, 0x63, 0xae, 0x6d, 0xdf, 0x8b, 0x5c, 0xe2, 0xbb, 0x29, 0x48, 0x80],
|
||||||
|
[0x46, 0xf2, 0x31, 0xef, 0xe4, 0x57, 0x03, 0x4c, 0x18, 0x14, 0x41, 0x80],
|
||||||
|
[0x3f, 0xb2, 0xce, 0x85, 0xab, 0xe9, 0xb0, 0xc7, 0x2e, 0x06, 0xfb, 0xe0],
|
||||||
|
[0xde, 0x87, 0x48, 0x1f, 0x28, 0x2c, 0x15, 0x39, 0x71, 0xa0, 0xa2, 0xe0],
|
||||||
|
[0xfc, 0xd7, 0xcc, 0xf2, 0x3c, 0x69, 0xfa, 0x99, 0xbb, 0xa1, 0x41, 0x20],
|
||||||
|
[0xf0, 0x26, 0x14, 0x47, 0xe9, 0x49, 0x0c, 0xa8, 0xe4, 0x74, 0xce, 0xc0],
|
||||||
|
[0x44, 0x10, 0x11, 0x58, 0x18, 0x19, 0x6f, 0x95, 0xcd, 0xd7, 0x01, 0x20],
|
||||||
|
[0x08, 0x8f, 0xc3, 0x1d, 0xf4, 0xbf, 0xbd, 0xe2, 0xa4, 0xea, 0xfb, 0x40],
|
||||||
|
[0xb8, 0xfe, 0xf1, 0xb6, 0x30, 0x77, 0x29, 0xfb, 0x0a, 0x07, 0x8c, 0x00],
|
||||||
|
[0x5a, 0xfe, 0xa7, 0xac, 0xcc, 0xb7, 0x7b, 0xbc, 0x9d, 0x99, 0xa9, 0x00],
|
||||||
|
[0x49, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xf6, 0x5e, 0xcd, 0xc9, 0x07, 0x60],
|
||||||
|
[0x19, 0x44, 0xd0, 0x85, 0xbe, 0x4e, 0x7d, 0xa8, 0xd6, 0xcc, 0x7d, 0x00],
|
||||||
|
[0x25, 0x1f, 0x62, 0xad, 0xc4, 0x03, 0x2f, 0x0e, 0xe7, 0x14, 0x00, 0x20],
|
||||||
|
[0x56, 0x47, 0x1f, 0x87, 0x02, 0xa0, 0x72, 0x1e, 0x00, 0xb1, 0x2b, 0x80],
|
||||||
|
[0x2b, 0x8e, 0x49, 0x23, 0xf2, 0xdd, 0x51, 0xe2, 0xd5, 0x37, 0xfa, 0x00],
|
||||||
|
[0x6b, 0x55, 0x0a, 0x40, 0xa6, 0x6f, 0x47, 0x55, 0xde, 0x95, 0xc2, 0x60],
|
||||||
|
[0xa1, 0x8a, 0xd2, 0x8d, 0x4e, 0x27, 0xfe, 0x92, 0xa4, 0xf6, 0xc8, 0x40],
|
||||||
|
[0x10, 0xc2, 0xe5, 0x86, 0x38, 0x8c, 0xb8, 0x2a, 0x3d, 0x80, 0x75, 0x80],
|
||||||
|
[0xef, 0x34, 0xa4, 0x18, 0x17, 0xee, 0x02, 0x13, 0x3d, 0xb2, 0xeb, 0x00],
|
||||||
|
[0x7e, 0x9c, 0x0c, 0x54, 0x32, 0x5a, 0x9c, 0x15, 0x83, 0x6e, 0x00, 0x00],
|
||||||
|
[0x36, 0x93, 0xe5, 0x72, 0xd1, 0xfd, 0xe4, 0xcd, 0xf0, 0x79, 0xe8, 0x60],
|
||||||
|
[0xbf, 0xb2, 0xce, 0xc5, 0xab, 0xe1, 0xb0, 0xc7, 0x2e, 0x07, 0xfb, 0xe0],
|
||||||
|
[0x7e, 0xe1, 0x82, 0x30, 0xc5, 0x83, 0xcc, 0xcc, 0x57, 0xd4, 0xb0, 0x80],
|
||||||
|
[0xa0, 0x66, 0xcb, 0x2f, 0xed, 0xaf, 0xc9, 0xf5, 0x26, 0x64, 0x12, 0x60],
|
||||||
|
[0xbb, 0x23, 0x72, 0x5a, 0xbc, 0x47, 0xcc, 0x5f, 0x4c, 0xc4, 0xcd, 0x20],
|
||||||
|
[0xde, 0xd9, 0xdb, 0xa3, 0xbe, 0xe4, 0x0c, 0x59, 0xb5, 0x60, 0x9b, 0x40],
|
||||||
|
[0xd9, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xe6, 0xde, 0xcd, 0xc9, 0x03, 0x60],
|
||||||
|
[0x9a, 0xd4, 0x6a, 0xed, 0x5f, 0x70, 0x7f, 0x28, 0x0a, 0xb5, 0xfc, 0x40],
|
||||||
|
[0xe5, 0x92, 0x1c, 0x77, 0x82, 0x25, 0x87, 0x31, 0x6d, 0x7d, 0x3c, 0x20],
|
||||||
|
[0x4f, 0x14, 0xda, 0x82, 0x42, 0xa8, 0xb8, 0x6d, 0xca, 0x73, 0x35, 0x20],
|
||||||
|
[0x8b, 0x8b, 0x50, 0x7a, 0xd4, 0x67, 0xd4, 0x44, 0x1d, 0xf7, 0x70, 0xe0],
|
||||||
|
[0x22, 0x83, 0x1c, 0x9c, 0xf1, 0x16, 0x94, 0x67, 0xad, 0x04, 0xb6, 0x80],
|
||||||
|
[0x21, 0x3b, 0x83, 0x8f, 0xe2, 0xae, 0x54, 0xc3, 0x8e, 0xe7, 0x18, 0x00],
|
||||||
|
[0x5d, 0x92, 0x6b, 0x6d, 0xd7, 0x1f, 0x08, 0x51, 0x81, 0xa4, 0xe1, 0x20],
|
||||||
|
[0x66, 0xab, 0x79, 0xd4, 0xb2, 0x9e, 0xe6, 0xe6, 0x95, 0x09, 0xe5, 0x60],
|
||||||
|
[0x95, 0x81, 0x48, 0x68, 0x2d, 0x74, 0x8a, 0x38, 0xdd, 0x68, 0xba, 0xa0],
|
||||||
|
[0xb8, 0xce, 0x02, 0x0c, 0xf0, 0x69, 0xc3, 0x2a, 0x72, 0x3a, 0xb1, 0x40],
|
||||||
|
[0xf4, 0x33, 0x1d, 0x6d, 0x46, 0x16, 0x07, 0xe9, 0x57, 0x52, 0x74, 0x60],
|
||||||
|
[0x6d, 0xa2, 0x3b, 0xa4, 0x24, 0xb9, 0x59, 0x61, 0x33, 0xcf, 0x9c, 0x80],
|
||||||
|
[0xa6, 0x36, 0xbc, 0xbc, 0x7b, 0x30, 0xc5, 0xfb, 0xea, 0xe6, 0x7f, 0xe0],
|
||||||
|
[0x5c, 0xb0, 0xd8, 0x6a, 0x07, 0xdf, 0x65, 0x4a, 0x90, 0x89, 0xa2, 0x00],
|
||||||
|
[0xf1, 0x1f, 0x10, 0x68, 0x48, 0x78, 0x0f, 0xc9, 0xec, 0xdd, 0x80, 0xa0],
|
||||||
|
[0x1f, 0xbb, 0x53, 0x64, 0xfb, 0x8d, 0x2c, 0x9d, 0x73, 0x0d, 0x5b, 0xa0],
|
||||||
|
[0xfc, 0xb8, 0x6b, 0xc7, 0x0a, 0x50, 0xc9, 0xd0, 0x2a, 0x5d, 0x03, 0x40],
|
||||||
|
[0xa5, 0x34, 0x43, 0x30, 0x29, 0xea, 0xc1, 0x5f, 0x32, 0x2e, 0x34, 0xc0],
|
||||||
|
[0xc9, 0x89, 0xd9, 0xc7, 0xc3, 0xd3, 0xb8, 0xc5, 0x5d, 0x75, 0x13, 0x00],
|
||||||
|
[0x7b, 0xb3, 0x8b, 0x2f, 0x01, 0x86, 0xd4, 0x66, 0x43, 0xae, 0x96, 0x20],
|
||||||
|
[0x26, 0x44, 0xeb, 0xad, 0xeb, 0x44, 0xb9, 0x46, 0x7d, 0x1f, 0x42, 0xc0],
|
||||||
|
[0x60, 0x8c, 0xc8, 0x57, 0x59, 0x4b, 0xfb, 0xb5, 0x5d, 0x69, 0x60, 0x00],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// LDPC parity check matrix Nm: each row describes one parity check.
|
||||||
|
/// Numbers are 1-origin indices into the codeword.
|
||||||
|
pub const FTX_LDPC_NM: [[u8; 7]; FTX_LDPC_M] = [
|
||||||
|
[4, 31, 59, 91, 92, 96, 153],
|
||||||
|
[5, 32, 60, 93, 115, 146, 0],
|
||||||
|
[6, 24, 61, 94, 122, 151, 0],
|
||||||
|
[7, 33, 62, 95, 96, 143, 0],
|
||||||
|
[8, 25, 63, 83, 93, 96, 148],
|
||||||
|
[6, 32, 64, 97, 126, 138, 0],
|
||||||
|
[5, 34, 65, 78, 98, 107, 154],
|
||||||
|
[9, 35, 66, 99, 139, 146, 0],
|
||||||
|
[10, 36, 67, 100, 107, 126, 0],
|
||||||
|
[11, 37, 67, 87, 101, 139, 158],
|
||||||
|
[12, 38, 68, 102, 105, 155, 0],
|
||||||
|
[13, 39, 69, 103, 149, 162, 0],
|
||||||
|
[8, 40, 70, 82, 104, 114, 145],
|
||||||
|
[14, 41, 71, 88, 102, 123, 156],
|
||||||
|
[15, 42, 59, 106, 123, 159, 0],
|
||||||
|
[1, 33, 72, 106, 107, 157, 0],
|
||||||
|
[16, 43, 73, 108, 141, 160, 0],
|
||||||
|
[17, 37, 74, 81, 109, 131, 154],
|
||||||
|
[11, 44, 75, 110, 121, 166, 0],
|
||||||
|
[45, 55, 64, 111, 130, 161, 173],
|
||||||
|
[8, 46, 71, 112, 119, 166, 0],
|
||||||
|
[18, 36, 76, 89, 113, 114, 143],
|
||||||
|
[19, 38, 77, 104, 116, 163, 0],
|
||||||
|
[20, 47, 70, 92, 138, 165, 0],
|
||||||
|
[2, 48, 74, 113, 128, 160, 0],
|
||||||
|
[21, 45, 78, 83, 117, 121, 151],
|
||||||
|
[22, 47, 58, 118, 127, 164, 0],
|
||||||
|
[16, 39, 62, 112, 134, 158, 0],
|
||||||
|
[23, 43, 79, 120, 131, 145, 0],
|
||||||
|
[19, 35, 59, 73, 110, 125, 161],
|
||||||
|
[20, 36, 63, 94, 136, 161, 0],
|
||||||
|
[14, 31, 79, 98, 132, 164, 0],
|
||||||
|
[3, 44, 80, 124, 127, 169, 0],
|
||||||
|
[19, 46, 81, 117, 135, 167, 0],
|
||||||
|
[7, 49, 58, 90, 100, 105, 168],
|
||||||
|
[12, 50, 61, 118, 119, 144, 0],
|
||||||
|
[13, 51, 64, 114, 118, 157, 0],
|
||||||
|
[24, 52, 76, 129, 148, 149, 0],
|
||||||
|
[25, 53, 69, 90, 101, 130, 156],
|
||||||
|
[20, 46, 65, 80, 120, 140, 170],
|
||||||
|
[21, 54, 77, 100, 140, 171, 0],
|
||||||
|
[35, 82, 133, 142, 171, 174, 0],
|
||||||
|
[14, 30, 83, 113, 125, 170, 0],
|
||||||
|
[4, 29, 68, 120, 134, 173, 0],
|
||||||
|
[1, 4, 52, 57, 86, 136, 152],
|
||||||
|
[26, 51, 56, 91, 122, 137, 168],
|
||||||
|
[52, 84, 110, 115, 145, 168, 0],
|
||||||
|
[7, 50, 81, 99, 132, 173, 0],
|
||||||
|
[23, 55, 67, 95, 172, 174, 0],
|
||||||
|
[26, 41, 77, 109, 141, 148, 0],
|
||||||
|
[2, 27, 41, 61, 62, 115, 133],
|
||||||
|
[27, 40, 56, 124, 125, 126, 0],
|
||||||
|
[18, 49, 55, 124, 141, 167, 0],
|
||||||
|
[6, 33, 85, 108, 116, 156, 0],
|
||||||
|
[28, 48, 70, 85, 105, 129, 158],
|
||||||
|
[9, 54, 63, 131, 147, 155, 0],
|
||||||
|
[22, 53, 68, 109, 121, 174, 0],
|
||||||
|
[3, 13, 48, 78, 95, 123, 0],
|
||||||
|
[31, 69, 133, 150, 155, 169, 0],
|
||||||
|
[12, 43, 66, 89, 97, 135, 159],
|
||||||
|
[5, 39, 75, 102, 136, 167, 0],
|
||||||
|
[2, 54, 86, 101, 135, 164, 0],
|
||||||
|
[15, 56, 87, 108, 119, 171, 0],
|
||||||
|
[10, 44, 82, 91, 111, 144, 149],
|
||||||
|
[23, 34, 71, 94, 127, 153, 0],
|
||||||
|
[11, 49, 88, 92, 142, 157, 0],
|
||||||
|
[29, 34, 87, 97, 147, 162, 0],
|
||||||
|
[30, 50, 60, 86, 137, 142, 162],
|
||||||
|
[10, 53, 66, 84, 112, 128, 165],
|
||||||
|
[22, 57, 85, 93, 140, 159, 0],
|
||||||
|
[28, 32, 72, 103, 132, 166, 0],
|
||||||
|
[28, 29, 84, 88, 117, 143, 150],
|
||||||
|
[1, 26, 45, 80, 128, 147, 0],
|
||||||
|
[17, 27, 89, 103, 116, 153, 0],
|
||||||
|
[51, 57, 98, 163, 165, 172, 0],
|
||||||
|
[21, 37, 73, 138, 152, 169, 0],
|
||||||
|
[16, 47, 76, 130, 137, 154, 0],
|
||||||
|
[3, 24, 30, 72, 104, 139, 0],
|
||||||
|
[9, 40, 90, 106, 134, 151, 0],
|
||||||
|
[15, 58, 60, 74, 111, 150, 163],
|
||||||
|
[18, 42, 79, 144, 146, 152, 0],
|
||||||
|
[25, 38, 65, 99, 122, 160, 0],
|
||||||
|
[17, 42, 75, 129, 170, 172, 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Mn: each row corresponds to a codeword bit.
|
||||||
|
/// The numbers indicate which three parity checks refer to the codeword bit (1-origin).
|
||||||
|
pub const FTX_LDPC_MN: [[u8; 3]; FTX_LDPC_N] = [
|
||||||
|
[16, 45, 73], [25, 51, 62], [33, 58, 78], [1, 44, 45], [2, 7, 61],
|
||||||
|
[3, 6, 54], [4, 35, 48], [5, 13, 21], [8, 56, 79], [9, 64, 69],
|
||||||
|
[10, 19, 66], [11, 36, 60], [12, 37, 58], [14, 32, 43], [15, 63, 80],
|
||||||
|
[17, 28, 77], [18, 74, 83], [22, 53, 81], [23, 30, 34], [24, 31, 40],
|
||||||
|
[26, 41, 76], [27, 57, 70], [29, 49, 65], [3, 38, 78], [5, 39, 82],
|
||||||
|
[46, 50, 73], [51, 52, 74], [55, 71, 72], [44, 67, 72], [43, 68, 78],
|
||||||
|
[1, 32, 59], [2, 6, 71], [4, 16, 54], [7, 65, 67], [8, 30, 42],
|
||||||
|
[9, 22, 31], [10, 18, 76], [11, 23, 82], [12, 28, 61], [13, 52, 79],
|
||||||
|
[14, 50, 51], [15, 81, 83], [17, 29, 60], [19, 33, 64], [20, 26, 73],
|
||||||
|
[21, 34, 40], [24, 27, 77], [25, 55, 58], [35, 53, 66], [36, 48, 68],
|
||||||
|
[37, 46, 75], [38, 45, 47], [39, 57, 69], [41, 56, 62], [20, 49, 53],
|
||||||
|
[46, 52, 63], [45, 70, 75], [27, 35, 80], [1, 15, 30], [2, 68, 80],
|
||||||
|
[3, 36, 51], [4, 28, 51], [5, 31, 56], [6, 20, 37], [7, 40, 82],
|
||||||
|
[8, 60, 69], [9, 10, 49], [11, 44, 57], [12, 39, 59], [13, 24, 55],
|
||||||
|
[14, 21, 65], [16, 71, 78], [17, 30, 76], [18, 25, 80], [19, 61, 83],
|
||||||
|
[22, 38, 77], [23, 41, 50], [7, 26, 58], [29, 32, 81], [33, 40, 73],
|
||||||
|
[18, 34, 48], [13, 42, 64], [5, 26, 43], [47, 69, 72], [54, 55, 70],
|
||||||
|
[45, 62, 68], [10, 63, 67], [14, 66, 72], [22, 60, 74], [35, 39, 79],
|
||||||
|
[1, 46, 64], [1, 24, 66], [2, 5, 70], [3, 31, 65], [4, 49, 58],
|
||||||
|
[1, 4, 5], [6, 60, 67], [7, 32, 75], [8, 48, 82], [9, 35, 41],
|
||||||
|
[10, 39, 62], [11, 14, 61], [12, 71, 74], [13, 23, 78], [11, 35, 55],
|
||||||
|
[15, 16, 79], [7, 9, 16], [17, 54, 63], [18, 50, 57], [19, 30, 47],
|
||||||
|
[20, 64, 80], [21, 28, 69], [22, 25, 43], [13, 22, 37], [2, 47, 51],
|
||||||
|
[23, 54, 74], [26, 34, 72], [27, 36, 37], [21, 36, 63], [29, 40, 44],
|
||||||
|
[19, 26, 57], [3, 46, 82], [23, 54, 74], [33, 52, 53], [30, 43, 52],
|
||||||
|
[6, 9, 52], [27, 33, 65], [25, 69, 73], [38, 55, 83], [20, 39, 77],
|
||||||
|
[18, 29, 56], [32, 48, 71], [42, 51, 59], [28, 44, 79], [34, 60, 62],
|
||||||
|
[31, 45, 61], [46, 68, 77], [6, 24, 76], [8, 10, 78], [40, 41, 70],
|
||||||
|
[17, 50, 53], [42, 66, 68], [4, 22, 72], [36, 64, 81], [13, 29, 47],
|
||||||
|
[2, 8, 81], [56, 67, 73], [5, 38, 50], [12, 38, 64], [59, 72, 80],
|
||||||
|
[3, 26, 79], [45, 76, 81], [1, 65, 74], [7, 18, 77], [11, 56, 59],
|
||||||
|
[14, 39, 54], [16, 37, 66], [10, 28, 55], [15, 60, 70], [17, 25, 82],
|
||||||
|
[20, 30, 31], [12, 67, 68], [23, 75, 80], [27, 32, 62], [24, 69, 75],
|
||||||
|
[19, 21, 71], [34, 53, 61], [35, 46, 47], [33, 59, 76], [40, 43, 83],
|
||||||
|
[41, 42, 63], [49, 75, 83], [20, 44, 48], [42, 49, 57],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Number of entries per row in FTX_LDPC_NM.
|
||||||
|
pub const FTX_LDPC_NUM_ROWS: [u8; FTX_LDPC_M] = [
|
||||||
|
7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6,
|
||||||
|
6, 7, 6, 7, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6,
|
||||||
|
6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6,
|
||||||
|
6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7,
|
||||||
|
6, 6, 6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7,
|
||||||
|
6, 6, 6,
|
||||||
|
];
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use crate::protocol::{FT8_CRC_POLYNOMIAL, FT8_CRC_WIDTH};
|
||||||
|
|
||||||
|
const TOPBIT: u16 = 1 << (FT8_CRC_WIDTH - 1);
|
||||||
|
|
||||||
|
/// Compute 14-bit CRC for a sequence of given number of bits.
|
||||||
|
/// `message` is a byte sequence (MSB first), `num_bits` is the number of bits.
|
||||||
|
pub fn ftx_compute_crc(message: &[u8], num_bits: usize) -> u16 {
|
||||||
|
let mut remainder: u16 = 0;
|
||||||
|
let mut idx_byte: usize = 0;
|
||||||
|
|
||||||
|
for idx_bit in 0..num_bits {
|
||||||
|
if idx_bit % 8 == 0 {
|
||||||
|
remainder ^= (message[idx_byte] as u16) << (FT8_CRC_WIDTH - 8);
|
||||||
|
idx_byte += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainder & TOPBIT != 0 {
|
||||||
|
remainder = (remainder << 1) ^ FT8_CRC_POLYNOMIAL;
|
||||||
|
} else {
|
||||||
|
remainder <<= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remainder & ((TOPBIT << 1) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the FT8/FT4 CRC from a packed 91-bit message.
|
||||||
|
pub fn ftx_extract_crc(a91: &[u8]) -> u16 {
|
||||||
|
((a91[9] as u16 & 0x07) << 11) | ((a91[10] as u16) << 3) | ((a91[11] as u16) >> 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add FT8/FT4 CRC to a packed message.
|
||||||
|
/// `payload` contains 77 bits of payload data, `a91` receives 91 bits (payload + CRC).
|
||||||
|
pub fn ftx_add_crc(payload: &[u8], a91: &mut [u8]) {
|
||||||
|
// Copy 77 bits of payload data
|
||||||
|
for i in 0..10 {
|
||||||
|
a91[i] = payload[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 3 bits after the payload to make 82 bits
|
||||||
|
a91[9] &= 0xF8;
|
||||||
|
a91[10] = 0;
|
||||||
|
|
||||||
|
// Calculate CRC of 82 bits (77 + 5 zeros)
|
||||||
|
let checksum = ftx_compute_crc(a91, 96 - 14);
|
||||||
|
|
||||||
|
// Store the CRC at the end of 77 bit message
|
||||||
|
a91[9] |= (checksum >> 11) as u8;
|
||||||
|
a91[10] = (checksum >> 3) as u8;
|
||||||
|
a91[11] = (checksum << 5) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check CRC of a packed 91-bit message. Returns true if valid.
|
||||||
|
pub fn ftx_check_crc(a91: &[u8; 12]) -> bool {
|
||||||
|
let crc_extracted = ftx_extract_crc(a91);
|
||||||
|
let mut temp = *a91;
|
||||||
|
temp[9] &= 0xF8;
|
||||||
|
temp[10] = 0x00;
|
||||||
|
let crc_calculated = ftx_compute_crc(&temp, 96 - 14);
|
||||||
|
crc_extracted == crc_calculated
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc_round_trip() {
|
||||||
|
let payload: [u8; 10] = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x20];
|
||||||
|
let mut a91 = [0u8; 12];
|
||||||
|
ftx_add_crc(&payload, &mut a91);
|
||||||
|
let crc = ftx_extract_crc(&a91);
|
||||||
|
// Verify CRC matches what we computed
|
||||||
|
let mut check = a91;
|
||||||
|
check[9] &= 0xF8;
|
||||||
|
check[10] = 0x00;
|
||||||
|
assert_eq!(crc, ftx_compute_crc(&check, 96 - 14));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc_check() {
|
||||||
|
let payload: [u8; 10] = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||||
|
let mut a91 = [0u8; 12];
|
||||||
|
ftx_add_crc(&payload, &mut a91);
|
||||||
|
assert!(ftx_check_crc(&a91));
|
||||||
|
// Corrupt a bit
|
||||||
|
a91[0] ^= 0x01;
|
||||||
|
assert!(!ftx_check_crc(&a91));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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::{WfElem, Waterfall};
|
||||||
|
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 {
|
||||||
|
let amplitude = 10.0_f32.powf(elem.mag / 20.0);
|
||||||
|
Complex32::from_polar(amplitude, elem.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
&WfElem { mag: -120.0, phase: 0.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaked reference for out-of-bounds default
|
||||||
|
static DEFAULT_WF_ELEM: WfElem = WfElem { mag: -120.0, phase: 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 as i32 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 + 7) / 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits(&plain174, FTX_LDPC_K, &mut 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 mut message = FtxMessage {
|
||||||
|
hash: crc_calculated,
|
||||||
|
payload: [0; FTX_PAYLOAD_LENGTH_BYTES],
|
||||||
|
};
|
||||||
|
|
||||||
|
if wf.protocol.uses_ft4_layout() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! 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::*;
|
||||||
|
|
||||||
|
const DEFAULT_F_MIN_HZ: f32 = 200.0;
|
||||||
|
const DEFAULT_F_MAX_HZ: f32 = 3000.0;
|
||||||
|
const DEFAULT_TIME_OSR: i32 = 2;
|
||||||
|
const DEFAULT_FREQ_OSR: i32 = 2;
|
||||||
|
|
||||||
|
const FT2_F_MIN_HZ: f32 = 200.0;
|
||||||
|
const FT2_F_MAX_HZ: f32 = 5000.0;
|
||||||
|
const FT2_TIME_OSR: i32 = 8;
|
||||||
|
const FT2_FREQ_OSR: i32 = 4;
|
||||||
|
|
||||||
|
const MAX_LDPC_ITERATIONS: usize = 20;
|
||||||
|
const MIN_CANDIDATE_SCORE: i32 = 10;
|
||||||
|
const MAX_CANDIDATES: usize = 120;
|
||||||
|
|
||||||
|
/// Decoded result from the FT8/FT4/FT2 decoder.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Ft8DecodeResult {
|
||||||
|
pub text: String,
|
||||||
|
pub snr_db: f32,
|
||||||
|
pub dt_s: f32,
|
||||||
|
pub freq_hz: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FTx decoder instance supporting FT8, FT4, and FT2 protocols.
|
||||||
|
pub struct Ft8Decoder {
|
||||||
|
protocol: FtxProtocol,
|
||||||
|
sample_rate: u32,
|
||||||
|
block_size: usize,
|
||||||
|
window_samples: usize,
|
||||||
|
monitor: Monitor,
|
||||||
|
callsign_hash: CallsignHashTable,
|
||||||
|
// FT2-specific pipeline
|
||||||
|
ft2_pipeline: Option<crate::ft2::Ft2Pipeline>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ft8Decoder is not shared across threads, but may be moved between tasks.
|
||||||
|
unsafe impl Send for Ft8Decoder {}
|
||||||
|
|
||||||
|
impl Ft8Decoder {
|
||||||
|
/// Create a new FT8 decoder.
|
||||||
|
pub fn new(sample_rate: u32) -> Result<Self, String> {
|
||||||
|
Self::new_with_protocol(sample_rate, FtxProtocol::Ft8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new FT4 decoder.
|
||||||
|
pub fn new_ft4(sample_rate: u32) -> Result<Self, String> {
|
||||||
|
Self::new_with_protocol(sample_rate, FtxProtocol::Ft4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new FT2 decoder.
|
||||||
|
pub fn new_ft2(sample_rate: u32) -> Result<Self, String> {
|
||||||
|
Self::new_with_protocol(sample_rate, FtxProtocol::Ft2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_with_protocol(sample_rate: u32, protocol: FtxProtocol) -> Result<Self, String> {
|
||||||
|
let (f_min, f_max, time_osr, freq_osr) = match protocol {
|
||||||
|
FtxProtocol::Ft2 => (FT2_F_MIN_HZ, FT2_F_MAX_HZ, FT2_TIME_OSR, FT2_FREQ_OSR),
|
||||||
|
_ => (
|
||||||
|
DEFAULT_F_MIN_HZ,
|
||||||
|
DEFAULT_F_MAX_HZ,
|
||||||
|
DEFAULT_TIME_OSR,
|
||||||
|
DEFAULT_FREQ_OSR,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = MonitorConfig {
|
||||||
|
f_min,
|
||||||
|
f_max,
|
||||||
|
sample_rate: sample_rate as i32,
|
||||||
|
time_osr,
|
||||||
|
freq_osr,
|
||||||
|
protocol,
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor = Monitor::new(&cfg);
|
||||||
|
let block_size = monitor.block_size;
|
||||||
|
|
||||||
|
if block_size == 0 {
|
||||||
|
return Err(format!("invalid {:?} block size", protocol));
|
||||||
|
}
|
||||||
|
|
||||||
|
let window_samples = match protocol {
|
||||||
|
FtxProtocol::Ft2 => crate::ft2::FT2_NMAX,
|
||||||
|
_ => {
|
||||||
|
let slot_time = protocol.slot_time();
|
||||||
|
(sample_rate as f32 * slot_time) as usize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if window_samples == 0 {
|
||||||
|
return Err(format!("invalid {:?} analysis window", protocol));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ft2_pipeline = if protocol == FtxProtocol::Ft2 {
|
||||||
|
Some(crate::ft2::Ft2Pipeline::new(sample_rate as i32))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
protocol,
|
||||||
|
sample_rate,
|
||||||
|
block_size,
|
||||||
|
window_samples,
|
||||||
|
monitor,
|
||||||
|
callsign_hash: CallsignHashTable::new(),
|
||||||
|
ft2_pipeline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block size in samples for `process_block`.
|
||||||
|
pub fn block_size(&self) -> usize {
|
||||||
|
self.block_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sample rate this decoder was configured with.
|
||||||
|
pub fn sample_rate(&self) -> u32 {
|
||||||
|
self.sample_rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total analysis window in samples.
|
||||||
|
pub fn window_samples(&self) -> usize {
|
||||||
|
self.window_samples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the decoder state for a new decode cycle.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.monitor.reset();
|
||||||
|
if let Some(ref mut pipe) = self.ft2_pipeline {
|
||||||
|
pipe.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one block of audio samples to the decoder.
|
||||||
|
pub fn process_block(&mut self, block: &[f32]) {
|
||||||
|
if block.len() < self.block_size {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.protocol == FtxProtocol::Ft2 {
|
||||||
|
// FT2: accumulate raw audio and also feed the monitor
|
||||||
|
if let Some(ref mut pipe) = self.ft2_pipeline {
|
||||||
|
pipe.accumulate(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.monitor.process(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if enough data has been collected and run the decode.
|
||||||
|
/// Returns decoded messages, or empty if not ready yet.
|
||||||
|
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||||
|
if self.protocol == FtxProtocol::Ft2 {
|
||||||
|
return self.decode_ft2(max_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FT8/FT4: waterfall-based decode
|
||||||
|
if self.monitor.wf.num_blocks < self.monitor.wf.max_blocks {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.decode_waterfall(max_results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waterfall-based decode for FT8/FT4.
|
||||||
|
fn decode_waterfall(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||||
|
let candidates =
|
||||||
|
ftx_find_candidates(&self.monitor.wf, MAX_CANDIDATES, MIN_CANDIDATE_SCORE);
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut seen: Vec<u16> = Vec::new();
|
||||||
|
|
||||||
|
for cand in &candidates {
|
||||||
|
if results.len() >= max_results {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = match ftx_decode_candidate(&self.monitor.wf, cand, MAX_LDPC_ITERATIONS) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dedup by hash
|
||||||
|
if seen.contains(&msg.hash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.push(msg.hash);
|
||||||
|
|
||||||
|
// Unpack message text
|
||||||
|
let text = match self.unpack_message(&msg) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute SNR
|
||||||
|
let snr_db = ftx_post_decode_snr(&self.monitor.wf, cand, &msg);
|
||||||
|
|
||||||
|
// Compute time offset
|
||||||
|
let symbol_period = self.protocol.symbol_period();
|
||||||
|
let dt_s =
|
||||||
|
(cand.time_offset as f32 + cand.time_sub as f32 / self.monitor.wf.time_osr as f32)
|
||||||
|
* symbol_period
|
||||||
|
- 0.5;
|
||||||
|
|
||||||
|
// Compute frequency
|
||||||
|
let freq_hz = (self.monitor.min_bin as f32 + cand.freq_offset as f32
|
||||||
|
+ cand.freq_sub as f32 / self.monitor.wf.freq_osr as f32)
|
||||||
|
/ symbol_period;
|
||||||
|
|
||||||
|
results.push(Ft8DecodeResult {
|
||||||
|
text,
|
||||||
|
snr_db,
|
||||||
|
dt_s,
|
||||||
|
freq_hz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FT2-specific decode pipeline.
|
||||||
|
fn decode_ft2(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||||
|
let pipe = match self.ft2_pipeline.as_ref() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !pipe.is_ready() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ft2_results = pipe.decode(max_results);
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for r in ft2_results {
|
||||||
|
let text = match self.unpack_message(&r.message) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(Ft8DecodeResult {
|
||||||
|
text,
|
||||||
|
snr_db: r.snr_db,
|
||||||
|
dt_s: r.dt_s,
|
||||||
|
freq_hz: r.freq_hz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a decoded FtxMessage into a human-readable string.
|
||||||
|
fn unpack_message(&mut self, msg: &FtxMessage) -> Option<String> {
|
||||||
|
let m = message::FtxMessage {
|
||||||
|
payload: msg.payload,
|
||||||
|
hash: msg.hash as u32,
|
||||||
|
};
|
||||||
|
let (text, _offsets, _rc) =
|
||||||
|
message::ftx_message_decode(&m, &mut self.callsign_hash);
|
||||||
|
if text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft8_decoder_creates() {
|
||||||
|
let dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||||
|
assert_eq!(dec.block_size(), 1920); // 12000 * 0.160
|
||||||
|
assert_eq!(dec.sample_rate(), 12_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_decoder_creates() {
|
||||||
|
let dec = Ft8Decoder::new_ft4(12_000).expect("ft4 decoder");
|
||||||
|
assert_eq!(dec.block_size(), 576); // 12000 * 0.048
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft2_uses_distinct_block_size() {
|
||||||
|
let ft4 = Ft8Decoder::new_ft4(12_000).expect("ft4 decoder");
|
||||||
|
let ft2 = Ft8Decoder::new_ft2(12_000).expect("ft2 decoder");
|
||||||
|
|
||||||
|
assert!(ft2.block_size() < ft4.block_size());
|
||||||
|
assert_eq!(ft4.block_size(), 576);
|
||||||
|
assert_eq!(ft2.block_size(), 288);
|
||||||
|
assert_eq!(ft2.window_samples(), 45_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_reset() {
|
||||||
|
let mut dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||||
|
dec.reset();
|
||||||
|
// Should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_empty_returns_nothing() {
|
||||||
|
let mut dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||||
|
let results = dec.decode_if_ready(10);
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
fn parity8(x: u8) -> u8 {
|
||||||
|
let x = x ^ (x >> 4);
|
||||||
|
let x = x ^ (x >> 2);
|
||||||
|
let x = x ^ (x >> 1);
|
||||||
|
x % 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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 % 2 != 0 {
|
||||||
|
codeword[col_idx] |= col_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
col_mask >>= 1;
|
||||||
|
if col_mask == 0 {
|
||||||
|
col_mask = 0x80u8;
|
||||||
|
col_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Per-symbol FFT and multi-scale bit metrics extraction.
|
||||||
|
//!
|
||||||
|
//! Takes the downsampled complex signal, computes per-symbol FFTs to extract
|
||||||
|
//! complex tone amplitudes, and generates bit metrics at three scales:
|
||||||
|
//! 1-symbol, 2-symbol, and 4-symbol coherent integration.
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
|
use crate::constants::{FT4_COSTAS_PATTERN, FT4_GRAY_MAP};
|
||||||
|
|
||||||
|
use super::{FT2_FRAME_SYMBOLS, FT2_NSS};
|
||||||
|
|
||||||
|
/// Extract bit metrics from the downsampled signal region.
|
||||||
|
///
|
||||||
|
/// Returns a 2D array of shape `[2 * FT2_FRAME_SYMBOLS][3]` where:
|
||||||
|
/// - Index 0: 1-symbol scale metric
|
||||||
|
/// - Index 1: 2-symbol scale metric
|
||||||
|
/// - Index 2: 4-symbol scale metric
|
||||||
|
///
|
||||||
|
/// Returns `None` if the sync quality is too poor (fewer than 4 of 16
|
||||||
|
/// Costas sync tones decoded correctly).
|
||||||
|
pub fn extract_bitmetrics_raw(
|
||||||
|
signal: &[Complex32],
|
||||||
|
) -> Option<Vec<[f32; 3]>> {
|
||||||
|
let n_metrics = 2 * FT2_FRAME_SYMBOLS;
|
||||||
|
let mut bitmetrics = vec![[0.0f32; 3]; n_metrics];
|
||||||
|
|
||||||
|
// Per-symbol FFT to extract complex tone amplitudes
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(FT2_NSS);
|
||||||
|
let fft_scratch_len = fft.get_inplace_scratch_len();
|
||||||
|
let mut scratch = vec![Complex32::new(0.0, 0.0); fft_scratch_len];
|
||||||
|
|
||||||
|
// Complex symbols for each of the 4 tones at each frame symbol
|
||||||
|
let mut symbols = vec![[Complex32::new(0.0, 0.0); 4]; FT2_FRAME_SYMBOLS];
|
||||||
|
// Magnitude for each tone at each symbol
|
||||||
|
let mut s4 = vec![[0.0f32; 4]; FT2_FRAME_SYMBOLS];
|
||||||
|
|
||||||
|
for sym in 0..FT2_FRAME_SYMBOLS {
|
||||||
|
let offset = sym * FT2_NSS;
|
||||||
|
let mut csymb: Vec<Complex32> = (0..FT2_NSS)
|
||||||
|
.map(|i| {
|
||||||
|
if offset + i < signal.len() {
|
||||||
|
signal[offset + i]
|
||||||
|
} else {
|
||||||
|
Complex32::new(0.0, 0.0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
fft.process_with_scratch(&mut csymb, &mut scratch);
|
||||||
|
|
||||||
|
for tone in 0..4 {
|
||||||
|
if tone < csymb.len() {
|
||||||
|
symbols[sym][tone] = csymb[tone];
|
||||||
|
s4[sym][tone] = csymb[tone].norm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync quality check: verify Costas patterns are detectable
|
||||||
|
let mut sync_ok = 0;
|
||||||
|
for group in 0..4 {
|
||||||
|
let base = group * 33;
|
||||||
|
for i in 0..4 {
|
||||||
|
if base + i >= FT2_FRAME_SYMBOLS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut best = 0;
|
||||||
|
for tone in 1..4 {
|
||||||
|
if s4[base + i][tone] > s4[base + i][best] {
|
||||||
|
best = tone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == FT4_COSTAS_PATTERN[group][i] as usize {
|
||||||
|
sync_ok += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sync_ok < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute one_mask: for each integer 0..255 and bit position 0..7,
|
||||||
|
// whether that bit is set.
|
||||||
|
let one_mask: Vec<[u8; 8]> = (0..256u16)
|
||||||
|
.map(|i| {
|
||||||
|
let mut m = [0u8; 8];
|
||||||
|
for j in 0..8 {
|
||||||
|
m[j] = if (i & (1 << j)) != 0 { 1 } else { 0 };
|
||||||
|
}
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Compute metrics at three scales
|
||||||
|
let mut metric1 = vec![0.0f32; n_metrics];
|
||||||
|
let mut metric2 = vec![0.0f32; n_metrics];
|
||||||
|
let mut metric4 = vec![0.0f32; n_metrics];
|
||||||
|
|
||||||
|
for nseq in 0..3 {
|
||||||
|
let nsym = match nseq {
|
||||||
|
0 => 1,
|
||||||
|
1 => 2,
|
||||||
|
_ => 4,
|
||||||
|
};
|
||||||
|
let nt = 1 << (2 * nsym); // number of tone sequences to enumerate
|
||||||
|
|
||||||
|
let mut ks = 0;
|
||||||
|
while ks + nsym <= FT2_FRAME_SYMBOLS {
|
||||||
|
// Compute coherent magnitude for each possible tone sequence
|
||||||
|
let mut s2 = vec![0.0f32; nt];
|
||||||
|
for i in 0..nt {
|
||||||
|
let i1 = i / 64;
|
||||||
|
let i2 = (i & 63) / 16;
|
||||||
|
let i3 = (i & 15) / 4;
|
||||||
|
let i4 = i & 3;
|
||||||
|
|
||||||
|
let sum = match nsym {
|
||||||
|
1 => symbols[ks][FT4_GRAY_MAP[i4] as usize],
|
||||||
|
2 => {
|
||||||
|
symbols[ks][FT4_GRAY_MAP[i3] as usize]
|
||||||
|
+ symbols[ks + 1][FT4_GRAY_MAP[i4] as usize]
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
symbols[ks][FT4_GRAY_MAP[i1] as usize]
|
||||||
|
+ symbols[ks + 1][FT4_GRAY_MAP[i2] as usize]
|
||||||
|
+ symbols[ks + 2][FT4_GRAY_MAP[i3] as usize]
|
||||||
|
+ symbols[ks + 3][FT4_GRAY_MAP[i4] as usize]
|
||||||
|
}
|
||||||
|
_ => Complex32::new(0.0, 0.0),
|
||||||
|
};
|
||||||
|
s2[i] = sum.norm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract bit metrics: for each bit position, find max coherent
|
||||||
|
// magnitude with that bit set vs unset
|
||||||
|
let ipt = 2 * ks;
|
||||||
|
let ibmax: usize = match nsym {
|
||||||
|
1 => 1,
|
||||||
|
2 => 3,
|
||||||
|
4 => 7,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for ib in 0..=ibmax {
|
||||||
|
let mut max_one = f32::NEG_INFINITY;
|
||||||
|
let mut max_zero = f32::NEG_INFINITY;
|
||||||
|
|
||||||
|
for i in 0..nt {
|
||||||
|
if i < 256 {
|
||||||
|
if one_mask[i][ibmax - ib] != 0 {
|
||||||
|
if s2[i] > max_one {
|
||||||
|
max_one = s2[i];
|
||||||
|
}
|
||||||
|
} else if s2[i] > max_zero {
|
||||||
|
max_zero = s2[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let metric_idx = ipt + ib;
|
||||||
|
if metric_idx >= n_metrics {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match nseq {
|
||||||
|
0 => metric1[metric_idx] = max_one - max_zero,
|
||||||
|
1 => metric2[metric_idx] = max_one - max_zero,
|
||||||
|
_ => metric4[metric_idx] = max_one - max_zero,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ks += nsym;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch boundary metrics where multi-symbol integration overruns
|
||||||
|
if n_metrics >= 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize each metric scale independently
|
||||||
|
normalize_metric(&mut metric1);
|
||||||
|
normalize_metric(&mut metric2);
|
||||||
|
normalize_metric(&mut metric4);
|
||||||
|
|
||||||
|
// Pack into output
|
||||||
|
for i in 0..n_metrics {
|
||||||
|
bitmetrics[i][0] = metric1[i];
|
||||||
|
bitmetrics[i][1] = metric2[i];
|
||||||
|
bitmetrics[i][2] = metric4[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(bitmetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a metric array by dividing by its standard deviation.
|
||||||
|
fn normalize_metric(metric: &mut [f32]) {
|
||||||
|
let count = metric.len();
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = 0.0f32;
|
||||||
|
let mut sum2 = 0.0f32;
|
||||||
|
for &v in metric.iter() {
|
||||||
|
sum += v;
|
||||||
|
sum2 += v * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mean = sum / count as f32;
|
||||||
|
let variance = (sum2 / count as f32) - (mean * mean);
|
||||||
|
let sigma = if variance > 0.0 {
|
||||||
|
variance.sqrt()
|
||||||
|
} else {
|
||||||
|
(sum2 / count as f32).max(0.0).sqrt()
|
||||||
|
};
|
||||||
|
|
||||||
|
if sigma <= 1e-6 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for v in metric.iter_mut() {
|
||||||
|
*v /= sigma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_metric_zeros() {
|
||||||
|
let mut m = vec![0.0f32; 100];
|
||||||
|
normalize_metric(&mut m);
|
||||||
|
for &v in &m {
|
||||||
|
assert_eq!(v, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_metric_uniform() {
|
||||||
|
let mut m = vec![1.0f32; 100];
|
||||||
|
normalize_metric(&mut m);
|
||||||
|
// All values are the same so variance is zero, sigma will be computed
|
||||||
|
// from sum2/n which is 1.0, so sigma=1.0 and values remain 1.0
|
||||||
|
for &v in &m {
|
||||||
|
assert!((v - 1.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_metric_nonzero() {
|
||||||
|
let mut m: Vec<f32> = (0..100).map(|i| (i as f32 - 50.0) * 0.1).collect();
|
||||||
|
let orig_variance: f32 = {
|
||||||
|
let mean: f32 = m.iter().sum::<f32>() / m.len() as f32;
|
||||||
|
m.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / m.len() as f32
|
||||||
|
};
|
||||||
|
normalize_metric(&mut m);
|
||||||
|
// After normalization, standard deviation should be ~1.0
|
||||||
|
let mean: f32 = m.iter().sum::<f32>() / m.len() as f32;
|
||||||
|
let variance: f32 =
|
||||||
|
m.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / m.len() as f32;
|
||||||
|
assert!(
|
||||||
|
(variance - 1.0).abs() < 0.1,
|
||||||
|
"Normalized variance should be ~1.0, got {}",
|
||||||
|
variance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_bitmetrics_silent_signal() {
|
||||||
|
let signal = vec![Complex32::new(0.0, 0.0); FT2_FRAME_SYMBOLS * FT2_NSS];
|
||||||
|
// Silent signal: all tones have zero magnitude, so the "best tone"
|
||||||
|
// defaults to tone 0 for every symbol. When tone 0 happens to match
|
||||||
|
// the Costas pattern (which it does for some groups), sync_ok may
|
||||||
|
// reach >= 4. So a silent signal can still pass the sync quality
|
||||||
|
// gate — the important thing is it does not panic.
|
||||||
|
let _result = extract_bitmetrics_raw(&signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_symbols_constant() {
|
||||||
|
// FT2_NN=105, FT2_NR=2 => FT2_FRAME_SYMBOLS=103
|
||||||
|
assert_eq!(FT2_FRAME_SYMBOLS, 103);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nss_constant() {
|
||||||
|
// FT2_NSTEP=288, FT2_NDOWN=9 => FT2_NSS=32
|
||||||
|
assert_eq!(FT2_NSS, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Frequency-domain downsampling via IFFT.
|
||||||
|
//!
|
||||||
|
//! Given the full-rate raw audio, this module computes a single forward FFT of
|
||||||
|
//! the entire buffer, then for each candidate frequency extracts a narrow band
|
||||||
|
//! around that frequency, applies a spectral window, and inverse-FFTs to produce
|
||||||
|
//! a complex baseband signal at a reduced sample rate (12000/NDOWN = 1333.3 Hz).
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
|
use super::{FT2_NDOWN, FT2_SYMBOL_PERIOD_F};
|
||||||
|
|
||||||
|
/// Downsample context holding precomputed FFT data and spectral window.
|
||||||
|
pub struct DownsampleContext {
|
||||||
|
/// Number of raw samples.
|
||||||
|
nraw: usize,
|
||||||
|
/// Length of the downsampled FFT (nraw / NDOWN).
|
||||||
|
nfft2: usize,
|
||||||
|
/// Frequency resolution of the raw FFT (Hz per bin).
|
||||||
|
df: f32,
|
||||||
|
/// Spectral extraction window (length nfft2).
|
||||||
|
window: Vec<f32>,
|
||||||
|
/// Full spectrum of the raw audio (nraw/2 + 1 complex bins).
|
||||||
|
spectrum: Vec<Complex32>,
|
||||||
|
/// IFFT plan for the downsampled length.
|
||||||
|
ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||||
|
/// Scratch buffer for IFFT.
|
||||||
|
ifft_scratch: Vec<Complex32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownsampleContext {
|
||||||
|
/// Initialize the downsample context by computing the forward FFT of
|
||||||
|
/// the raw audio and preparing the spectral window.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the raw audio is too short or allocation fails.
|
||||||
|
pub fn new(raw_audio: &[f32], sample_rate: f32) -> Option<Self> {
|
||||||
|
let nraw = raw_audio.len();
|
||||||
|
if nraw == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nfft2 = nraw / FT2_NDOWN;
|
||||||
|
if nfft2 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let df = sample_rate / nraw as f32;
|
||||||
|
|
||||||
|
// Build spectral extraction window
|
||||||
|
let window = build_spectral_window(nfft2, df);
|
||||||
|
|
||||||
|
// Forward real FFT of raw audio
|
||||||
|
let mut real_planner = realfft::RealFftPlanner::<f32>::new();
|
||||||
|
let fft = real_planner.plan_fft_forward(nraw);
|
||||||
|
let mut input = fft.make_input_vec();
|
||||||
|
let mut output = fft.make_output_vec();
|
||||||
|
let mut scratch = fft.make_scratch_vec();
|
||||||
|
|
||||||
|
for (i, s) in raw_audio.iter().enumerate() {
|
||||||
|
if i < input.len() {
|
||||||
|
input[i] = *s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fft.process_with_scratch(&mut input, &mut output, &mut scratch)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let spectrum = output;
|
||||||
|
|
||||||
|
// IFFT plan for downsampled length
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let ifft = planner.plan_fft_inverse(nfft2);
|
||||||
|
let ifft_scratch = vec![Complex32::new(0.0, 0.0); ifft.get_inplace_scratch_len()];
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
nraw,
|
||||||
|
nfft2,
|
||||||
|
df,
|
||||||
|
window,
|
||||||
|
spectrum,
|
||||||
|
ifft,
|
||||||
|
ifft_scratch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of downsampled output samples.
|
||||||
|
pub fn nfft2(&self) -> usize {
|
||||||
|
self.nfft2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsample the raw audio around `freq_hz`, writing complex baseband
|
||||||
|
/// samples into `out`. Returns the number of samples produced.
|
||||||
|
pub fn downsample(&self, freq_hz: f32, out: &mut [Complex32]) -> usize {
|
||||||
|
if out.len() < self.nfft2 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working band buffer
|
||||||
|
let mut band = vec![Complex32::new(0.0, 0.0); self.nfft2];
|
||||||
|
let i0 = (freq_hz / self.df).round() as i32;
|
||||||
|
let half_nraw = (self.nraw / 2) as i32;
|
||||||
|
|
||||||
|
// DC bin
|
||||||
|
if i0 >= 0 && i0 <= half_nraw && (i0 as usize) < self.spectrum.len() {
|
||||||
|
band[0] = self.spectrum[i0 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive and negative frequency bins
|
||||||
|
for i in 1..=(self.nfft2 as i32 / 2) {
|
||||||
|
let pos = i0 + i;
|
||||||
|
if pos >= 0 && pos <= half_nraw && (pos as usize) < self.spectrum.len() {
|
||||||
|
band[i as usize] = self.spectrum[pos as usize];
|
||||||
|
}
|
||||||
|
let neg = i0 - i;
|
||||||
|
if neg >= 0 && neg <= half_nraw && (neg as usize) < self.spectrum.len() {
|
||||||
|
band[(self.nfft2 as i32 - i) as usize] = self.spectrum[neg as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply spectral window and scale
|
||||||
|
let inv_nfft2 = 1.0 / self.nfft2 as f32;
|
||||||
|
for i in 0..self.nfft2 {
|
||||||
|
band[i] = Complex32::new(
|
||||||
|
band[i].re * self.window[i] * inv_nfft2,
|
||||||
|
band[i].im * self.window[i] * inv_nfft2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse FFT (in-place)
|
||||||
|
let mut scratch = self.ifft_scratch.clone();
|
||||||
|
self.ifft
|
||||||
|
.process_with_scratch(&mut band, &mut scratch);
|
||||||
|
|
||||||
|
out[..self.nfft2].copy_from_slice(&band);
|
||||||
|
self.nfft2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the spectral window used during band extraction.
|
||||||
|
///
|
||||||
|
/// The window has a raised-cosine transition, a flat passband covering
|
||||||
|
/// the FT2 signal bandwidth (4 * baud), and is circularly shifted by
|
||||||
|
/// one baud rate worth of bins.
|
||||||
|
fn build_spectral_window(nfft2: usize, df: f32) -> Vec<f32> {
|
||||||
|
let baud = 1.0 / FT2_SYMBOL_PERIOD_F;
|
||||||
|
let iwt = ((0.5 * baud) / df) as usize;
|
||||||
|
let iwf = ((4.0 * baud) / df) as usize;
|
||||||
|
let iws = (baud / df) as usize;
|
||||||
|
|
||||||
|
let mut window = vec![0.0f32; nfft2];
|
||||||
|
|
||||||
|
if iwt == 0 {
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raised-cosine leading edge
|
||||||
|
for i in 0..iwt.min(nfft2) {
|
||||||
|
window[i] = 0.5 * (1.0 + (std::f32::consts::PI * (iwt - 1 - i) as f32 / iwt as f32).cos());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat passband
|
||||||
|
for i in iwt..(iwt + iwf).min(nfft2) {
|
||||||
|
window[i] = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raised-cosine trailing edge
|
||||||
|
for i in (iwt + iwf)..(2 * iwt + iwf).min(nfft2) {
|
||||||
|
window[i] = 0.5 * (1.0 + (std::f32::consts::PI * (i - (iwt + iwf)) as f32 / iwt as f32).cos());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular shift by iws bins
|
||||||
|
if iws > 0 && iws < nfft2 {
|
||||||
|
let shifted: Vec<f32> = (0..nfft2).map(|i| window[(i + iws) % nfft2]).collect();
|
||||||
|
window.copy_from_slice(&shifted);
|
||||||
|
}
|
||||||
|
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectral_window_length() {
|
||||||
|
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
||||||
|
assert_eq!(w.len(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spectral_window_nonnegative() {
|
||||||
|
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
||||||
|
for &v in &w {
|
||||||
|
assert!(v >= 0.0, "Window value should be non-negative: {}", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_context_creation() {
|
||||||
|
let raw = vec![0.0f32; 45000];
|
||||||
|
let ctx = DownsampleContext::new(&raw, 12000.0);
|
||||||
|
assert!(ctx.is_some());
|
||||||
|
let ctx = ctx.unwrap();
|
||||||
|
assert_eq!(ctx.nfft2(), 45000 / 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_produces_samples() {
|
||||||
|
let raw = vec![0.0f32; 45000];
|
||||||
|
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
||||||
|
let nfft2 = ctx.nfft2();
|
||||||
|
let mut out = vec![Complex32::new(0.0, 0.0); nfft2];
|
||||||
|
let n = ctx.downsample(1000.0, &mut out);
|
||||||
|
assert_eq!(n, nfft2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_output_too_small() {
|
||||||
|
let raw = vec![0.0f32; 45000];
|
||||||
|
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
||||||
|
let mut out = vec![Complex32::new(0.0, 0.0); 10];
|
||||||
|
let n = ctx.downsample(1000.0, &mut out);
|
||||||
|
assert_eq!(n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_audio_returns_none() {
|
||||||
|
let raw: Vec<f32> = Vec::new();
|
||||||
|
assert!(DownsampleContext::new(&raw, 12000.0).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! FT2 pipeline orchestration.
|
||||||
|
//!
|
||||||
|
//! Implements the full FT2 decode flow: accumulate raw audio, find frequency
|
||||||
|
//! peaks in the averaged spectrum, downsample each candidate, compute 2D sync
|
||||||
|
//! scores, extract bit metrics, and run multi-pass LDPC + OSD decode.
|
||||||
|
|
||||||
|
pub mod bitmetrics;
|
||||||
|
pub mod downsample;
|
||||||
|
pub mod osd;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
|
||||||
|
use crate::constants::FT4_XOR_SEQUENCE;
|
||||||
|
use crate::crc::{ftx_compute_crc, ftx_extract_crc};
|
||||||
|
use crate::decode::{pack_bits, FtxMessage};
|
||||||
|
use crate::ldpc;
|
||||||
|
use crate::protocol::*;
|
||||||
|
|
||||||
|
use downsample::DownsampleContext;
|
||||||
|
use sync::{prepare_sync_waveforms, sync2d_score};
|
||||||
|
|
||||||
|
// FT2 DSP constants
|
||||||
|
pub const FT2_NDOWN: usize = 9;
|
||||||
|
pub const FT2_NFFT1: usize = 1152;
|
||||||
|
pub const FT2_NH1: usize = FT2_NFFT1 / 2;
|
||||||
|
pub const FT2_NSTEP: usize = 288;
|
||||||
|
pub const FT2_NMAX: usize = 45000;
|
||||||
|
pub const FT2_MAX_RAW_CANDIDATES: usize = 96;
|
||||||
|
pub const FT2_MAX_SCAN_HITS: usize = 128;
|
||||||
|
pub const FT2_SYNC_TWEAK_MIN: i32 = -16;
|
||||||
|
pub const FT2_SYNC_TWEAK_MAX: i32 = 16;
|
||||||
|
pub const FT2_NSS: usize = FT2_NSTEP / FT2_NDOWN;
|
||||||
|
pub const FT2_FRAME_SYMBOLS: usize = FT2_NN - FT2_NR;
|
||||||
|
pub const FT2_FRAME_SAMPLES: usize = FT2_FRAME_SYMBOLS * FT2_NSS;
|
||||||
|
pub const FT2_SYMBOL_PERIOD_F: f32 = FT2_SYMBOL_PERIOD;
|
||||||
|
|
||||||
|
/// Maximum hard-error count for accepting an OSD result.
|
||||||
|
const FT2_OSD_MAX_HARD_ERRORS: usize = 36;
|
||||||
|
|
||||||
|
/// Frequency offset applied to FT2 candidates.
|
||||||
|
pub fn ft2_frequency_offset_hz() -> f32 {
|
||||||
|
-1.5 / FT2_SYMBOL_PERIOD_F
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw frequency peak candidate from the averaged power spectrum.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct RawCandidate {
|
||||||
|
pub freq_hz: f32,
|
||||||
|
pub score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan hit with refined sync parameters.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct ScanHit {
|
||||||
|
pub freq_hz: f32,
|
||||||
|
pub snr0: f32,
|
||||||
|
pub sync_score: f32,
|
||||||
|
pub start: i32,
|
||||||
|
pub idf: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics from the scan phase.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ScanStats {
|
||||||
|
pub peaks_found: usize,
|
||||||
|
pub hits_found: usize,
|
||||||
|
pub best_peak_score: f32,
|
||||||
|
pub best_sync_score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure stage classification for diagnostics.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FailStage {
|
||||||
|
None,
|
||||||
|
RefinedSync,
|
||||||
|
FreqRange,
|
||||||
|
FinalDownsample,
|
||||||
|
BitMetrics,
|
||||||
|
SyncQual,
|
||||||
|
Ldpc,
|
||||||
|
Crc,
|
||||||
|
Unpack,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-pass diagnostic information.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PassDiag {
|
||||||
|
pub ntype: [i32; 5],
|
||||||
|
pub nharderror: [i32; 5],
|
||||||
|
pub dmin: [f32; 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PassDiag {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ntype: [0; 5],
|
||||||
|
nharderror: [-1; 5],
|
||||||
|
dmin: [f32::INFINITY; 5],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded FT2 result with timing and frequency metadata.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Ft2DecodeResult {
|
||||||
|
pub message: FtxMessage,
|
||||||
|
pub dt_s: f32,
|
||||||
|
pub freq_hz: f32,
|
||||||
|
pub snr_db: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FT2 pipeline state. Accumulates raw audio and runs the full decode flow.
|
||||||
|
pub struct Ft2Pipeline {
|
||||||
|
sample_rate: f32,
|
||||||
|
raw_audio: Vec<f32>,
|
||||||
|
raw_capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ft2Pipeline {
|
||||||
|
/// Create a new FT2 pipeline for the given sample rate.
|
||||||
|
pub fn new(sample_rate: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: sample_rate as f32,
|
||||||
|
raw_audio: Vec::with_capacity(FT2_NMAX),
|
||||||
|
raw_capacity: FT2_NMAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the pipeline, clearing all accumulated audio.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.raw_audio.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate raw audio samples. Returns true when the buffer is full.
|
||||||
|
pub fn accumulate(&mut self, samples: &[f32]) -> bool {
|
||||||
|
let remaining = self.raw_capacity.saturating_sub(self.raw_audio.len());
|
||||||
|
if remaining > 0 {
|
||||||
|
let n = remaining.min(samples.len());
|
||||||
|
self.raw_audio.extend_from_slice(&samples[..n]);
|
||||||
|
}
|
||||||
|
self.raw_audio.len() >= self.raw_capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when enough audio has been accumulated for decoding.
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.raw_audio.len() >= self.raw_capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of raw audio samples accumulated so far.
|
||||||
|
pub fn raw_len(&self) -> usize {
|
||||||
|
self.raw_audio.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full FT2 decode pipeline. Returns decoded messages.
|
||||||
|
pub fn decode(&self, max_results: usize) -> Vec<Ft2DecodeResult> {
|
||||||
|
if self.raw_audio.len() < FT2_NFFT1 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = match DownsampleContext::new(&self.raw_audio, self.sample_rate) {
|
||||||
|
Some(ctx) => ctx,
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let hits = self.find_scan_hits(&ctx);
|
||||||
|
if hits.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut seen_hashes: Vec<(u16, [u8; FTX_PAYLOAD_LENGTH_BYTES])> = Vec::new();
|
||||||
|
|
||||||
|
for hit in &hits {
|
||||||
|
if results.len() >= max_results {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(result) = self.decode_hit(&ctx, hit) {
|
||||||
|
// Dedup
|
||||||
|
let dominated = seen_hashes.iter().any(|(h, p)| {
|
||||||
|
*h == result.message.hash && *p == result.message.payload
|
||||||
|
});
|
||||||
|
if dominated {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_hashes.push((result.message.hash, result.message.payload));
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find frequency peaks from averaged power spectrum.
|
||||||
|
fn find_frequency_peaks(&self) -> Vec<RawCandidate> {
|
||||||
|
if self.raw_audio.len() < FT2_NFFT1 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fs = self.sample_rate;
|
||||||
|
let df = fs / FT2_NFFT1 as f32;
|
||||||
|
let n_frames = 1 + (self.raw_audio.len() - FT2_NFFT1) / FT2_NSTEP;
|
||||||
|
|
||||||
|
// Compute Nuttall window
|
||||||
|
let window = nuttall_window(FT2_NFFT1);
|
||||||
|
|
||||||
|
// Forward real FFT setup
|
||||||
|
let mut real_planner = realfft::RealFftPlanner::<f32>::new();
|
||||||
|
let fft = real_planner.plan_fft_forward(FT2_NFFT1);
|
||||||
|
let mut fft_input = fft.make_input_vec();
|
||||||
|
let mut fft_output = fft.make_output_vec();
|
||||||
|
let mut fft_scratch = fft.make_scratch_vec();
|
||||||
|
|
||||||
|
// Average power spectrum across frames
|
||||||
|
let mut avg = vec![0.0f32; FT2_NH1];
|
||||||
|
|
||||||
|
for frame in 0..n_frames {
|
||||||
|
let start = frame * FT2_NSTEP;
|
||||||
|
for i in 0..FT2_NFFT1 {
|
||||||
|
fft_input[i] = self.raw_audio[start + i] * window[i];
|
||||||
|
}
|
||||||
|
fft.process_with_scratch(&mut fft_input, &mut fft_output, &mut fft_scratch)
|
||||||
|
.expect("FFT failed");
|
||||||
|
|
||||||
|
for bin in 1..FT2_NH1 {
|
||||||
|
if bin < fft_output.len() {
|
||||||
|
let c = fft_output[bin];
|
||||||
|
let power = c.re * c.re + c.im * c.im;
|
||||||
|
avg[bin] += power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in 1..FT2_NH1 {
|
||||||
|
avg[bin] /= n_frames as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth with 15-point moving average
|
||||||
|
let mut smooth = vec![0.0f32; FT2_NH1];
|
||||||
|
for bin in 8..FT2_NH1.saturating_sub(8) {
|
||||||
|
let mut sum = 0.0f32;
|
||||||
|
for i in (bin.saturating_sub(7))..=(bin + 7).min(FT2_NH1 - 1) {
|
||||||
|
sum += avg[i];
|
||||||
|
}
|
||||||
|
smooth[bin] = sum / 15.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline with 63-point moving average
|
||||||
|
let mut baseline = vec![0.0f32; FT2_NH1];
|
||||||
|
for bin in 32..FT2_NH1.saturating_sub(32) {
|
||||||
|
let mut sum = 0.0f32;
|
||||||
|
for i in (bin.saturating_sub(31))..=(bin + 31).min(FT2_NH1 - 1) {
|
||||||
|
sum += smooth[i];
|
||||||
|
}
|
||||||
|
baseline[bin] = sum / 63.0 + 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find peaks
|
||||||
|
let min_bin = (200.0 / df).round() as usize;
|
||||||
|
let max_bin = (4910.0 / df).round() as usize;
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
let mut bin = min_bin + 1;
|
||||||
|
while bin < max_bin.saturating_sub(1) && candidates.len() < FT2_MAX_RAW_CANDIDATES {
|
||||||
|
if baseline[bin] <= 0.0 {
|
||||||
|
bin += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = smooth[bin] / baseline[bin];
|
||||||
|
if value < 1.03 {
|
||||||
|
bin += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = smooth[bin.saturating_sub(1)] / baseline[bin.saturating_sub(1)].max(1e-9);
|
||||||
|
let right = if bin + 1 < FT2_NH1 {
|
||||||
|
smooth[bin + 1] / baseline[bin + 1].max(1e-9)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if value < left || value < right {
|
||||||
|
bin += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let den = left - 2.0 * value + right;
|
||||||
|
let delta = if den.abs() > 1e-6 {
|
||||||
|
0.5 * (left - right) / den
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let freq_hz = (bin as f32 + delta) * df + ft2_frequency_offset_hz();
|
||||||
|
if freq_hz < 200.0 || freq_hz > 4910.0 {
|
||||||
|
bin += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(RawCandidate {
|
||||||
|
freq_hz,
|
||||||
|
score: value,
|
||||||
|
});
|
||||||
|
bin += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find scan hits by downsampling each frequency peak and computing sync scores.
|
||||||
|
fn find_scan_hits(&self, ctx: &DownsampleContext) -> Vec<ScanHit> {
|
||||||
|
let peaks = self.find_frequency_peaks();
|
||||||
|
if peaks.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nfft2 = ctx.nfft2();
|
||||||
|
let waveforms = prepare_sync_waveforms();
|
||||||
|
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
|
for peak in &peaks {
|
||||||
|
if hits.len() >= FT2_MAX_SCAN_HITS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut down = vec![Complex32::new(0.0, 0.0); nfft2];
|
||||||
|
let produced = ctx.downsample(peak.freq_hz, &mut down);
|
||||||
|
if produced == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalize_downsampled(&mut down[..produced], produced);
|
||||||
|
|
||||||
|
// Coarse search
|
||||||
|
let mut best_score: f32 = -1.0;
|
||||||
|
let mut best_start: i32 = 0;
|
||||||
|
let mut best_idf: i32 = 0;
|
||||||
|
|
||||||
|
let mut idf = -12i32;
|
||||||
|
while idf <= 12 {
|
||||||
|
let mut start = -688i32;
|
||||||
|
while start <= 2024 {
|
||||||
|
let score = sync2d_score(
|
||||||
|
&down[..produced],
|
||||||
|
start,
|
||||||
|
idf,
|
||||||
|
&waveforms,
|
||||||
|
);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_start = start;
|
||||||
|
best_idf = idf;
|
||||||
|
}
|
||||||
|
start += 4;
|
||||||
|
}
|
||||||
|
idf += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_score < 0.50 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fine refinement
|
||||||
|
for idf in (best_idf - 4)..=(best_idf + 4) {
|
||||||
|
if idf < FT2_SYNC_TWEAK_MIN || idf > FT2_SYNC_TWEAK_MAX {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for start in (best_start - 5)..=(best_start + 5) {
|
||||||
|
let score = sync2d_score(
|
||||||
|
&down[..produced],
|
||||||
|
start,
|
||||||
|
idf,
|
||||||
|
&waveforms,
|
||||||
|
);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_start = start;
|
||||||
|
best_idf = idf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_score < 0.50 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hits.push(ScanHit {
|
||||||
|
freq_hz: peak.freq_hz,
|
||||||
|
snr0: peak.score - 1.0,
|
||||||
|
sync_score: best_score,
|
||||||
|
start: best_start,
|
||||||
|
idf: best_idf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by sync score descending
|
||||||
|
hits.sort_by(|a, b| {
|
||||||
|
b.sync_score
|
||||||
|
.partial_cmp(&a.sync_score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to decode a single scan hit through the full pipeline.
|
||||||
|
fn decode_hit(&self, ctx: &DownsampleContext, hit: &ScanHit) -> Option<Ft2DecodeResult> {
|
||||||
|
let nfft2 = ctx.nfft2();
|
||||||
|
let waveforms = prepare_sync_waveforms();
|
||||||
|
|
||||||
|
// Initial downsample for sync refinement
|
||||||
|
let mut cd2 = vec![Complex32::new(0.0, 0.0); nfft2];
|
||||||
|
let produced = ctx.downsample(hit.freq_hz, &mut cd2);
|
||||||
|
if produced == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalize_downsampled(&mut cd2[..produced], produced);
|
||||||
|
|
||||||
|
// Refine sync
|
||||||
|
let mut best_score: f32 = -1.0;
|
||||||
|
let mut best_start = hit.start;
|
||||||
|
let mut best_idf = hit.idf;
|
||||||
|
|
||||||
|
for idf in (hit.idf - 4)..=(hit.idf + 4) {
|
||||||
|
if idf < FT2_SYNC_TWEAK_MIN || idf > FT2_SYNC_TWEAK_MAX {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for start in (hit.start - 5)..=(hit.start + 5) {
|
||||||
|
let score = sync2d_score(&cd2[..produced], start, idf, &waveforms);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_start = start;
|
||||||
|
best_idf = idf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_score < 0.65 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency correction
|
||||||
|
let corrected_freq_hz = hit.freq_hz + best_idf as f32;
|
||||||
|
if corrected_freq_hz <= 10.0 || corrected_freq_hz >= 4990.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final downsample at corrected frequency
|
||||||
|
let mut cb = vec![Complex32::new(0.0, 0.0); nfft2];
|
||||||
|
let produced2 = ctx.downsample(corrected_freq_hz, &mut cb);
|
||||||
|
if produced2 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalize_downsampled(&mut cb[..produced2], FT2_FRAME_SAMPLES);
|
||||||
|
|
||||||
|
// Extract signal region
|
||||||
|
let mut signal = vec![Complex32::new(0.0, 0.0); FT2_FRAME_SAMPLES];
|
||||||
|
extract_signal_region(&cb[..produced2], best_start, &mut signal);
|
||||||
|
|
||||||
|
// Extract bit metrics
|
||||||
|
let bitmetrics = match bitmetrics::extract_bitmetrics_raw(&signal) {
|
||||||
|
Some(bm) => bm,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync quality check using known Costas bit patterns
|
||||||
|
let sync_bits_a: [u8; 8] = [0, 0, 0, 1, 1, 0, 1, 1];
|
||||||
|
let sync_bits_b: [u8; 8] = [0, 1, 0, 0, 1, 1, 1, 0];
|
||||||
|
let sync_bits_c: [u8; 8] = [1, 1, 1, 0, 0, 1, 0, 0];
|
||||||
|
let sync_bits_d: [u8; 8] = [1, 0, 1, 1, 0, 0, 0, 1];
|
||||||
|
let mut sync_qual = 0;
|
||||||
|
for i in 0..8 {
|
||||||
|
sync_qual += if (bitmetrics[i][0] >= 0.0) as u8 == sync_bits_a[i] { 1 } else { 0 };
|
||||||
|
sync_qual += if (bitmetrics[66 + i][0] >= 0.0) as u8 == sync_bits_b[i] { 1 } else { 0 };
|
||||||
|
sync_qual += if (bitmetrics[132 + i][0] >= 0.0) as u8 == sync_bits_c[i] { 1 } else { 0 };
|
||||||
|
sync_qual += if (bitmetrics[198 + i][0] >= 0.0) as u8 == sync_bits_d[i] { 1 } else { 0 };
|
||||||
|
}
|
||||||
|
if sync_qual < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 5 LLR passes from the 3 metric scales
|
||||||
|
let mut llr_passes = [[0.0f32; FTX_LDPC_N]; 5];
|
||||||
|
for i in 0..58 {
|
||||||
|
llr_passes[0][i] = bitmetrics[8 + i][0];
|
||||||
|
llr_passes[0][58 + i] = bitmetrics[74 + i][0];
|
||||||
|
llr_passes[0][116 + i] = bitmetrics[140 + i][0];
|
||||||
|
|
||||||
|
llr_passes[1][i] = bitmetrics[8 + i][1];
|
||||||
|
llr_passes[1][58 + i] = bitmetrics[74 + i][1];
|
||||||
|
llr_passes[1][116 + i] = bitmetrics[140 + i][1];
|
||||||
|
|
||||||
|
llr_passes[2][i] = bitmetrics[8 + i][2];
|
||||||
|
llr_passes[2][58 + i] = bitmetrics[74 + i][2];
|
||||||
|
llr_passes[2][116 + i] = bitmetrics[140 + i][2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale and derive combined passes
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
llr_passes[0][i] *= 3.2;
|
||||||
|
llr_passes[1][i] *= 3.2;
|
||||||
|
llr_passes[2][i] *= 3.2;
|
||||||
|
|
||||||
|
let a = llr_passes[0][i];
|
||||||
|
let b = llr_passes[1][i];
|
||||||
|
let c = llr_passes[2][i];
|
||||||
|
|
||||||
|
// Pass 3: max-abs metric
|
||||||
|
llr_passes[3][i] = if a.abs() >= b.abs() && a.abs() >= c.abs() {
|
||||||
|
a
|
||||||
|
} else if b.abs() >= c.abs() {
|
||||||
|
b
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass 4: average
|
||||||
|
llr_passes[4][i] = (a + b + c) / 3.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-pass LDPC decode
|
||||||
|
let mut ok = false;
|
||||||
|
let mut message = FtxMessage::default();
|
||||||
|
let mut global_best_errors = FTX_LDPC_M as i32;
|
||||||
|
|
||||||
|
for pass in 0..5 {
|
||||||
|
if ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut log174 = llr_passes[pass];
|
||||||
|
normalize_log174(&mut log174);
|
||||||
|
|
||||||
|
let mut nharderror = FTX_LDPC_M as i32;
|
||||||
|
|
||||||
|
// BP decode
|
||||||
|
let mut bp_plain = [0u8; FTX_LDPC_N];
|
||||||
|
let bp_errors = ldpc::bp_decode(&log174, 50, &mut bp_plain);
|
||||||
|
if bp_errors < nharderror {
|
||||||
|
nharderror = bp_errors;
|
||||||
|
}
|
||||||
|
if bp_errors == 0 {
|
||||||
|
if let Some(msg) = unpack_message(&bp_plain) {
|
||||||
|
message = msg;
|
||||||
|
ok = true;
|
||||||
|
nharderror = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum-product decode (fallback)
|
||||||
|
if !ok {
|
||||||
|
let mut sp_log174 = llr_passes[pass];
|
||||||
|
normalize_log174(&mut sp_log174);
|
||||||
|
let mut sp_plain = [0u8; FTX_LDPC_N];
|
||||||
|
let sp_errors = ldpc::ldpc_decode(&mut sp_log174, 50, &mut sp_plain);
|
||||||
|
if sp_errors < nharderror {
|
||||||
|
nharderror = sp_errors;
|
||||||
|
}
|
||||||
|
if sp_errors == 0 {
|
||||||
|
if let Some(msg) = unpack_message(&sp_plain) {
|
||||||
|
message = msg;
|
||||||
|
ok = true;
|
||||||
|
nharderror = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nharderror < global_best_errors {
|
||||||
|
global_best_errors = nharderror;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC-based OSD-1/OSD-2 fallback when LDPC was close to converging
|
||||||
|
if !ok && global_best_errors <= 6 {
|
||||||
|
for pass in 0..5 {
|
||||||
|
if ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut osd_log174 = llr_passes[pass];
|
||||||
|
normalize_log174(&mut osd_log174);
|
||||||
|
if let Some(msg) = osd_lite_decode(&osd_log174) {
|
||||||
|
message = msg;
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute refined timing via parabolic interpolation
|
||||||
|
let sm1 = sync2d_score(&cd2[..produced], best_start - 1, best_idf, &waveforms);
|
||||||
|
let sp1 = sync2d_score(&cd2[..produced], best_start + 1, best_idf, &waveforms);
|
||||||
|
let mut xstart = best_start as f32;
|
||||||
|
let den = sm1 - 2.0 * best_score + sp1;
|
||||||
|
if den.abs() > 1e-6 {
|
||||||
|
xstart += 0.5 * (sm1 - sp1) / den;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dt_s = xstart / (12000.0 / FT2_NDOWN as f32) - 0.5;
|
||||||
|
let snr_db = if hit.snr0 > 0.0 {
|
||||||
|
(10.0 * hit.snr0.log10() - 13.0).max(-21.0)
|
||||||
|
} else {
|
||||||
|
-21.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Ft2DecodeResult {
|
||||||
|
message,
|
||||||
|
dt_s,
|
||||||
|
freq_hz: corrected_freq_hz,
|
||||||
|
snr_db,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a Nuttall window of length `n`.
|
||||||
|
fn nuttall_window(n: usize) -> Vec<f32> {
|
||||||
|
let a0: f32 = 0.355768;
|
||||||
|
let a1: f32 = 0.487396;
|
||||||
|
let a2: f32 = 0.144232;
|
||||||
|
let a3: f32 = 0.012604;
|
||||||
|
(0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let phase = 2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32;
|
||||||
|
a0 - a1 * phase.cos() + a2 * (2.0 * phase).cos() - a3 * (3.0 * phase).cos()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize complex downsampled signal to unit power.
|
||||||
|
fn normalize_downsampled(samples: &mut [Complex32], ref_count: usize) {
|
||||||
|
let power: f32 = samples.iter().map(|s| s.norm_sqr()).sum();
|
||||||
|
if power <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rc = if ref_count == 0 { samples.len() } else { ref_count };
|
||||||
|
let scale = (rc as f32 / power).sqrt();
|
||||||
|
for s in samples.iter_mut() {
|
||||||
|
*s *= scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a signal region starting at `start` into `out_signal`.
|
||||||
|
fn extract_signal_region(input: &[Complex32], start: i32, out_signal: &mut [Complex32]) {
|
||||||
|
for i in 0..out_signal.len() {
|
||||||
|
let src = start + i as i32;
|
||||||
|
out_signal[i] = if src >= 0 && (src as usize) < input.len() {
|
||||||
|
input[src as usize]
|
||||||
|
} else {
|
||||||
|
Complex32::new(0.0, 0.0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize LLR array (divide by standard deviation).
|
||||||
|
fn normalize_log174(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 <= 1e-12 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sigma = variance.sqrt();
|
||||||
|
for v in log174.iter_mut() {
|
||||||
|
*v /= sigma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a 174-bit plaintext into an FtxMessage, verifying CRC and applying XOR sequence.
|
||||||
|
fn unpack_message(plain174: &[u8; FTX_LDPC_N]) -> Option<FtxMessage> {
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits(plain174, FTX_LDPC_K, &mut a91);
|
||||||
|
|
||||||
|
let crc_extracted = ftx_extract_crc(&a91);
|
||||||
|
a91[9] &= 0xF8;
|
||||||
|
a91[10] = 0x00;
|
||||||
|
let crc_calculated = ftx_compute_crc(&a91, 96 - 14);
|
||||||
|
|
||||||
|
if crc_extracted != crc_calculated {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read a91 since we modified it for CRC check
|
||||||
|
pack_bits(plain174, FTX_LDPC_K, &mut a91);
|
||||||
|
|
||||||
|
let mut msg = FtxMessage {
|
||||||
|
hash: crc_calculated,
|
||||||
|
payload: [0; FTX_PAYLOAD_LENGTH_BYTES],
|
||||||
|
};
|
||||||
|
for i in 0..10 {
|
||||||
|
msg.payload[i] = a91[i] ^ FT4_XOR_SEQUENCE[i];
|
||||||
|
}
|
||||||
|
Some(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a packed 91-bit message into a 174-bit codeword (bit array).
|
||||||
|
fn encode_codeword_from_a91(a91: &[u8; FTX_LDPC_K_BYTES]) -> [u8; FTX_LDPC_N] {
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N];
|
||||||
|
// Systematic part
|
||||||
|
for i in 0..FTX_LDPC_K {
|
||||||
|
codeword[i] = (a91[i / 8] >> (7 - (i % 8))) & 0x01;
|
||||||
|
}
|
||||||
|
// Parity part using generator matrix
|
||||||
|
for i in 0..FTX_LDPC_M {
|
||||||
|
let mut nsum: u8 = 0;
|
||||||
|
for j in 0..FTX_LDPC_K_BYTES {
|
||||||
|
let x = a91[j] & crate::constants::FTX_LDPC_GENERATOR[i][j];
|
||||||
|
nsum ^= parity8(x);
|
||||||
|
}
|
||||||
|
codeword[FTX_LDPC_K + i] = nsum & 0x01;
|
||||||
|
}
|
||||||
|
codeword
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count parity of a byte.
|
||||||
|
fn parity8(x: u8) -> u8 {
|
||||||
|
let x = x ^ (x >> 4);
|
||||||
|
let x = x ^ (x >> 2);
|
||||||
|
let x = x ^ (x >> 1);
|
||||||
|
x & 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count hard errors between LLR signs and a candidate codeword.
|
||||||
|
fn count_hard_errors_vs_llr(log174: &[f32; FTX_LDPC_N], codeword: &[u8; FTX_LDPC_N]) -> usize {
|
||||||
|
let mut errors = 0;
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
let received = if log174[i] >= 0.0 { 1u8 } else { 0u8 };
|
||||||
|
if received != codeword[i] {
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try a CRC candidate: encode the packed message, verify CRC and hard-error count.
|
||||||
|
fn try_crc_candidate(
|
||||||
|
a91: &[u8; FTX_LDPC_K_BYTES],
|
||||||
|
log174: &[f32; FTX_LDPC_N],
|
||||||
|
) -> Option<FtxMessage> {
|
||||||
|
let codeword = encode_codeword_from_a91(a91);
|
||||||
|
|
||||||
|
// Check CRC via unpack
|
||||||
|
let mut plain174 = [0u8; FTX_LDPC_N];
|
||||||
|
plain174.copy_from_slice(&codeword);
|
||||||
|
let msg = unpack_message(&plain174)?;
|
||||||
|
|
||||||
|
// Verify consistency with received LLRs
|
||||||
|
if count_hard_errors_vs_llr(log174, &codeword) > FT2_OSD_MAX_HARD_ERRORS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reliability entry for OSD-lite sorting.
|
||||||
|
struct ReliabilityEntry {
|
||||||
|
index: usize,
|
||||||
|
reliability: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRC-guided OSD-1/OSD-2 lite decoder.
|
||||||
|
///
|
||||||
|
/// Tries flipping each of the 16 least-reliable systematic bits (OSD-1),
|
||||||
|
/// then all pairs (OSD-2). Returns decoded message on CRC match.
|
||||||
|
fn osd_lite_decode(log174: &[f32; FTX_LDPC_N]) -> Option<FtxMessage> {
|
||||||
|
// Build base hard decision from systematic bits
|
||||||
|
let mut base_a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
for i in 0..FTX_LDPC_K {
|
||||||
|
if log174[i] >= 0.0 {
|
||||||
|
base_a91[i / 8] |= 0x80u8 >> (i % 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try base (zero flips)
|
||||||
|
if let Some(msg) = try_crc_candidate(&base_a91, log174) {
|
||||||
|
return Some(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort systematic bits by reliability (ascending = least reliable first)
|
||||||
|
let mut rel: Vec<ReliabilityEntry> = (0..FTX_LDPC_K)
|
||||||
|
.map(|i| ReliabilityEntry {
|
||||||
|
index: i,
|
||||||
|
reliability: log174[i].abs(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rel.sort_by(|a, b| {
|
||||||
|
a.reliability
|
||||||
|
.partial_cmp(&b.reliability)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
let max_candidates = 16.min(FTX_LDPC_K);
|
||||||
|
|
||||||
|
// OSD-1: single bit flips
|
||||||
|
for i in 0..max_candidates {
|
||||||
|
let mut trial = base_a91;
|
||||||
|
let b0 = rel[i].index;
|
||||||
|
trial[b0 / 8] ^= 0x80u8 >> (b0 % 8);
|
||||||
|
if let Some(msg) = try_crc_candidate(&trial, log174) {
|
||||||
|
return Some(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSD-2: all pairs
|
||||||
|
for i in 0..max_candidates {
|
||||||
|
for j in (i + 1)..max_candidates {
|
||||||
|
let mut trial = base_a91;
|
||||||
|
let b0 = rel[i].index;
|
||||||
|
let b1 = rel[j].index;
|
||||||
|
trial[b0 / 8] ^= 0x80u8 >> (b0 % 8);
|
||||||
|
trial[b1 / 8] ^= 0x80u8 >> (b1 % 8);
|
||||||
|
if let Some(msg) = try_crc_candidate(&trial, log174) {
|
||||||
|
return Some(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nuttall_window_length() {
|
||||||
|
let w = nuttall_window(64);
|
||||||
|
assert_eq!(w.len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nuttall_window_symmetric() {
|
||||||
|
let w = nuttall_window(128);
|
||||||
|
for i in 0..64 {
|
||||||
|
assert!(
|
||||||
|
(w[i] - w[127 - i]).abs() < 1e-6,
|
||||||
|
"Window not symmetric at index {}",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_accumulate() {
|
||||||
|
let mut pipe = Ft2Pipeline::new(12000);
|
||||||
|
let samples = vec![0.0f32; 1000];
|
||||||
|
assert!(!pipe.accumulate(&samples));
|
||||||
|
assert_eq!(pipe.raw_len(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_ready() {
|
||||||
|
let mut pipe = Ft2Pipeline::new(12000);
|
||||||
|
let samples = vec![0.0f32; FT2_NMAX];
|
||||||
|
assert!(pipe.accumulate(&samples));
|
||||||
|
assert!(pipe.is_ready());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_downsampled_zero_power() {
|
||||||
|
let mut samples = vec![Complex32::new(0.0, 0.0); 16];
|
||||||
|
normalize_downsampled(&mut samples, 16);
|
||||||
|
// Should not crash or produce NaN
|
||||||
|
for s in &samples {
|
||||||
|
assert!(!s.re.is_nan());
|
||||||
|
assert!(!s.im.is_nan());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity8_basic() {
|
||||||
|
assert_eq!(parity8(0x00), 0);
|
||||||
|
assert_eq!(parity8(0x01), 1);
|
||||||
|
assert_eq!(parity8(0x03), 0);
|
||||||
|
assert_eq!(parity8(0xFF), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_codeword_all_zeros() {
|
||||||
|
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
let cw = encode_codeword_from_a91(&a91);
|
||||||
|
for &b in &cw {
|
||||||
|
assert_eq!(b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! OSD-1/OSD-2 CRC-guided bit-flip decoder for the (174,91) LDPC code.
|
||||||
|
//!
|
||||||
|
//! This is a port of `ft2_ldpc.c` which implements Ordered Statistics Decoding
|
||||||
|
//! with configurable depth (ndeep 0-6). The decoder first runs iterative
|
||||||
|
//! belief-propagation (BP), then falls back to OSD refinement using the
|
||||||
|
//! accumulated LLR sums from BP iterations.
|
||||||
|
//!
|
||||||
|
//! The OSD algorithm works by:
|
||||||
|
//! 1. Sorting codeword bits by LLR reliability
|
||||||
|
//! 2. Gaussian elimination to put the generator matrix in systematic form
|
||||||
|
//! (with respect to the most reliable bits)
|
||||||
|
//! 3. Exhaustive search over bit-flip patterns of increasing weight
|
||||||
|
//! 4. Pattern hashing (OSD-2) to efficiently search two-bit-flip corrections
|
||||||
|
|
||||||
|
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::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||||
|
|
||||||
|
/// Check LDPC parity of a 174-bit codeword. Returns number of parity errors.
|
||||||
|
pub fn ft2_ldpc_check(codeword: &[u8]) -> i32 {
|
||||||
|
let mut errors = 0i32;
|
||||||
|
for m in 0..FTX_LDPC_M {
|
||||||
|
let mut x: u8 = 0;
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
for i in 0..num_rows {
|
||||||
|
let idx = FTX_LDPC_NM[m][i] as usize;
|
||||||
|
if idx > 0 && idx - 1 < codeword.len() {
|
||||||
|
x ^= codeword[idx - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x != 0 {
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast rational approximation of `atanh(x)`.
|
||||||
|
fn fast_atanh(x: f32) -> f32 {
|
||||||
|
let x2 = x * x;
|
||||||
|
let a = x * (945.0 + x2 * (-735.0 + x2 * 64.0));
|
||||||
|
let b = 945.0 + x2 * (-1050.0 + x2 * 225.0);
|
||||||
|
a / b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Piecewise linear approximation of `atanh(x)` used in BP message passing.
|
||||||
|
fn platanh(x: f32) -> f32 {
|
||||||
|
let isign: f32 = if x < 0.0 { -1.0 } else { 1.0 };
|
||||||
|
let z = x.abs();
|
||||||
|
|
||||||
|
if z <= 0.664 {
|
||||||
|
return x / 0.83;
|
||||||
|
}
|
||||||
|
if z <= 0.9217 {
|
||||||
|
return isign * ((z - 0.4064) / 0.322);
|
||||||
|
}
|
||||||
|
if z <= 0.9951 {
|
||||||
|
return isign * ((z - 0.8378) / 0.0524);
|
||||||
|
}
|
||||||
|
if z <= 0.9998 {
|
||||||
|
return isign * ((z - 0.9914) / 0.0012);
|
||||||
|
}
|
||||||
|
isign * 7.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack bit array into bytes (MSB first).
|
||||||
|
fn pack_bits91(bit_array: &[u8], num_bits: usize, packed: &mut [u8]) {
|
||||||
|
let num_bytes = (num_bits + 7) / 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check CRC of a 91-bit message (in bit array form).
|
||||||
|
fn check_crc91(plain91: &[u8]) -> bool {
|
||||||
|
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits91(plain91, FTX_LDPC_K, &mut a91);
|
||||||
|
let crc_extracted = ftx_extract_crc(&a91);
|
||||||
|
a91[9] &= 0xF8;
|
||||||
|
a91[10] = 0x00;
|
||||||
|
let crc_calculated = ftx_compute_crc(&a91, 96 - 14);
|
||||||
|
crc_extracted == crc_calculated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute parity of a byte.
|
||||||
|
fn parity8(x: u8) -> u8 {
|
||||||
|
let x = x ^ (x >> 4);
|
||||||
|
let x = x ^ (x >> 2);
|
||||||
|
let x = x ^ (x >> 1);
|
||||||
|
x & 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a 91-bit message (bit array) into a 174-bit codeword without CRC computation.
|
||||||
|
fn encode174_91_nocrc_bits(message91: &[u8], codeword: &mut [u8; FTX_LDPC_N]) {
|
||||||
|
let mut packed = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits91(message91, FTX_LDPC_K, &mut packed);
|
||||||
|
|
||||||
|
// Systematic bits
|
||||||
|
for i in 0..FTX_LDPC_K {
|
||||||
|
codeword[i] = message91[i] & 0x01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parity bits from generator matrix
|
||||||
|
for i in 0..FTX_LDPC_M {
|
||||||
|
let mut nsum: u8 = 0;
|
||||||
|
for j in 0..FTX_LDPC_K_BYTES {
|
||||||
|
nsum ^= parity8(packed[j] & FTX_LDPC_GENERATOR[i][j]);
|
||||||
|
}
|
||||||
|
codeword[FTX_LDPC_K + i] = nsum & 0x01;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XOR two byte slices.
|
||||||
|
fn xor_rows(dst: &mut [u8], src: &[u8], len: usize) {
|
||||||
|
for i in 0..len {
|
||||||
|
dst[i] ^= src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix-vector multiply for re-encoding in OSD.
|
||||||
|
fn mrbencode91(me: &[u8], codeword: &mut [u8], g2: &[u8], n: usize, k: usize) {
|
||||||
|
for c in codeword[..n].iter_mut() {
|
||||||
|
*c = 0;
|
||||||
|
}
|
||||||
|
for i in 0..k {
|
||||||
|
if me[i] == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for j in 0..n {
|
||||||
|
codeword[j] ^= g2[j * k + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate next bit-flip pattern of given order.
|
||||||
|
fn nextpat91(mi: &mut [u8], k: usize, iorder: usize, iflag: &mut i32) {
|
||||||
|
let mut ind: i32 = -1;
|
||||||
|
for i in 0..k.saturating_sub(1) {
|
||||||
|
if mi[i] == 0 && mi[i + 1] == 1 {
|
||||||
|
ind = i as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ind < 0 {
|
||||||
|
*iflag = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ms = vec![0u8; k];
|
||||||
|
for i in 0..ind as usize {
|
||||||
|
ms[i] = mi[i];
|
||||||
|
}
|
||||||
|
ms[ind as usize] = 1;
|
||||||
|
ms[ind as usize + 1] = 0;
|
||||||
|
|
||||||
|
if (ind as usize + 1) < k {
|
||||||
|
let mut nz = iorder as i32;
|
||||||
|
for i in 0..k {
|
||||||
|
nz -= ms[i] as i32;
|
||||||
|
}
|
||||||
|
if nz > 0 {
|
||||||
|
for i in (k - nz as usize)..k {
|
||||||
|
ms[i] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mi[..k].copy_from_slice(&ms[..k]);
|
||||||
|
|
||||||
|
*iflag = -1;
|
||||||
|
for i in 0..k {
|
||||||
|
if mi[i] == 1 {
|
||||||
|
*iflag = i as i32;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pattern hash table for OSD-2 optimization.
|
||||||
|
struct OsdBox {
|
||||||
|
head: Vec<i32>,
|
||||||
|
next: Vec<i32>,
|
||||||
|
pairs: Vec<[i32; 2]>,
|
||||||
|
capacity: usize,
|
||||||
|
count: usize,
|
||||||
|
size: usize,
|
||||||
|
last_pattern: i32,
|
||||||
|
next_index: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsdBox {
|
||||||
|
fn new(ntau: usize) -> Option<Self> {
|
||||||
|
let size = 1 << ntau;
|
||||||
|
let capacity = 5000;
|
||||||
|
Some(Self {
|
||||||
|
head: vec![-1; size],
|
||||||
|
next: vec![-1; capacity],
|
||||||
|
pairs: vec![[-1, -1]; capacity],
|
||||||
|
capacity,
|
||||||
|
count: 0,
|
||||||
|
size,
|
||||||
|
last_pattern: -1,
|
||||||
|
next_index: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boxit(&mut self, e2: &[u8], ntau: usize, i1: i32, i2: i32) {
|
||||||
|
if self.count >= self.capacity {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = self.count;
|
||||||
|
self.count += 1;
|
||||||
|
self.pairs[idx] = [i1, i2];
|
||||||
|
|
||||||
|
let ipat = pattern_hash(e2, ntau);
|
||||||
|
let ip = self.head[ipat];
|
||||||
|
if ip == -1 {
|
||||||
|
self.head[ipat] = idx as i32;
|
||||||
|
} else {
|
||||||
|
let mut cur = ip;
|
||||||
|
while self.next[cur as usize] != -1 {
|
||||||
|
cur = self.next[cur as usize];
|
||||||
|
}
|
||||||
|
self.next[cur as usize] = idx as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetchit(&mut self, e2: &[u8], ntau: usize) -> (i32, i32) {
|
||||||
|
let ipat = pattern_hash(e2, ntau);
|
||||||
|
let index = self.head[ipat];
|
||||||
|
|
||||||
|
if self.last_pattern != ipat as i32 && index >= 0 {
|
||||||
|
let i1 = self.pairs[index as usize][0];
|
||||||
|
let i2 = self.pairs[index as usize][1];
|
||||||
|
self.next_index = self.next[index as usize];
|
||||||
|
self.last_pattern = ipat as i32;
|
||||||
|
(i1, i2)
|
||||||
|
} else if self.last_pattern == ipat as i32 && self.next_index >= 0 {
|
||||||
|
let ni = self.next_index as usize;
|
||||||
|
let i1 = self.pairs[ni][0];
|
||||||
|
let i2 = self.pairs[ni][1];
|
||||||
|
self.next_index = self.next[ni];
|
||||||
|
(i1, i2)
|
||||||
|
} else {
|
||||||
|
self.next_index = -1;
|
||||||
|
self.last_pattern = ipat as i32;
|
||||||
|
(-1, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute hash of a bit pattern for OSD-2 lookup.
|
||||||
|
fn pattern_hash(e2: &[u8], ntau: usize) -> usize {
|
||||||
|
let mut ipat = 0usize;
|
||||||
|
for i in 0..ntau {
|
||||||
|
if e2[i] != 0 {
|
||||||
|
ipat |= 1 << (ntau - i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered Statistics Decoding with configurable depth.
|
||||||
|
///
|
||||||
|
/// `llr`: log-likelihood ratios for 174 bits (modified internally).
|
||||||
|
/// `k`: number of systematic bits (91).
|
||||||
|
/// `apmask`: a priori mask (which bits are known).
|
||||||
|
/// `ndeep`: search depth (0-6).
|
||||||
|
/// `message91`: output 91-bit message.
|
||||||
|
/// `cw`: output 174-bit codeword.
|
||||||
|
/// `nhardmin`: output minimum hard errors.
|
||||||
|
/// `dmin`: output minimum distance.
|
||||||
|
pub fn osd174_91(
|
||||||
|
llr: &mut [f32; FTX_LDPC_N],
|
||||||
|
k: usize,
|
||||||
|
apmask: &[u8; FTX_LDPC_N],
|
||||||
|
ndeep: usize,
|
||||||
|
message91: &mut [u8; FTX_LDPC_K],
|
||||||
|
cw: &mut [u8; FTX_LDPC_N],
|
||||||
|
nhardmin: &mut i32,
|
||||||
|
dmin: &mut f32,
|
||||||
|
) {
|
||||||
|
let n = FTX_LDPC_N;
|
||||||
|
let ndeep = ndeep.min(6);
|
||||||
|
|
||||||
|
// Build per-bit generator matrix (each row i generates codeword from
|
||||||
|
// unit vector e_i)
|
||||||
|
let gen = build_generator_matrix();
|
||||||
|
|
||||||
|
// Allocate working buffers
|
||||||
|
let mut genmrb = vec![0u8; k * n];
|
||||||
|
let mut g2 = vec![0u8; n * k];
|
||||||
|
let mut m0 = vec![0u8; k];
|
||||||
|
let mut me = vec![0u8; k];
|
||||||
|
let mut mi = vec![0u8; k];
|
||||||
|
let mut misub = vec![0u8; k];
|
||||||
|
let mut e2sub = vec![0u8; n - k];
|
||||||
|
let mut e2 = vec![0u8; n - k];
|
||||||
|
let mut ui = vec![0u8; n - k];
|
||||||
|
let mut r2pat = vec![0u8; n - k];
|
||||||
|
let mut hdec = vec![0u8; n];
|
||||||
|
let mut c0 = vec![0u8; n];
|
||||||
|
let mut ce = vec![0u8; n];
|
||||||
|
let mut nxor = vec![0u8; n];
|
||||||
|
let mut apmaskr = vec![0u8; n];
|
||||||
|
let mut rx = vec![0.0f32; n];
|
||||||
|
let mut absrx = vec![0.0f32; n];
|
||||||
|
let mut indices = vec![0usize; n];
|
||||||
|
|
||||||
|
// Sort bits by reliability (descending)
|
||||||
|
struct RelEntry {
|
||||||
|
index: usize,
|
||||||
|
abs_llr: f32,
|
||||||
|
}
|
||||||
|
let mut rel: Vec<RelEntry> = (0..n)
|
||||||
|
.map(|i| RelEntry {
|
||||||
|
index: i,
|
||||||
|
abs_llr: llr[i].abs(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rel.sort_by(|a, b| {
|
||||||
|
b.abs_llr
|
||||||
|
.partial_cmp(&a.abs_llr)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
rx[i] = llr[i];
|
||||||
|
apmaskr[i] = apmask[i];
|
||||||
|
hdec[i] = if rx[i] >= 0.0 { 1 } else { 0 };
|
||||||
|
absrx[i] = rx[i].abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder by reliability
|
||||||
|
for i in 0..n {
|
||||||
|
indices[i] = rel[i].index;
|
||||||
|
for row in 0..k {
|
||||||
|
genmrb[row * n + i] = gen[row][indices[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gaussian elimination to systematic form
|
||||||
|
for id in 0..k {
|
||||||
|
let max_col = (k + 20).min(n);
|
||||||
|
for col in id..max_col {
|
||||||
|
if genmrb[id * n + col] == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Swap columns id and col
|
||||||
|
if col != id {
|
||||||
|
for row in 0..k {
|
||||||
|
let a = genmrb[row * n + id];
|
||||||
|
genmrb[row * n + id] = genmrb[row * n + col];
|
||||||
|
genmrb[row * n + col] = a;
|
||||||
|
}
|
||||||
|
indices.swap(id, col);
|
||||||
|
}
|
||||||
|
// Eliminate column id from all other rows
|
||||||
|
for row in 0..k {
|
||||||
|
if row != id && genmrb[row * n + id] == 1 {
|
||||||
|
for c in 0..n {
|
||||||
|
genmrb[row * n + c] ^= genmrb[id * n + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transpose to column-major g2
|
||||||
|
for row in 0..k {
|
||||||
|
for col in 0..n {
|
||||||
|
g2[col * k + row] = genmrb[row * n + col];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder LLRs and hard decisions by reliability
|
||||||
|
for i in 0..n {
|
||||||
|
hdec[i] = if rx[indices[i]] >= 0.0 { 1 } else { 0 };
|
||||||
|
absrx[i] = rx[indices[i]].abs();
|
||||||
|
rx[i] = llr[indices[i]];
|
||||||
|
apmaskr[i] = apmask[indices[i]];
|
||||||
|
}
|
||||||
|
m0[..k].copy_from_slice(&hdec[..k]);
|
||||||
|
|
||||||
|
// Initial encode
|
||||||
|
mrbencode91(&m0, &mut c0, &g2, n, k);
|
||||||
|
for i in 0..n {
|
||||||
|
nxor[i] = c0[i] ^ hdec[i];
|
||||||
|
}
|
||||||
|
*nhardmin = 0;
|
||||||
|
*dmin = 0.0;
|
||||||
|
for i in 0..n {
|
||||||
|
*nhardmin += nxor[i] as i32;
|
||||||
|
if nxor[i] != 0 {
|
||||||
|
*dmin += absrx[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cw.copy_from_slice(&c0[..n]);
|
||||||
|
|
||||||
|
if ndeep == 0 {
|
||||||
|
reorder_result(cw, &indices, message91, nhardmin, dmin, llr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure search parameters based on depth
|
||||||
|
let (nord, npre1, npre2, nt, ntheta, ntau) = match ndeep {
|
||||||
|
1 => (1, 0, 0, 40, 12, 0),
|
||||||
|
2 => (1, 1, 0, 40, 10, 0),
|
||||||
|
3 => (1, 1, 1, 40, 12, 14),
|
||||||
|
4 => (2, 1, 1, 40, 12, 17),
|
||||||
|
5 => (3, 1, 1, 40, 12, 15),
|
||||||
|
_ => (4, 1, 1, 95, 12, 15),
|
||||||
|
};
|
||||||
|
|
||||||
|
// OSD-1: exhaustive search over bit patterns of increasing order
|
||||||
|
for iorder in 1..=nord {
|
||||||
|
misub.iter_mut().for_each(|v| *v = 0);
|
||||||
|
for i in (k - iorder)..k {
|
||||||
|
misub[i] = 1;
|
||||||
|
}
|
||||||
|
let mut iflag = (k - iorder) as i32;
|
||||||
|
|
||||||
|
while iflag >= 0 {
|
||||||
|
let iend = if iorder == nord && npre1 == 0 {
|
||||||
|
iflag as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let mut d1 = 0.0f32;
|
||||||
|
|
||||||
|
let mut n1 = iflag;
|
||||||
|
while n1 >= iend as i32 {
|
||||||
|
mi[..k].copy_from_slice(&misub[..k]);
|
||||||
|
mi[n1 as usize] = 1;
|
||||||
|
|
||||||
|
// Check if any masked bit would be flipped
|
||||||
|
let masked = (0..k).any(|i| apmaskr[i] != 0 && mi[i] != 0);
|
||||||
|
if masked {
|
||||||
|
n1 -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..k {
|
||||||
|
me[i] = m0[i] ^ mi[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if n1 == iflag {
|
||||||
|
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||||
|
for i in 0..(n - k) {
|
||||||
|
e2sub[i] = ce[k + i] ^ hdec[k + i];
|
||||||
|
e2[i] = e2sub[i];
|
||||||
|
}
|
||||||
|
let mut nd1kpt = 1;
|
||||||
|
for i in 0..nt.min(n - k) {
|
||||||
|
nd1kpt += e2sub[i] as i32;
|
||||||
|
}
|
||||||
|
d1 = 0.0;
|
||||||
|
for i in 0..k {
|
||||||
|
if (me[i] ^ hdec[i]) != 0 {
|
||||||
|
d1 += absrx[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nd1kpt <= ntheta {
|
||||||
|
let mut dd = d1;
|
||||||
|
for i in 0..(n - k) {
|
||||||
|
if e2sub[i] != 0 {
|
||||||
|
dd += absrx[k + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dd < *dmin {
|
||||||
|
*dmin = dd;
|
||||||
|
cw[..n].copy_from_slice(&ce[..n]);
|
||||||
|
*nhardmin = 0;
|
||||||
|
for i in 0..n {
|
||||||
|
*nhardmin += (ce[i] ^ hdec[i]) as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i in 0..(n - k) {
|
||||||
|
e2[i] = e2sub[i] ^ g2[(k + i) * k + n1 as usize];
|
||||||
|
}
|
||||||
|
let mut nd1kpt = 2;
|
||||||
|
for i in 0..nt.min(n - k) {
|
||||||
|
nd1kpt += e2[i] as i32;
|
||||||
|
}
|
||||||
|
if nd1kpt <= ntheta {
|
||||||
|
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||||
|
let mut dd = d1
|
||||||
|
+ if (ce[n1 as usize] ^ hdec[n1 as usize]) != 0 {
|
||||||
|
absrx[n1 as usize]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
for i in 0..(n - k) {
|
||||||
|
if e2[i] != 0 {
|
||||||
|
dd += absrx[k + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dd < *dmin {
|
||||||
|
*dmin = dd;
|
||||||
|
cw[..n].copy_from_slice(&ce[..n]);
|
||||||
|
*nhardmin = 0;
|
||||||
|
for i in 0..n {
|
||||||
|
*nhardmin += (ce[i] ^ hdec[i]) as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n1 -= 1;
|
||||||
|
}
|
||||||
|
nextpat91(&mut misub, k, iorder, &mut iflag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSD-2: pattern-hashed two-bit-flip search
|
||||||
|
if npre2 == 1 {
|
||||||
|
if let Some(mut osd_box) = OsdBox::new(ntau) {
|
||||||
|
// Build hash table of all column pairs
|
||||||
|
for i1 in (0..k as i32).rev() {
|
||||||
|
for i2 in (0..i1).rev() {
|
||||||
|
for i in 0..ntau {
|
||||||
|
mi[i] = g2[(k + i) * k + i1 as usize] ^ g2[(k + i) * k + i2 as usize];
|
||||||
|
}
|
||||||
|
osd_box.boxit(&mi, ntau, i1, i2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search using base patterns
|
||||||
|
misub.iter_mut().for_each(|v| *v = 0);
|
||||||
|
for i in (k - nord)..k {
|
||||||
|
misub[i] = 1;
|
||||||
|
}
|
||||||
|
let mut iflag = (k - nord) as i32;
|
||||||
|
|
||||||
|
while iflag >= 0 {
|
||||||
|
for i in 0..k {
|
||||||
|
me[i] = m0[i] ^ misub[i];
|
||||||
|
}
|
||||||
|
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||||
|
for i in 0..(n - k) {
|
||||||
|
e2sub[i] = ce[k + i] ^ hdec[k + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for i2 in 0..=ntau {
|
||||||
|
ui.iter_mut().for_each(|v| *v = 0);
|
||||||
|
if i2 > 0 {
|
||||||
|
ui[i2 - 1] = 1;
|
||||||
|
}
|
||||||
|
for i in 0..ntau {
|
||||||
|
r2pat[i] = e2sub[i] ^ ui[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
osd_box.last_pattern = -1;
|
||||||
|
osd_box.next_index = -1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (in1, in2) = osd_box.fetchit(&r2pat, ntau);
|
||||||
|
if in1 < 0 || in2 < 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
mi[..k].copy_from_slice(&misub[..k]);
|
||||||
|
mi[in1 as usize] = 1;
|
||||||
|
mi[in2 as usize] = 1;
|
||||||
|
|
||||||
|
let mut w = 0;
|
||||||
|
let mut masked = false;
|
||||||
|
for i in 0..k {
|
||||||
|
w += mi[i] as usize;
|
||||||
|
if apmaskr[i] != 0 && mi[i] != 0 {
|
||||||
|
masked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if w < nord + npre1 + npre2 || masked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..k {
|
||||||
|
me[i] = m0[i] ^ mi[i];
|
||||||
|
}
|
||||||
|
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||||
|
|
||||||
|
let mut dd = 0.0f32;
|
||||||
|
let mut nh = 0i32;
|
||||||
|
for i in 0..n {
|
||||||
|
let diff = ce[i] ^ hdec[i];
|
||||||
|
nh += diff as i32;
|
||||||
|
if diff != 0 {
|
||||||
|
dd += absrx[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dd < *dmin {
|
||||||
|
*dmin = dd;
|
||||||
|
cw[..n].copy_from_slice(&ce[..n]);
|
||||||
|
*nhardmin = nh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextpat91(&mut misub, k, nord, &mut iflag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reorder_result(cw, &indices, message91, nhardmin, dmin, llr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder codeword back to original bit ordering and verify CRC.
|
||||||
|
fn reorder_result(
|
||||||
|
cw: &mut [u8; FTX_LDPC_N],
|
||||||
|
indices: &[usize],
|
||||||
|
message91: &mut [u8; FTX_LDPC_K],
|
||||||
|
nhardmin: &mut i32,
|
||||||
|
_dmin: &mut f32,
|
||||||
|
_llr: &[f32; FTX_LDPC_N],
|
||||||
|
) {
|
||||||
|
let mut reordered = [0u8; FTX_LDPC_N];
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
reordered[indices[i]] = cw[i];
|
||||||
|
}
|
||||||
|
cw.copy_from_slice(&reordered);
|
||||||
|
message91.copy_from_slice(&cw[..FTX_LDPC_K]);
|
||||||
|
if !check_crc91(message91) {
|
||||||
|
*nhardmin = -*nhardmin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the full per-bit generator matrix.
|
||||||
|
/// Each row `i` contains the 174-bit codeword produced by encoding
|
||||||
|
/// a unit vector with bit `i` set.
|
||||||
|
fn build_generator_matrix() -> Vec<[u8; FTX_LDPC_N]> {
|
||||||
|
let mut gen = vec![[0u8; FTX_LDPC_N]; FTX_LDPC_K];
|
||||||
|
for i in 0..FTX_LDPC_K {
|
||||||
|
let mut msg = [0u8; FTX_LDPC_K];
|
||||||
|
msg[i] = 1;
|
||||||
|
if i < 77 {
|
||||||
|
for j in 77..FTX_LDPC_K {
|
||||||
|
msg[j] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encode174_91_nocrc_bits(&msg, &mut gen[i]);
|
||||||
|
}
|
||||||
|
gen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full iterative BP decoder with OSD refinement.
|
||||||
|
///
|
||||||
|
/// Runs belief-propagation for up to `maxiterations` iterations, saving
|
||||||
|
/// accumulated LLR sums. If BP does not converge, falls back to OSD
|
||||||
|
/// using the saved sums.
|
||||||
|
///
|
||||||
|
/// `llr`: input log-likelihood ratios (174 values).
|
||||||
|
/// `keff`: effective K (must be 91).
|
||||||
|
/// `maxosd`: maximum number of OSD passes (0-3).
|
||||||
|
/// `norder`: OSD depth parameter.
|
||||||
|
/// `apmask`: a priori mask.
|
||||||
|
/// `message91`: output decoded 91-bit message.
|
||||||
|
/// `cw`: output 174-bit codeword.
|
||||||
|
/// `ntype`: output decode type (0=fail, 1=BP, 2=OSD).
|
||||||
|
/// `nharderror`: output number of hard errors.
|
||||||
|
/// `dmin`: output minimum distance.
|
||||||
|
pub fn ft2_decode174_91_osd(
|
||||||
|
llr: &mut [f32; FTX_LDPC_N],
|
||||||
|
keff: usize,
|
||||||
|
maxosd: usize,
|
||||||
|
norder: usize,
|
||||||
|
apmask: &mut [u8; FTX_LDPC_N],
|
||||||
|
message91: &mut [u8; FTX_LDPC_K],
|
||||||
|
cw: &mut [u8; FTX_LDPC_N],
|
||||||
|
ntype: &mut i32,
|
||||||
|
nharderror: &mut i32,
|
||||||
|
dmin: &mut f32,
|
||||||
|
) {
|
||||||
|
*ntype = 0;
|
||||||
|
*nharderror = -1;
|
||||||
|
*dmin = 0.0;
|
||||||
|
|
||||||
|
if keff != FTX_LDPC_K {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxiterations = 30;
|
||||||
|
let maxosd = maxosd.min(3);
|
||||||
|
|
||||||
|
let nosd = if maxosd == 0 { 1 } else { maxosd };
|
||||||
|
|
||||||
|
let mut zsave = vec![[0.0f32; FTX_LDPC_N]; 3];
|
||||||
|
if maxosd == 0 {
|
||||||
|
zsave[0].copy_from_slice(llr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tov = [[0.0f32; 3]; FTX_LDPC_N];
|
||||||
|
let mut toc = [[0.0f32; 7]; FTX_LDPC_M];
|
||||||
|
let mut zsum = [0.0f32; FTX_LDPC_N];
|
||||||
|
let mut hdec = [0u8; FTX_LDPC_N];
|
||||||
|
let mut best_cw = [0u8; FTX_LDPC_N];
|
||||||
|
let mut ncnt = 0;
|
||||||
|
let mut nclast = 0;
|
||||||
|
|
||||||
|
for iter in 0..=maxiterations {
|
||||||
|
// Compute beliefs
|
||||||
|
let mut zn = [0.0f32; FTX_LDPC_N];
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
zn[i] = llr[i];
|
||||||
|
if apmask[i] != 1 {
|
||||||
|
zn[i] += tov[i][0] + tov[i][1] + tov[i][2];
|
||||||
|
}
|
||||||
|
zsum[i] += zn[i];
|
||||||
|
}
|
||||||
|
if iter > 0 && iter <= maxosd {
|
||||||
|
zsave[iter - 1].copy_from_slice(&zsum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard decisions
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
best_cw[i] = if zn[i] > 0.0 { 1 } else { 0 };
|
||||||
|
}
|
||||||
|
let ncheck = ft2_ldpc_check(&best_cw);
|
||||||
|
|
||||||
|
if ncheck == 0 && check_crc91(&best_cw) {
|
||||||
|
message91.copy_from_slice(&best_cw[..FTX_LDPC_K]);
|
||||||
|
cw.copy_from_slice(&best_cw);
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
hdec[i] = if llr[i] >= 0.0 { 1 } else { 0 };
|
||||||
|
}
|
||||||
|
*nharderror = 0;
|
||||||
|
*dmin = 0.0;
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
let diff = hdec[i] ^ best_cw[i];
|
||||||
|
*nharderror += diff as i32;
|
||||||
|
if diff != 0 {
|
||||||
|
*dmin += llr[i].abs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*ntype = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early termination
|
||||||
|
if iter > 0 {
|
||||||
|
let nd = ncheck - nclast;
|
||||||
|
ncnt = if nd < 0 { 0 } else { ncnt + 1 };
|
||||||
|
if ncnt >= 5 && iter >= 10 && ncheck > 15 {
|
||||||
|
*nharderror = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nclast = ncheck;
|
||||||
|
|
||||||
|
// Check-to-variable messages
|
||||||
|
for m in 0..FTX_LDPC_M {
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
for n_idx in 0..num_rows {
|
||||||
|
let n = FTX_LDPC_NM[m][n_idx] as usize - 1;
|
||||||
|
if n >= FTX_LDPC_N {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toc[m][n_idx] = zn[n];
|
||||||
|
for kk in 0..3 {
|
||||||
|
if (FTX_LDPC_MN[n][kk] as usize).wrapping_sub(1) == m {
|
||||||
|
toc[m][n_idx] -= tov[n][kk];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable-to-check messages
|
||||||
|
for m in 0..FTX_LDPC_M {
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
let mut tanhtoc = [0.0f32; 7];
|
||||||
|
for i in 0..num_rows.min(7) {
|
||||||
|
tanhtoc[i] = (-toc[m][i] / 2.0).tanh();
|
||||||
|
}
|
||||||
|
for j in 0..num_rows {
|
||||||
|
let n = FTX_LDPC_NM[m][j] as usize - 1;
|
||||||
|
if n >= FTX_LDPC_N {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut tmn = 1.0f32;
|
||||||
|
for n_idx in 0..num_rows {
|
||||||
|
if FTX_LDPC_NM[m][n_idx] as usize - 1 != n {
|
||||||
|
tmn *= tanhtoc[n_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kk in 0..3 {
|
||||||
|
if (FTX_LDPC_MN[n][kk] as usize).wrapping_sub(1) == m {
|
||||||
|
tov[n][kk] = 2.0 * platanh(-tmn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSD fallback
|
||||||
|
for i in 0..nosd {
|
||||||
|
if i >= zsave.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut osd_llr = [0.0f32; FTX_LDPC_N];
|
||||||
|
osd_llr.copy_from_slice(&zsave[i]);
|
||||||
|
let mut osd_harderror: i32 = -1;
|
||||||
|
let mut osd_dmin: f32 = 0.0;
|
||||||
|
osd174_91(
|
||||||
|
&mut osd_llr,
|
||||||
|
keff,
|
||||||
|
apmask,
|
||||||
|
norder,
|
||||||
|
message91,
|
||||||
|
cw,
|
||||||
|
&mut osd_harderror,
|
||||||
|
&mut osd_dmin,
|
||||||
|
);
|
||||||
|
if osd_harderror > 0 {
|
||||||
|
*nharderror = osd_harderror;
|
||||||
|
*dmin = 0.0;
|
||||||
|
for j in 0..FTX_LDPC_N {
|
||||||
|
hdec[j] = if llr[j] >= 0.0 { 1 } else { 0 };
|
||||||
|
if (hdec[j] ^ cw[j]) != 0 {
|
||||||
|
*dmin += llr[j].abs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*ntype = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*ntype = 0;
|
||||||
|
*nharderror = -1;
|
||||||
|
*dmin = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ldpc_check_all_zeros() {
|
||||||
|
let cw = [0u8; FTX_LDPC_N];
|
||||||
|
assert_eq!(ft2_ldpc_check(&cw), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ldpc_check_single_bit_error() {
|
||||||
|
let mut cw = [0u8; FTX_LDPC_N];
|
||||||
|
cw[0] = 1;
|
||||||
|
assert!(ft2_ldpc_check(&cw) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_atanh_zero() {
|
||||||
|
assert!(fast_atanh(0.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_atanh_approximation() {
|
||||||
|
for &x in &[-0.5f32, -0.25, 0.25, 0.5] {
|
||||||
|
let approx = fast_atanh(x);
|
||||||
|
let exact = x.atanh();
|
||||||
|
assert!(
|
||||||
|
(approx - exact).abs() < 0.05,
|
||||||
|
"fast_atanh({}) = {}, expected ~{}",
|
||||||
|
x,
|
||||||
|
approx,
|
||||||
|
exact
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platanh_small() {
|
||||||
|
let result = platanh(0.5);
|
||||||
|
assert!(result > 0.0);
|
||||||
|
assert!(result.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platanh_large() {
|
||||||
|
let result = platanh(0.9999);
|
||||||
|
assert!(result > 0.0);
|
||||||
|
assert!(result.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platanh_negative() {
|
||||||
|
let pos = platanh(0.5);
|
||||||
|
let neg = platanh(-0.5);
|
||||||
|
assert!((pos + neg).abs() < 1e-6, "platanh should be odd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pack_bits91_basic() {
|
||||||
|
let mut bits = [0u8; FTX_LDPC_K];
|
||||||
|
bits[0] = 1;
|
||||||
|
bits[7] = 1;
|
||||||
|
let mut packed = [0u8; FTX_LDPC_K_BYTES];
|
||||||
|
pack_bits91(&bits, FTX_LDPC_K, &mut packed);
|
||||||
|
assert_eq!(packed[0], 0x81);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_crc91_all_zeros() {
|
||||||
|
// All-zero message likely fails CRC
|
||||||
|
let bits = [0u8; FTX_LDPC_K];
|
||||||
|
// CRC check result depends on specific polynomial behavior
|
||||||
|
let _result = check_crc91(&bits);
|
||||||
|
// Just verify it doesn't panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity8_basic() {
|
||||||
|
assert_eq!(parity8(0x00), 0);
|
||||||
|
assert_eq!(parity8(0x01), 1);
|
||||||
|
assert_eq!(parity8(0x03), 0);
|
||||||
|
assert_eq!(parity8(0xFF), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_hash_basic() {
|
||||||
|
let e2 = [1u8, 0, 1, 0];
|
||||||
|
assert_eq!(pattern_hash(&e2, 4), 0b1010);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_hash_all_zeros() {
|
||||||
|
let e2 = [0u8; 16];
|
||||||
|
assert_eq!(pattern_hash(&e2, 16), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nextpat91_basic() {
|
||||||
|
let k = 5;
|
||||||
|
let mut mi = vec![0u8; k];
|
||||||
|
mi[4] = 1;
|
||||||
|
let mut iflag = 4i32;
|
||||||
|
nextpat91(&mut mi, k, 1, &mut iflag);
|
||||||
|
// After one step, the pattern should shift
|
||||||
|
assert!(iflag >= -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generator_matrix_row_zero() {
|
||||||
|
let gen = build_generator_matrix();
|
||||||
|
// Row 0 should encode unit vector e_0
|
||||||
|
assert_eq!(gen[0][0], 1);
|
||||||
|
// Some parity bits should be non-zero
|
||||||
|
let parity_nonzero = gen[0][FTX_LDPC_K..FTX_LDPC_N].iter().any(|&b| b != 0);
|
||||||
|
assert!(parity_nonzero);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode174_91_nocrc_all_zeros() {
|
||||||
|
let msg = [0u8; FTX_LDPC_K];
|
||||||
|
let mut cw = [0u8; FTX_LDPC_N];
|
||||||
|
encode174_91_nocrc_bits(&msg, &mut cw);
|
||||||
|
for &b in &cw {
|
||||||
|
assert_eq!(b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osd_box_basic() {
|
||||||
|
let mut b = OsdBox::new(4).unwrap();
|
||||||
|
let pattern = [1u8, 0, 1, 0];
|
||||||
|
b.boxit(&pattern, 4, 5, 3);
|
||||||
|
let (i1, i2) = b.fetchit(&pattern, 4);
|
||||||
|
assert_eq!(i1, 5);
|
||||||
|
assert_eq!(i2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osd_box_empty_fetch() {
|
||||||
|
let mut b = OsdBox::new(4).unwrap();
|
||||||
|
let pattern = [0u8; 4];
|
||||||
|
let (i1, i2) = b.fetchit(&pattern, 4);
|
||||||
|
assert_eq!(i1, -1);
|
||||||
|
assert_eq!(i2, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! 2D sync scoring with complex Costas reference waveforms.
|
||||||
|
//!
|
||||||
|
//! Prepares reference sync waveforms from the FT4 Costas pattern and frequency
|
||||||
|
//! tweak phasors, then correlates downsampled complex symbols against the
|
||||||
|
//! reference across time and frequency offsets.
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
|
||||||
|
use crate::constants::FT4_COSTAS_PATTERN;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Precomputed sync and frequency-tweak waveforms.
|
||||||
|
pub struct SyncWaveforms {
|
||||||
|
/// Complex reference waveforms for each of the 4 Costas sync groups.
|
||||||
|
/// Each group has 64 samples (4 tones * 16 samples per half-symbol).
|
||||||
|
pub sync_wave: [[Complex32; 64]; 4],
|
||||||
|
/// Frequency tweak phasors for each integer frequency offset.
|
||||||
|
/// Index by `idf - FT2_SYNC_TWEAK_MIN`.
|
||||||
|
pub tweak_wave: [[Complex32; 64]; NUM_TWEAKS],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare complex reference waveforms for sync scoring.
|
||||||
|
///
|
||||||
|
/// For each of the 4 Costas sync groups, we generate the expected complex
|
||||||
|
/// signal using continuous-phase tone generation at the downsampled rate.
|
||||||
|
/// We also generate frequency-tweak phasors for fine frequency searching.
|
||||||
|
pub fn prepare_sync_waveforms() -> SyncWaveforms {
|
||||||
|
let fs_down = 12000.0f32 / FT2_NDOWN as f32;
|
||||||
|
let nss = FT2_SYMBOL_PERIOD_F * fs_down;
|
||||||
|
|
||||||
|
let mut sync_wave = [[Complex32::new(0.0, 0.0); 64]; 4];
|
||||||
|
let mut tweak_wave = [[Complex32::new(0.0, 0.0); 64]; NUM_TWEAKS];
|
||||||
|
|
||||||
|
// Build sync reference waveforms (continuous phase across tones)
|
||||||
|
for group in 0..4 {
|
||||||
|
let mut idx = 0usize;
|
||||||
|
let mut phase = 0.0f32;
|
||||||
|
for tone_idx in 0..4 {
|
||||||
|
let tone = FT4_COSTAS_PATTERN[group][tone_idx] as f32;
|
||||||
|
let dphase = 4.0 * std::f32::consts::PI * tone / nss;
|
||||||
|
let half_nss = (nss / 2.0) as usize;
|
||||||
|
for _step in 0..half_nss {
|
||||||
|
if idx >= 64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sync_wave[group][idx] = Complex32::new(phase.cos(), phase.sin());
|
||||||
|
phase = (phase + dphase) % (2.0 * std::f32::consts::PI);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build frequency tweak phasors
|
||||||
|
for idf in FT2_SYNC_TWEAK_MIN..=FT2_SYNC_TWEAK_MAX {
|
||||||
|
let tw_idx = (idf - FT2_SYNC_TWEAK_MIN) as usize;
|
||||||
|
for n in 0..64 {
|
||||||
|
let phase = 4.0 * std::f32::consts::PI * idf as f32 * n as f32 / fs_down;
|
||||||
|
tweak_wave[tw_idx][n] = Complex32::new(phase.cos(), phase.sin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncWaveforms {
|
||||||
|
sync_wave,
|
||||||
|
tweak_wave,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the 2D sync score for a given time offset and frequency tweak.
|
||||||
|
///
|
||||||
|
/// Correlates the downsampled complex samples against the four Costas sync
|
||||||
|
/// group reference waveforms, applying the specified frequency tweak.
|
||||||
|
///
|
||||||
|
/// `samples`: downsampled complex baseband signal.
|
||||||
|
/// `start`: sample offset for the start of the frame.
|
||||||
|
/// `idf`: integer frequency tweak (Hz).
|
||||||
|
/// `waveforms`: precomputed reference waveforms.
|
||||||
|
///
|
||||||
|
/// Returns the sync correlation score (higher is better).
|
||||||
|
pub fn sync2d_score(
|
||||||
|
samples: &[Complex32],
|
||||||
|
start: i32,
|
||||||
|
idf: i32,
|
||||||
|
waveforms: &SyncWaveforms,
|
||||||
|
) -> f32 {
|
||||||
|
let nss = FT2_NSS as i32;
|
||||||
|
let n_samples = samples.len() as i32;
|
||||||
|
|
||||||
|
// The four sync groups are at symbol positions 0, 33, 66, 99 within the frame
|
||||||
|
let positions = [
|
||||||
|
start,
|
||||||
|
start + 33 * nss,
|
||||||
|
start + 66 * nss,
|
||||||
|
start + 99 * nss,
|
||||||
|
];
|
||||||
|
|
||||||
|
let tw_idx = (idf - FT2_SYNC_TWEAK_MIN) as usize;
|
||||||
|
if tw_idx >= waveforms.tweak_wave.len() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let tweak = &waveforms.tweak_wave[tw_idx];
|
||||||
|
|
||||||
|
let mut score = 0.0f32;
|
||||||
|
|
||||||
|
for group in 0..4 {
|
||||||
|
let pos = positions[group];
|
||||||
|
let mut sum = Complex32::new(0.0, 0.0);
|
||||||
|
let mut usable = 0;
|
||||||
|
|
||||||
|
for i in 0..64 {
|
||||||
|
let sample_idx = pos + 2 * i as i32;
|
||||||
|
if sample_idx < 0 || sample_idx >= n_samples {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Correlate: multiply received sample by conjugate of
|
||||||
|
// (sync_reference * tweak_phasor)
|
||||||
|
let reference = waveforms.sync_wave[group][i] * tweak[i];
|
||||||
|
sum += samples[sample_idx as usize] * reference.conj();
|
||||||
|
usable += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if usable > 16 {
|
||||||
|
score += sum.norm() / (2.0 * FT2_NSS as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refine frequency tweak around a coarse estimate.
|
||||||
|
///
|
||||||
|
/// Searches `idf` values from `center_idf - range` to `center_idf + range`
|
||||||
|
/// and `start` values from `center_start - start_range` to
|
||||||
|
/// `center_start + start_range`, returning the best score and parameters.
|
||||||
|
pub fn refine_sync(
|
||||||
|
samples: &[Complex32],
|
||||||
|
center_start: i32,
|
||||||
|
center_idf: i32,
|
||||||
|
start_range: i32,
|
||||||
|
idf_range: i32,
|
||||||
|
waveforms: &SyncWaveforms,
|
||||||
|
) -> (f32, i32, i32) {
|
||||||
|
let mut best_score: f32 = -1.0;
|
||||||
|
let mut best_start = center_start;
|
||||||
|
let mut best_idf = center_idf;
|
||||||
|
|
||||||
|
for idf in (center_idf - idf_range)..=(center_idf + idf_range) {
|
||||||
|
if idf < FT2_SYNC_TWEAK_MIN || idf > FT2_SYNC_TWEAK_MAX {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for start in (center_start - start_range)..=(center_start + start_range) {
|
||||||
|
let score = sync2d_score(samples, start, idf, waveforms);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_start = start;
|
||||||
|
best_idf = idf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(best_score, best_start, best_idf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waveform_preparation() {
|
||||||
|
let wf = prepare_sync_waveforms();
|
||||||
|
// Sync waveforms should have unit magnitude at each sample
|
||||||
|
for group in 0..4 {
|
||||||
|
for i in 0..64 {
|
||||||
|
let mag = wf.sync_wave[group][i].norm();
|
||||||
|
assert!(
|
||||||
|
(mag - 1.0).abs() < 1e-4,
|
||||||
|
"Sync wave group {} sample {} has magnitude {}, expected ~1.0",
|
||||||
|
group,
|
||||||
|
i,
|
||||||
|
mag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tweak_waveform_unit_magnitude() {
|
||||||
|
let wf = prepare_sync_waveforms();
|
||||||
|
for tw in &wf.tweak_wave {
|
||||||
|
for &s in tw {
|
||||||
|
let mag = s.norm();
|
||||||
|
assert!(
|
||||||
|
(mag - 1.0).abs() < 1e-4,
|
||||||
|
"Tweak wave magnitude {} should be ~1.0",
|
||||||
|
mag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_score_zero_signal() {
|
||||||
|
let wf = prepare_sync_waveforms();
|
||||||
|
let samples = vec![Complex32::new(0.0, 0.0); 5000];
|
||||||
|
let score = sync2d_score(&samples, 0, 0, &wf);
|
||||||
|
assert!(
|
||||||
|
score.abs() < 1e-6,
|
||||||
|
"Score of zero signal should be ~0, got {}",
|
||||||
|
score
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_score_out_of_range_idf() {
|
||||||
|
let wf = prepare_sync_waveforms();
|
||||||
|
let samples = vec![Complex32::new(1.0, 0.0); 5000];
|
||||||
|
let score = sync2d_score(&samples, 0, FT2_SYNC_TWEAK_MAX + 100, &wf);
|
||||||
|
assert_eq!(score, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refine_improves_on_coarse() {
|
||||||
|
let wf = prepare_sync_waveforms();
|
||||||
|
// Create a simple signal where the coarse and fine searches should
|
||||||
|
// produce non-negative scores
|
||||||
|
let samples = vec![Complex32::new(0.1, 0.05); 5000];
|
||||||
|
let (score, _start, _idf) = refine_sync(&samples, 100, 0, 5, 4, &wf);
|
||||||
|
assert!(score >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn num_tweaks_matches_range() {
|
||||||
|
assert_eq!(
|
||||||
|
NUM_TWEAKS,
|
||||||
|
(FT2_SYNC_TWEAK_MAX - FT2_SYNC_TWEAK_MIN + 1) as usize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Pure Rust LDPC decoder for FTx protocols.
|
||||||
|
//!
|
||||||
|
//! This is a port of the sum-product and belief-propagation LDPC decoders
|
||||||
|
//! from ft8_lib's `ldpc.c`. Given a 174-bit codeword as an array of
|
||||||
|
//! 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};
|
||||||
|
|
||||||
|
/// Fast rational approximation of `tanh(x)`, clamped at +/-4.97.
|
||||||
|
fn fast_tanh(x: f32) -> f32 {
|
||||||
|
if x < -4.97f32 {
|
||||||
|
return -1.0f32;
|
||||||
|
}
|
||||||
|
if x > 4.97f32 {
|
||||||
|
return 1.0f32;
|
||||||
|
}
|
||||||
|
let x2 = x * x;
|
||||||
|
let a = x * (945.0f32 + x2 * (105.0f32 + x2));
|
||||||
|
let b = 945.0f32 + x2 * (420.0f32 + x2 * 15.0f32);
|
||||||
|
a / b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast rational approximation of `atanh(x)`.
|
||||||
|
fn fast_atanh(x: f32) -> f32 {
|
||||||
|
let x2 = x * x;
|
||||||
|
let a = x * (945.0f32 + x2 * (-735.0f32 + x2 * 64.0f32));
|
||||||
|
let b = 945.0f32 + x2 * (-1050.0f32 + x2 * 225.0f32);
|
||||||
|
a / b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count the number of LDPC parity errors in a 174-bit codeword.
|
||||||
|
///
|
||||||
|
/// Returns 0 if all parity checks pass (valid codeword).
|
||||||
|
pub fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
|
||||||
|
let mut errors = 0i32;
|
||||||
|
for m in 0..FTX_LDPC_M {
|
||||||
|
let mut x: u8 = 0;
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
for i in 0..num_rows {
|
||||||
|
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
||||||
|
}
|
||||||
|
if x != 0 {
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum-product LDPC decoder.
|
||||||
|
///
|
||||||
|
/// `codeword` contains 174 log-likelihood ratios (modified in place during
|
||||||
|
/// decoding). `plain` receives the decoded 174-bit hard decisions (0 or 1).
|
||||||
|
/// `max_iters` controls how many iterations to attempt.
|
||||||
|
///
|
||||||
|
/// Returns the number of remaining parity errors (0 = success).
|
||||||
|
pub fn ldpc_decode(
|
||||||
|
codeword: &mut [f32; FTX_LDPC_N],
|
||||||
|
max_iters: usize,
|
||||||
|
plain: &mut [u8; FTX_LDPC_N],
|
||||||
|
) -> i32 {
|
||||||
|
// Allocate m[][] and e[][] on the heap (~60 kB each) to avoid stack overflow.
|
||||||
|
let mut m_matrix: Vec<Vec<f32>> =
|
||||||
|
vec![vec![0.0f32; FTX_LDPC_N]; FTX_LDPC_M];
|
||||||
|
let mut e_matrix: Vec<Vec<f32>> =
|
||||||
|
vec![vec![0.0f32; FTX_LDPC_N]; FTX_LDPC_M];
|
||||||
|
|
||||||
|
// Initialize m[][] with the channel LLRs.
|
||||||
|
for j in 0..FTX_LDPC_M {
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
m_matrix[j][i] = codeword[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut min_errors = FTX_LDPC_M as i32;
|
||||||
|
|
||||||
|
for _iter in 0..max_iters {
|
||||||
|
// Update e[][] from m[][]
|
||||||
|
for j in 0..FTX_LDPC_M {
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||||
|
for ii1 in 0..num_rows {
|
||||||
|
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
||||||
|
let mut a = 1.0f32;
|
||||||
|
for ii2 in 0..num_rows {
|
||||||
|
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
||||||
|
if i2 != i1 {
|
||||||
|
a *= fast_tanh(-m_matrix[j][i2] / 2.0f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e_matrix[j][i1] = -2.0f32 * fast_atanh(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard decisions
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
let mut l = codeword[i];
|
||||||
|
for j in 0..3 {
|
||||||
|
l += e_matrix[FTX_LDPC_MN[i][j] as usize - 1][i];
|
||||||
|
}
|
||||||
|
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = ldpc_check(plain);
|
||||||
|
if errors < min_errors {
|
||||||
|
min_errors = errors;
|
||||||
|
if errors == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update m[][] from e[][]
|
||||||
|
for i in 0..FTX_LDPC_N {
|
||||||
|
for ji1 in 0..3 {
|
||||||
|
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
||||||
|
let mut l = codeword[i];
|
||||||
|
for ji2 in 0..3 {
|
||||||
|
if ji1 != ji2 {
|
||||||
|
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
||||||
|
l += e_matrix[j2][i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_matrix[j1][i] = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
min_errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Belief-propagation LDPC decoder.
|
||||||
|
///
|
||||||
|
/// `codeword` contains 174 log-likelihood ratios. `plain` receives the
|
||||||
|
/// decoded 174-bit hard decisions (0 or 1). `max_iters` controls how many
|
||||||
|
/// iterations to attempt.
|
||||||
|
///
|
||||||
|
/// Returns the number of remaining parity errors (0 = success).
|
||||||
|
pub fn bp_decode(
|
||||||
|
codeword: &[f32; FTX_LDPC_N],
|
||||||
|
max_iters: usize,
|
||||||
|
plain: &mut [u8; FTX_LDPC_N],
|
||||||
|
) -> i32 {
|
||||||
|
let mut tov = [[0.0f32; 3]; FTX_LDPC_N];
|
||||||
|
let mut toc = [[0.0f32; 7]; FTX_LDPC_M];
|
||||||
|
|
||||||
|
let mut min_errors = FTX_LDPC_M as i32;
|
||||||
|
|
||||||
|
for _iter in 0..max_iters {
|
||||||
|
// Hard decision guess (tov=0 in iter 0)
|
||||||
|
let mut plain_sum = 0u32;
|
||||||
|
for n in 0..FTX_LDPC_N {
|
||||||
|
let sum = codeword[n] + tov[n][0] + tov[n][1] + tov[n][2];
|
||||||
|
plain[n] = if sum > 0.0 { 1 } else { 0 };
|
||||||
|
plain_sum += plain[n] as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if plain_sum == 0 {
|
||||||
|
// Message converged to all-zeros, which is prohibited.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = ldpc_check(plain);
|
||||||
|
if errors < min_errors {
|
||||||
|
min_errors = errors;
|
||||||
|
if errors == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send messages from bits to check nodes
|
||||||
|
for m in 0..FTX_LDPC_M {
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
for n_idx in 0..num_rows {
|
||||||
|
let n = FTX_LDPC_NM[m][n_idx] as usize - 1;
|
||||||
|
let mut tnm = codeword[n];
|
||||||
|
for m_idx in 0..3 {
|
||||||
|
if (FTX_LDPC_MN[n][m_idx] as usize - 1) != m {
|
||||||
|
tnm += tov[n][m_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toc[m][n_idx] = fast_tanh(-tnm / 2.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send messages from check nodes to variable nodes
|
||||||
|
for n in 0..FTX_LDPC_N {
|
||||||
|
for m_idx in 0..3 {
|
||||||
|
let m = FTX_LDPC_MN[n][m_idx] as usize - 1;
|
||||||
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
|
let mut tmn = 1.0f32;
|
||||||
|
for n_idx in 0..num_rows {
|
||||||
|
if (FTX_LDPC_NM[m][n_idx] as usize - 1) != n {
|
||||||
|
tmn *= toc[m][n_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tov[n][m_idx] = -2.0 * fast_atanh(tmn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
min_errors
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fast_tanh_clamp() {
|
||||||
|
assert_eq!(fast_tanh(-5.0), -1.0);
|
||||||
|
assert_eq!(fast_tanh(5.0), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fast_tanh_zero() {
|
||||||
|
assert!((fast_tanh(0.0)).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fast_tanh_approximation() {
|
||||||
|
for &x in &[-3.0f32, -1.0, -0.5, 0.5, 1.0, 3.0] {
|
||||||
|
let approx = fast_tanh(x);
|
||||||
|
let exact = x.tanh();
|
||||||
|
assert!(
|
||||||
|
(approx - exact).abs() < 0.01,
|
||||||
|
"fast_tanh({}) = {}, expected ~{}",
|
||||||
|
x,
|
||||||
|
approx,
|
||||||
|
exact
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fast_atanh_zero() {
|
||||||
|
assert!((fast_atanh(0.0)).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fast_atanh_approximation() {
|
||||||
|
for &x in &[-0.5f32, -0.25, 0.25, 0.5] {
|
||||||
|
let approx = fast_atanh(x);
|
||||||
|
let exact = x.atanh();
|
||||||
|
assert!(
|
||||||
|
(approx - exact).abs() < 0.05,
|
||||||
|
"fast_atanh({}) = {}, expected ~{}",
|
||||||
|
x,
|
||||||
|
approx,
|
||||||
|
exact
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ldpc_check_all_zeros() {
|
||||||
|
// All-zero codeword should pass all parity checks.
|
||||||
|
let codeword = [0u8; FTX_LDPC_N];
|
||||||
|
assert_eq!(ldpc_check(&codeword), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ldpc_check_single_bit_error() {
|
||||||
|
// Flipping one bit should cause parity errors.
|
||||||
|
let mut codeword = [0u8; FTX_LDPC_N];
|
||||||
|
codeword[0] = 1;
|
||||||
|
assert!(ldpc_check(&codeword) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ldpc_decode_all_zeros() {
|
||||||
|
// Negative LLRs → hard decision 0 for all bits.
|
||||||
|
// The all-zeros codeword satisfies all LDPC parity checks.
|
||||||
|
let mut codeword = [-10.0f32; FTX_LDPC_N];
|
||||||
|
let mut plain = [0u8; FTX_LDPC_N];
|
||||||
|
let errors = ldpc_decode(&mut codeword, 20, &mut plain);
|
||||||
|
assert_eq!(errors, 0);
|
||||||
|
assert!(plain.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bp_decode_all_ones() {
|
||||||
|
// Positive LLRs → hard decision 1 for all bits.
|
||||||
|
// All-ones is not a valid codeword, so bp_decode should report errors.
|
||||||
|
let codeword = [10.0f32; FTX_LDPC_N];
|
||||||
|
let mut plain = [0u8; FTX_LDPC_N];
|
||||||
|
let errors = bp_decode(&codeword, 20, &mut plain);
|
||||||
|
assert!(errors > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod crc;
|
||||||
|
pub mod text;
|
||||||
|
pub mod ldpc;
|
||||||
|
pub mod encode;
|
||||||
|
pub mod callsign_hash;
|
||||||
|
pub mod message;
|
||||||
|
pub mod monitor;
|
||||||
|
pub mod decode;
|
||||||
|
pub mod ft2;
|
||||||
|
mod decoder;
|
||||||
|
|
||||||
|
pub use decoder::{Ft8Decoder, Ft8DecodeResult};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Windowed FFT waterfall/spectrogram engine for FTx decoding.
|
||||||
|
//!
|
||||||
|
//! Replaces `monitor.c` from ft8_lib, using `realfft`/`rustfft` instead of KissFFT.
|
||||||
|
|
||||||
|
use num_complex::Complex32;
|
||||||
|
use realfft::RealFftPlanner;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
|
use crate::protocol::FtxProtocol;
|
||||||
|
|
||||||
|
/// Waterfall element storing magnitude (dB) and phase (radians).
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct WfElem {
|
||||||
|
pub mag: f32,
|
||||||
|
pub phase: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WfElem {
|
||||||
|
pub fn mag_int(self) -> i32 {
|
||||||
|
(2.0 * (self.mag + 120.0)) as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waterfall data collected during a message slot.
|
||||||
|
pub struct Waterfall {
|
||||||
|
pub max_blocks: usize,
|
||||||
|
pub num_blocks: usize,
|
||||||
|
pub num_bins: usize,
|
||||||
|
pub time_osr: usize,
|
||||||
|
pub freq_osr: usize,
|
||||||
|
pub mag: Vec<WfElem>,
|
||||||
|
pub block_stride: usize,
|
||||||
|
pub protocol: FtxProtocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Waterfall {
|
||||||
|
pub fn new(max_blocks: usize, num_bins: usize, time_osr: usize, freq_osr: usize, protocol: FtxProtocol) -> Self {
|
||||||
|
let block_stride = time_osr * freq_osr * num_bins;
|
||||||
|
let mag = vec![WfElem::default(); max_blocks * block_stride];
|
||||||
|
Self {
|
||||||
|
max_blocks,
|
||||||
|
num_blocks: 0,
|
||||||
|
num_bins,
|
||||||
|
time_osr,
|
||||||
|
freq_osr,
|
||||||
|
mag,
|
||||||
|
block_stride,
|
||||||
|
protocol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.num_blocks = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor configuration.
|
||||||
|
pub struct MonitorConfig {
|
||||||
|
pub f_min: f32,
|
||||||
|
pub f_max: f32,
|
||||||
|
pub sample_rate: i32,
|
||||||
|
pub time_osr: i32,
|
||||||
|
pub freq_osr: i32,
|
||||||
|
pub protocol: FtxProtocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FTx monitor that manages DSP processing and prepares waterfall data.
|
||||||
|
pub struct Monitor {
|
||||||
|
pub symbol_period: f32,
|
||||||
|
pub min_bin: usize,
|
||||||
|
pub max_bin: usize,
|
||||||
|
pub block_size: usize,
|
||||||
|
pub subblock_size: usize,
|
||||||
|
pub nfft: usize,
|
||||||
|
pub fft_norm: f32,
|
||||||
|
window: Vec<f32>,
|
||||||
|
last_frame: Vec<f32>,
|
||||||
|
pub wf: Waterfall,
|
||||||
|
pub max_mag: f32,
|
||||||
|
// FFT planners/scratch
|
||||||
|
fft_scratch: Vec<Complex32>,
|
||||||
|
fft_output: Vec<Complex32>,
|
||||||
|
fft_input: Vec<f32>,
|
||||||
|
real_fft: std::sync::Arc<dyn realfft::RealToComplex<f32>>,
|
||||||
|
// iFFT for resynthesis
|
||||||
|
nifft: usize,
|
||||||
|
ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||||
|
ifft_scratch: Vec<Complex32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hann_i(i: usize, n: usize) -> f32 {
|
||||||
|
let x = (std::f32::consts::PI * i as f32 / n as f32).sin();
|
||||||
|
x * x
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Monitor {
|
||||||
|
pub fn new(cfg: &MonitorConfig) -> Self {
|
||||||
|
let symbol_period = cfg.protocol.symbol_period();
|
||||||
|
let slot_time = cfg.protocol.slot_time();
|
||||||
|
|
||||||
|
let block_size = (cfg.sample_rate as f32 * symbol_period) as usize;
|
||||||
|
let subblock_size = block_size / cfg.time_osr as usize;
|
||||||
|
let nfft = block_size * cfg.freq_osr as usize;
|
||||||
|
let fft_norm = 2.0 / nfft as f32;
|
||||||
|
|
||||||
|
let window: Vec<f32> = (0..nfft).map(|i| fft_norm * hann_i(i, nfft)).collect();
|
||||||
|
let last_frame = vec![0.0f32; nfft];
|
||||||
|
|
||||||
|
let min_bin = (cfg.f_min * symbol_period) as usize;
|
||||||
|
let max_bin = (cfg.f_max * symbol_period) as usize + 1;
|
||||||
|
let num_bins = max_bin - min_bin;
|
||||||
|
let max_blocks = (slot_time / symbol_period) as usize;
|
||||||
|
|
||||||
|
let wf = Waterfall::new(max_blocks, num_bins, cfg.time_osr as usize, cfg.freq_osr as usize, cfg.protocol);
|
||||||
|
|
||||||
|
let mut real_planner = RealFftPlanner::<f32>::new();
|
||||||
|
let real_fft = real_planner.plan_fft_forward(nfft);
|
||||||
|
let fft_scratch = real_fft.make_scratch_vec();
|
||||||
|
let fft_output = real_fft.make_output_vec();
|
||||||
|
let fft_input = real_fft.make_input_vec();
|
||||||
|
|
||||||
|
let nifft = 64;
|
||||||
|
let mut fft_planner = FftPlanner::<f32>::new();
|
||||||
|
let ifft = fft_planner.plan_fft_inverse(nifft);
|
||||||
|
let ifft_scratch = vec![Complex32::new(0.0, 0.0); ifft.get_inplace_scratch_len()];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
symbol_period,
|
||||||
|
min_bin,
|
||||||
|
max_bin,
|
||||||
|
block_size,
|
||||||
|
subblock_size,
|
||||||
|
nfft,
|
||||||
|
fft_norm,
|
||||||
|
window,
|
||||||
|
last_frame,
|
||||||
|
wf,
|
||||||
|
max_mag: -120.0,
|
||||||
|
fft_scratch,
|
||||||
|
fft_output,
|
||||||
|
fft_input,
|
||||||
|
real_fft,
|
||||||
|
nifft,
|
||||||
|
ifft,
|
||||||
|
ifft_scratch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.wf.reset();
|
||||||
|
self.max_mag = -120.0;
|
||||||
|
self.last_frame.fill(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one block of audio samples and update the waterfall.
|
||||||
|
pub fn process(&mut self, frame: &[f32]) {
|
||||||
|
if self.wf.num_blocks >= self.wf.max_blocks {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = self.wf.num_blocks * self.wf.block_stride;
|
||||||
|
let mut frame_pos = 0;
|
||||||
|
|
||||||
|
for _time_sub in 0..self.wf.time_osr {
|
||||||
|
// Shift new data into analysis frame
|
||||||
|
let shift = self.nfft - self.subblock_size;
|
||||||
|
self.last_frame.copy_within(self.subblock_size..self.nfft, 0);
|
||||||
|
for pos in shift..self.nfft {
|
||||||
|
self.last_frame[pos] = if frame_pos < frame.len() {
|
||||||
|
frame[frame_pos]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
frame_pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windowed FFT
|
||||||
|
for pos in 0..self.nfft {
|
||||||
|
self.fft_input[pos] = self.window[pos] * self.last_frame[pos];
|
||||||
|
}
|
||||||
|
self.real_fft
|
||||||
|
.process_with_scratch(&mut self.fft_input, &mut self.fft_output, &mut self.fft_scratch)
|
||||||
|
.expect("FFT process failed");
|
||||||
|
|
||||||
|
// Extract magnitude and phase for each frequency sub-bin
|
||||||
|
for freq_sub in 0..self.wf.freq_osr {
|
||||||
|
for bin in self.min_bin..self.max_bin {
|
||||||
|
let src_bin = bin * self.wf.freq_osr + freq_sub;
|
||||||
|
if src_bin < self.fft_output.len() {
|
||||||
|
let c = self.fft_output[src_bin];
|
||||||
|
let mag2 = c.re * c.re + c.im * c.im;
|
||||||
|
let db = 10.0 * (1e-12_f32 + mag2).log10();
|
||||||
|
let phase = c.im.atan2(c.re);
|
||||||
|
|
||||||
|
if offset < self.wf.mag.len() {
|
||||||
|
self.wf.mag[offset] = WfElem { mag: db, phase };
|
||||||
|
}
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if db > self.max_mag {
|
||||||
|
self.max_mag = db;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if offset < self.wf.mag.len() {
|
||||||
|
self.wf.mag[offset] = WfElem { mag: -120.0, phase: 0.0 };
|
||||||
|
}
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.wf.num_blocks += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_block_size_ft8() {
|
||||||
|
let cfg = MonitorConfig {
|
||||||
|
f_min: 200.0,
|
||||||
|
f_max: 3000.0,
|
||||||
|
sample_rate: 12000,
|
||||||
|
time_osr: 2,
|
||||||
|
freq_osr: 2,
|
||||||
|
protocol: FtxProtocol::Ft8,
|
||||||
|
};
|
||||||
|
let mon = Monitor::new(&cfg);
|
||||||
|
assert_eq!(mon.block_size, 1920); // 12000 * 0.160
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_block_size_ft4() {
|
||||||
|
let cfg = MonitorConfig {
|
||||||
|
f_min: 200.0,
|
||||||
|
f_max: 3000.0,
|
||||||
|
sample_rate: 12000,
|
||||||
|
time_osr: 2,
|
||||||
|
freq_osr: 2,
|
||||||
|
protocol: FtxProtocol::Ft4,
|
||||||
|
};
|
||||||
|
let mon = Monitor::new(&cfg);
|
||||||
|
assert_eq!(mon.block_size, 576); // 12000 * 0.048
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_block_size_ft2() {
|
||||||
|
let cfg = MonitorConfig {
|
||||||
|
f_min: 200.0,
|
||||||
|
f_max: 5000.0,
|
||||||
|
sample_rate: 12000,
|
||||||
|
time_osr: 8,
|
||||||
|
freq_osr: 4,
|
||||||
|
protocol: FtxProtocol::Ft2,
|
||||||
|
};
|
||||||
|
let mon = Monitor::new(&cfg);
|
||||||
|
assert_eq!(mon.block_size, 288); // 12000 * 0.024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
/// FTx protocol variants.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FtxProtocol {
|
||||||
|
Ft4,
|
||||||
|
Ft8,
|
||||||
|
Ft2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FtxProtocol {
|
||||||
|
/// Symbol period in seconds.
|
||||||
|
pub fn symbol_period(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Ft8 => FT8_SYMBOL_PERIOD,
|
||||||
|
Self::Ft4 => FT4_SYMBOL_PERIOD,
|
||||||
|
Self::Ft2 => FT2_SYMBOL_PERIOD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slot time in seconds.
|
||||||
|
pub fn slot_time(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Ft8 => FT8_SLOT_TIME,
|
||||||
|
Self::Ft4 => FT4_SLOT_TIME,
|
||||||
|
Self::Ft2 => FT2_SLOT_TIME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this protocol uses FT4-style channel layout (FT4 and FT2).
|
||||||
|
pub fn uses_ft4_layout(self) -> bool {
|
||||||
|
matches!(self, Self::Ft4 | Self::Ft2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of data symbols.
|
||||||
|
pub fn nd(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { FT4_ND } else { FT8_ND }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total channel symbols.
|
||||||
|
pub fn nn(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { FT4_NN } else { FT8_NN }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of each sync group.
|
||||||
|
pub fn sync_length(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { FT4_LENGTH_SYNC } else { FT8_LENGTH_SYNC }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of sync groups.
|
||||||
|
pub fn num_sync(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { FT4_NUM_SYNC } else { FT8_NUM_SYNC }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset between sync groups.
|
||||||
|
pub fn sync_offset(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { FT4_SYNC_OFFSET } else { FT8_SYNC_OFFSET }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of FSK tones.
|
||||||
|
pub fn num_tones(self) -> usize {
|
||||||
|
if self.uses_ft4_layout() { 4 } else { 8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FT8 timing
|
||||||
|
pub const FT8_SYMBOL_PERIOD: f32 = 0.160;
|
||||||
|
pub const FT8_SLOT_TIME: f32 = 15.0;
|
||||||
|
|
||||||
|
// FT4 timing
|
||||||
|
pub const FT4_SYMBOL_PERIOD: f32 = 0.048;
|
||||||
|
pub const FT4_SLOT_TIME: f32 = 7.5;
|
||||||
|
|
||||||
|
// FT2 timing
|
||||||
|
pub const FT2_SYMBOL_PERIOD: f32 = 0.024;
|
||||||
|
pub const FT2_SLOT_TIME: f32 = 3.75;
|
||||||
|
|
||||||
|
// FT8 symbol counts
|
||||||
|
pub const FT8_ND: usize = 58;
|
||||||
|
pub const FT8_NN: usize = 79;
|
||||||
|
pub const FT8_LENGTH_SYNC: usize = 7;
|
||||||
|
pub const FT8_NUM_SYNC: usize = 3;
|
||||||
|
pub const FT8_SYNC_OFFSET: usize = 36;
|
||||||
|
|
||||||
|
// FT4 symbol counts
|
||||||
|
pub const FT4_ND: usize = 87;
|
||||||
|
pub const FT4_NR: usize = 2;
|
||||||
|
pub const FT4_NN: usize = 105;
|
||||||
|
pub const FT4_LENGTH_SYNC: usize = 4;
|
||||||
|
pub const FT4_NUM_SYNC: usize = 4;
|
||||||
|
pub const FT4_SYNC_OFFSET: usize = 33;
|
||||||
|
|
||||||
|
// FT2 reuses FT4 layout
|
||||||
|
pub const FT2_ND: usize = FT4_ND;
|
||||||
|
pub const FT2_NR: usize = FT4_NR;
|
||||||
|
pub const FT2_NN: usize = FT4_NN;
|
||||||
|
pub const FT2_LENGTH_SYNC: usize = FT4_LENGTH_SYNC;
|
||||||
|
pub const FT2_NUM_SYNC: usize = FT4_NUM_SYNC;
|
||||||
|
pub const FT2_SYNC_OFFSET: usize = FT4_SYNC_OFFSET;
|
||||||
|
|
||||||
|
// LDPC parameters
|
||||||
|
pub const FTX_LDPC_N: usize = 174;
|
||||||
|
pub const FTX_LDPC_K: usize = 91;
|
||||||
|
pub const FTX_LDPC_M: usize = 83;
|
||||||
|
pub const FTX_LDPC_N_BYTES: usize = (FTX_LDPC_N + 7) / 8;
|
||||||
|
pub const FTX_LDPC_K_BYTES: usize = (FTX_LDPC_K + 7) / 8;
|
||||||
|
|
||||||
|
// CRC parameters
|
||||||
|
pub const FT8_CRC_POLYNOMIAL: u16 = 0x2757;
|
||||||
|
pub const FT8_CRC_WIDTH: u32 = 14;
|
||||||
|
|
||||||
|
// Message parameters
|
||||||
|
pub const FTX_PAYLOAD_LENGTH_BYTES: usize = 10;
|
||||||
|
pub const FTX_MAX_MESSAGE_LENGTH: usize = 35;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_timing() {
|
||||||
|
assert!((FtxProtocol::Ft8.symbol_period() - 0.160).abs() < 1e-6);
|
||||||
|
assert!((FtxProtocol::Ft4.symbol_period() - 0.048).abs() < 1e-6);
|
||||||
|
assert!((FtxProtocol::Ft2.symbol_period() - 0.024).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ft4_layout() {
|
||||||
|
assert!(FtxProtocol::Ft4.uses_ft4_layout());
|
||||||
|
assert!(FtxProtocol::Ft2.uses_ft4_layout());
|
||||||
|
assert!(!FtxProtocol::Ft8.uses_ft4_layout());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn symbol_counts() {
|
||||||
|
assert_eq!(FtxProtocol::Ft8.nn(), 79);
|
||||||
|
assert_eq!(FtxProtocol::Ft4.nn(), 105);
|
||||||
|
assert_eq!(FtxProtocol::Ft2.nn(), 105);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Character table lookup and string utility functions for FTx message
|
||||||
|
//! encoding/decoding.
|
||||||
|
//!
|
||||||
|
//! This is a pure Rust port of `ft8_lib/ft8/text.c`.
|
||||||
|
|
||||||
|
/// Character table variants used for encoding and decoding FTx messages.
|
||||||
|
///
|
||||||
|
/// Each variant defines a different subset of allowed characters.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CharTable {
|
||||||
|
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?"` (42 entries)
|
||||||
|
Full,
|
||||||
|
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"` (38 entries)
|
||||||
|
AlphanumSpaceSlash,
|
||||||
|
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (37 entries)
|
||||||
|
AlphanumSpace,
|
||||||
|
/// `" ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (27 entries)
|
||||||
|
LettersSpace,
|
||||||
|
/// `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (36 entries)
|
||||||
|
Alphanum,
|
||||||
|
/// `"0123456789"` (10 entries)
|
||||||
|
Numeric,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an integer index to an ASCII character according to the given
|
||||||
|
/// character table.
|
||||||
|
///
|
||||||
|
/// Returns `'_'` if the index is out of range (should not happen in normal
|
||||||
|
/// operation).
|
||||||
|
pub fn charn(mut c: i32, table: CharTable) -> char {
|
||||||
|
// Tables that include a leading space
|
||||||
|
if table != CharTable::Alphanum && table != CharTable::Numeric {
|
||||||
|
if c == 0 {
|
||||||
|
return ' ';
|
||||||
|
}
|
||||||
|
c -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits (unless letters-space table which skips digits)
|
||||||
|
if table != CharTable::LettersSpace {
|
||||||
|
if c < 10 {
|
||||||
|
return char::from(b'0' + c as u8);
|
||||||
|
}
|
||||||
|
c -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters (unless numeric table which has no letters)
|
||||||
|
if table != CharTable::Numeric {
|
||||||
|
if c < 26 {
|
||||||
|
return char::from(b'A' + c as u8);
|
||||||
|
}
|
||||||
|
c -= 26;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra symbols
|
||||||
|
match table {
|
||||||
|
CharTable::Full => {
|
||||||
|
const EXTRAS: [char; 5] = ['+', '-', '.', '/', '?'];
|
||||||
|
if (c as usize) < EXTRAS.len() {
|
||||||
|
return EXTRAS[c as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CharTable::AlphanumSpaceSlash => {
|
||||||
|
if c == 0 {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
'_' // unknown character — should never get here
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the index of an ASCII character in the given character table.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the character is not present in the table (the C version
|
||||||
|
/// returns -1).
|
||||||
|
pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
||||||
|
let mut n: i32 = 0;
|
||||||
|
|
||||||
|
// Leading space
|
||||||
|
if table != CharTable::Alphanum && table != CharTable::Numeric {
|
||||||
|
if c == ' ' {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits
|
||||||
|
if table != CharTable::LettersSpace {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
return Some(n + (c as i32 - '0' as i32));
|
||||||
|
}
|
||||||
|
n += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters
|
||||||
|
if table != CharTable::Numeric {
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
return Some(n + (c as i32 - 'A' as i32));
|
||||||
|
}
|
||||||
|
n += 26;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra symbols
|
||||||
|
match table {
|
||||||
|
CharTable::Full => {
|
||||||
|
match c {
|
||||||
|
'+' => return Some(n),
|
||||||
|
'-' => return Some(n + 1),
|
||||||
|
'.' => return Some(n + 2),
|
||||||
|
'/' => return Some(n + 3),
|
||||||
|
'?' => return Some(n + 4),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CharTable::AlphanumSpaceSlash => {
|
||||||
|
if c == '/' {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a character to uppercase ASCII. Non-letter characters are returned
|
||||||
|
/// unchanged.
|
||||||
|
pub fn to_upper(c: char) -> char {
|
||||||
|
if c >= 'a' && c <= 'z' {
|
||||||
|
char::from(c as u8 - b'a' + b'A')
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an FTx message string:
|
||||||
|
/// - replaces lowercase letters with uppercase
|
||||||
|
/// - collapses consecutive spaces into a single space
|
||||||
|
pub fn fmtmsg(msg_in: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(msg_in.len());
|
||||||
|
let mut last_out: Option<char> = None;
|
||||||
|
|
||||||
|
for c in msg_in.chars() {
|
||||||
|
if c == ' ' && last_out == Some(' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let upper = to_upper(c);
|
||||||
|
out.push(upper);
|
||||||
|
last_out = Some(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a signed integer from a string slice.
|
||||||
|
///
|
||||||
|
/// Handles optional leading `+` or `-` sign, followed by decimal digits.
|
||||||
|
/// Stops at the first non-digit character (or end of string).
|
||||||
|
pub fn dd_to_int(s: &str) -> i32 {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (negative, start) = match bytes[0] {
|
||||||
|
b'-' => (true, 1),
|
||||||
|
b'+' => (false, 1),
|
||||||
|
_ => (false, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut result: i32 = 0;
|
||||||
|
for &b in &bytes[start..] {
|
||||||
|
if !b.is_ascii_digit() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result = result * 10 + (b - b'0') as i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if negative {
|
||||||
|
-result
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an integer into a fixed-width decimal string.
|
||||||
|
///
|
||||||
|
/// * `value` – the integer value to format
|
||||||
|
/// * `width` – number of digit positions (excluding sign)
|
||||||
|
/// * `full_sign` – if `true`, a `+` is prepended for non-negative values
|
||||||
|
pub fn int_to_dd(value: i32, width: usize, full_sign: bool) -> String {
|
||||||
|
let mut out = String::with_capacity(width + 1);
|
||||||
|
|
||||||
|
let abs_value = if value < 0 {
|
||||||
|
out.push('-');
|
||||||
|
(-value) as u32
|
||||||
|
} else {
|
||||||
|
if full_sign {
|
||||||
|
out.push('+');
|
||||||
|
}
|
||||||
|
value as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
if width == 0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut divisor: u32 = 1;
|
||||||
|
for _ in 0..width - 1 {
|
||||||
|
divisor *= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remaining = abs_value;
|
||||||
|
while divisor >= 1 {
|
||||||
|
let digit = remaining / divisor;
|
||||||
|
out.push(char::from(b'0' + digit as u8));
|
||||||
|
remaining -= digit * divisor;
|
||||||
|
divisor /= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// charn / nchar round-trip tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_table_round_trip() {
|
||||||
|
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(charn(i as i32, CharTable::Full), ch, "charn({i})");
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::Full),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alphanum_space_slash_round_trip() {
|
||||||
|
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
charn(i as i32, CharTable::AlphanumSpaceSlash),
|
||||||
|
ch,
|
||||||
|
"charn({i})"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::AlphanumSpaceSlash),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alphanum_space_round_trip() {
|
||||||
|
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
charn(i as i32, CharTable::AlphanumSpace),
|
||||||
|
ch,
|
||||||
|
"charn({i})"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::AlphanumSpace),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn letters_space_round_trip() {
|
||||||
|
let expected = " ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
charn(i as i32, CharTable::LettersSpace),
|
||||||
|
ch,
|
||||||
|
"charn({i})"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::LettersSpace),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alphanum_round_trip() {
|
||||||
|
let expected = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(charn(i as i32, CharTable::Alphanum), ch, "charn({i})");
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::Alphanum),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_round_trip() {
|
||||||
|
let expected = "0123456789";
|
||||||
|
for (i, ch) in expected.chars().enumerate() {
|
||||||
|
assert_eq!(charn(i as i32, CharTable::Numeric), ch, "charn({i})");
|
||||||
|
assert_eq!(
|
||||||
|
nchar(ch, CharTable::Numeric),
|
||||||
|
Some(i as i32),
|
||||||
|
"nchar('{ch}')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nchar_returns_none_for_unknown() {
|
||||||
|
assert_eq!(nchar('!', CharTable::Full), None);
|
||||||
|
assert_eq!(nchar('a', CharTable::Full), None); // lowercase not in table
|
||||||
|
assert_eq!(nchar(' ', CharTable::Alphanum), None);
|
||||||
|
assert_eq!(nchar('A', CharTable::Numeric), None);
|
||||||
|
assert_eq!(nchar('0', CharTable::LettersSpace), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charn_returns_underscore_for_out_of_range() {
|
||||||
|
assert_eq!(charn(42, CharTable::Full), '_');
|
||||||
|
assert_eq!(charn(38, CharTable::AlphanumSpaceSlash), '_');
|
||||||
|
assert_eq!(charn(10, CharTable::Numeric), '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// to_upper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_upper_converts_lowercase() {
|
||||||
|
assert_eq!(to_upper('a'), 'A');
|
||||||
|
assert_eq!(to_upper('z'), 'Z');
|
||||||
|
assert_eq!(to_upper('m'), 'M');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_upper_preserves_non_lower() {
|
||||||
|
assert_eq!(to_upper('A'), 'A');
|
||||||
|
assert_eq!(to_upper('5'), '5');
|
||||||
|
assert_eq!(to_upper(' '), ' ');
|
||||||
|
assert_eq!(to_upper('/'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// fmtmsg
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmtmsg_uppercases_and_collapses_spaces() {
|
||||||
|
assert_eq!(fmtmsg("cq dx de ab1cd"), "CQ DX DE AB1CD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmtmsg_preserves_single_spaces() {
|
||||||
|
assert_eq!(fmtmsg("CQ DX"), "CQ DX");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmtmsg_empty() {
|
||||||
|
assert_eq!(fmtmsg(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmtmsg_all_spaces() {
|
||||||
|
assert_eq!(fmtmsg(" "), " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// dd_to_int
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dd_to_int_positive() {
|
||||||
|
assert_eq!(dd_to_int("42"), 42);
|
||||||
|
assert_eq!(dd_to_int("+42"), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dd_to_int_negative() {
|
||||||
|
assert_eq!(dd_to_int("-7"), -7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dd_to_int_stops_at_non_digit() {
|
||||||
|
assert_eq!(dd_to_int("12abc"), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dd_to_int_empty() {
|
||||||
|
assert_eq!(dd_to_int(""), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dd_to_int_sign_only() {
|
||||||
|
assert_eq!(dd_to_int("-"), 0);
|
||||||
|
assert_eq!(dd_to_int("+"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// int_to_dd
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int_to_dd_positive_no_sign() {
|
||||||
|
assert_eq!(int_to_dd(7, 2, false), "07");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int_to_dd_positive_with_sign() {
|
||||||
|
assert_eq!(int_to_dd(7, 2, true), "+07");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int_to_dd_negative() {
|
||||||
|
assert_eq!(int_to_dd(-15, 2, false), "-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int_to_dd_zero() {
|
||||||
|
assert_eq!(int_to_dd(0, 2, false), "00");
|
||||||
|
assert_eq!(int_to_dd(0, 2, true), "+00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int_to_dd_width_3() {
|
||||||
|
assert_eq!(int_to_dd(123, 3, false), "123");
|
||||||
|
assert_eq!(int_to_dd(5, 3, true), "+005");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user