[refactor](trx-ftx): reorganize into common/, ft8/, ft4/, ft2/ modules

Split flat src/ layout into protocol-oriented directory structure:
- common/: shared types, constants, LDPC/OSD decoders, monitor, message, CRC
- ft8/: FT8-specific sync scoring, likelihood extraction, tone encoding
- ft4/: FT4-specific sync scoring, likelihood extraction, tone encoding
- ft2/: FT2 pipeline, waterfall decode, bitmetrics, downsample, sync
- Top-level: lib.rs (mod declarations) and decoder.rs (public API)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-19 23:51:17 +01:00
parent de0bc89705
commit bb18d90cbe
23 changed files with 1231 additions and 1149 deletions
+434
View File
@@ -0,0 +1,434 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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.is_ascii_digit() {
return Some(n + (c as i32 - '0' as i32));
}
n += 10;
}
// Letters
if table != CharTable::Numeric {
if c.is_ascii_uppercase() {
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.is_ascii_lowercase() {
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");
}
}