[refactor](decoders): consolidate decoder crates under src/decoders/

Move trx-ft8 and trx-wspr into src/decoders/ alongside a new trx-aprs
crate that extracts the Bell 202/AX.25 decoder from trx-server, giving
all three modems a consistent crate-per-decoder layout.

- src/decoders/trx-ft8/  (moved from src/trx-ft8/)
- src/decoders/trx-wspr/ (moved from src/trx-wspr/)
- src/decoders/trx-aprs/ (new — Bell 202 AFSK + AX.25/APRS decoder)
- trx-ft8/build.rs: fix external/ft8_lib relative path after move
- trx-server: drop decode::aprs module, use trx_aprs::AprsDecoder
- AprsPacket stays in trx-core (mirrors Ft8Message / WsprMessage)
- Workspace Cargo.toml updated with new member paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 18:30:04 +01:00
parent 0d6a35a933
commit 3ebd185a7e
15 changed files with 29 additions and 8 deletions
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-ft8"
version = "0.1.0"
edition = "2021"
[dependencies]
libc = "0.2"
[build-dependencies]
cc = "1"
+42
View File
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
fn main() {
let base = "../../../external/ft8_lib";
let mut build = cc::Build::new();
build
.include(base)
.include(format!("{base}/common"))
.include(format!("{base}/fft"))
.include(format!("{base}/ft8"))
.define("_GNU_SOURCE", None)
.define("_POSIX_C_SOURCE", "200809L")
.file("src/ft8_wrapper.c")
.file(format!("{base}/common/monitor.c"))
.file(format!("{base}/fft/kiss_fft.c"))
.file(format!("{base}/fft/kiss_fftr.c"))
.file(format!("{base}/ft8/constants.c"))
.file(format!("{base}/ft8/crc.c"))
.file(format!("{base}/ft8/decode.c"))
.file(format!("{base}/ft8/ldpc.c"))
.file(format!("{base}/ft8/message.c"))
.file(format!("{base}/ft8/text.c"))
.flag_if_supported("-std=c99")
.flag_if_supported("-Wno-unused-const-variable")
.flag_if_supported("-Wno-unused-function")
.compile("trx_ft8");
println!("cargo:rustc-link-lib=m");
println!("cargo:rerun-if-changed=src/ft8_wrapper.c");
println!("cargo:rerun-if-changed={base}/common/monitor.c");
println!("cargo:rerun-if-changed={base}/fft/kiss_fft.c");
println!("cargo:rerun-if-changed={base}/fft/kiss_fftr.c");
println!("cargo:rerun-if-changed={base}/ft8/constants.c");
println!("cargo:rerun-if-changed={base}/ft8/crc.c");
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
println!("cargo:rerun-if-changed={base}/ft8/ldpc.c");
println!("cargo:rerun-if-changed={base}/ft8/message.c");
println!("cargo:rerun-if-changed={base}/ft8/text.c");
}
+244
View File
@@ -0,0 +1,244 @@
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
#include <ft8/decode.h>
#include <ft8/message.h>
#include <ft8/text.h>
#include <common/monitor.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
// Callsign hash table (from demo/decode_ft8.c)
#define CALLSIGN_HASHTABLE_SIZE 256
typedef struct
{
uint32_t hash;
char callsign[12];
} callsign_hashtable_entry_t;
static callsign_hashtable_entry_t callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
static int callsign_hashtable_size = 0;
static void hashtable_init(void)
{
callsign_hashtable_size = 0;
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
}
static void hashtable_cleanup(uint8_t max_age)
{
for (int idx_hash = 0; idx_hash < CALLSIGN_HASHTABLE_SIZE; ++idx_hash)
{
if (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
uint8_t age = (uint8_t)(callsign_hashtable[idx_hash].hash >> 24);
if (age >= max_age)
{
callsign_hashtable[idx_hash].callsign[0] = '\0';
callsign_hashtable[idx_hash].hash = 0;
callsign_hashtable_size--;
}
else
{
callsign_hashtable[idx_hash].hash = (((uint32_t)age + 1u) << 24) | (callsign_hashtable[idx_hash].hash & 0x3FFFFFu);
}
}
}
}
static void hashtable_add(const char* callsign, uint32_t hash)
{
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
{
callsign_hashtable[idx_hash].hash &= 0x3FFFFFu;
return;
}
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign_hashtable_size++;
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
callsign_hashtable[idx_hash].callsign[11] = '\0';
callsign_hashtable[idx_hash].hash = hash;
}
static bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
{
int hash_shift = (hash_type == FTX_CALLSIGN_HASH_22_BITS) ? 0 : (hash_type == FTX_CALLSIGN_HASH_12_BITS) ? 10 : 12;
uint32_t mask = (hash_type == FTX_CALLSIGN_HASH_22_BITS) ? 0x3FFFFFu : (hash_type == FTX_CALLSIGN_HASH_12_BITS) ? 0xFFFu : 0x3FFu;
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) >> hash_shift) == (hash & mask))
{
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
return true;
}
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign[0] = '\0';
return false;
}
static ftx_callsign_hash_interface_t hash_if = {
.lookup_hash = hashtable_lookup,
.save_hash = hashtable_add,
};
// Decoder wrapper
typedef struct
{
monitor_t mon;
monitor_config_t cfg;
} ft8_decoder_t;
typedef struct
{
char text[FTX_MAX_MESSAGE_LENGTH];
float snr_db;
float dt_s;
float freq_hz;
} ft8_decode_result_t;
ft8_decoder_t* ft8_decoder_create(int sample_rate, float f_min, float f_max, int time_osr, int freq_osr)
{
ft8_decoder_t* dec = (ft8_decoder_t*)calloc(1, sizeof(ft8_decoder_t));
if (!dec)
{
return NULL;
}
dec->cfg.f_min = f_min;
dec->cfg.f_max = f_max;
dec->cfg.sample_rate = sample_rate;
dec->cfg.time_osr = time_osr;
dec->cfg.freq_osr = freq_osr;
dec->cfg.protocol = FTX_PROTOCOL_FT8;
hashtable_init();
monitor_init(&dec->mon, &dec->cfg);
return dec;
}
void ft8_decoder_free(ft8_decoder_t* dec)
{
if (!dec)
return;
monitor_free(&dec->mon);
free(dec);
}
int ft8_decoder_block_size(const ft8_decoder_t* dec)
{
return dec ? dec->mon.block_size : 0;
}
void ft8_decoder_reset(ft8_decoder_t* dec)
{
if (!dec)
return;
monitor_reset(&dec->mon);
}
void ft8_decoder_process(ft8_decoder_t* dec, const float* frame)
{
if (!dec || !frame)
return;
monitor_process(&dec->mon, frame);
}
int ft8_decoder_is_ready(const ft8_decoder_t* dec)
{
if (!dec)
return 0;
return (dec->mon.wf.num_blocks >= dec->mon.wf.max_blocks) ? 1 : 0;
}
int ft8_decoder_decode(ft8_decoder_t* dec, ft8_decode_result_t* out, int max_results)
{
if (!dec || !out || max_results <= 0)
return 0;
const ftx_waterfall_t* wf = &dec->mon.wf;
const int kMaxCandidates = 200;
const int kMinScore = 10;
const int kLdpcIters = 30;
ftx_candidate_t candidate_list[kMaxCandidates];
int num_candidates = ftx_find_candidates(wf, kMaxCandidates, candidate_list, kMinScore);
int num_decoded = 0;
ftx_message_t decoded[200];
ftx_message_t* decoded_hashtable[200];
for (int i = 0; i < 200; ++i)
{
decoded_hashtable[i] = NULL;
}
for (int idx = 0; idx < num_candidates && num_decoded < max_results; ++idx)
{
const ftx_candidate_t* cand = &candidate_list[idx];
float freq_hz = (dec->mon.min_bin + cand->freq_offset + (float)cand->freq_sub / wf->freq_osr) / dec->mon.symbol_period;
float time_sec = (cand->time_offset + (float)cand->time_sub / wf->time_osr) * dec->mon.symbol_period;
ftx_message_t message;
ftx_decode_status_t status;
if (!ftx_decode_candidate(wf, cand, kLdpcIters, &message, &status))
{
continue;
}
int idx_hash = message.hash % 200;
bool found_empty_slot = false;
bool found_duplicate = false;
do
{
if (decoded_hashtable[idx_hash] == NULL)
{
found_empty_slot = true;
}
else if ((decoded_hashtable[idx_hash]->hash == message.hash) && (0 == memcmp(decoded_hashtable[idx_hash]->payload, message.payload, sizeof(message.payload))))
{
found_duplicate = true;
}
else
{
idx_hash = (idx_hash + 1) % 200;
}
} while (!found_empty_slot && !found_duplicate);
if (!found_empty_slot)
continue;
memcpy(&decoded[idx_hash], &message, sizeof(message));
decoded_hashtable[idx_hash] = &decoded[idx_hash];
char text[FTX_MAX_MESSAGE_LENGTH];
ftx_message_offsets_t offsets;
ftx_message_rc_t unpack_status = ftx_message_decode(&message, &hash_if, text, &offsets);
if (unpack_status != FTX_MESSAGE_RC_OK)
{
snprintf(text, sizeof(text), "Error [%d] while unpacking!", (int)unpack_status);
}
ft8_decode_result_t* dst = &out[num_decoded];
strncpy(dst->text, text, sizeof(dst->text) - 1);
dst->text[sizeof(dst->text) - 1] = '\0';
dst->dt_s = time_sec;
dst->freq_hz = freq_hz;
dst->snr_db = cand->score * 0.5f;
num_decoded++;
}
hashtable_cleanup(10);
return num_decoded;
}
+150
View File
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use libc::{c_float, c_int, c_void};
use std::ffi::CStr;
use std::ptr::NonNull;
const F_MIN_HZ: f32 = 200.0;
const F_MAX_HZ: f32 = 3000.0;
const TIME_OSR: i32 = 2;
const FREQ_OSR: i32 = 2;
const FTX_MAX_MESSAGE_LENGTH: usize = 35;
#[repr(C)]
#[derive(Clone, Copy)]
struct Ft8DecodeResultRaw {
text: [libc::c_char; FTX_MAX_MESSAGE_LENGTH],
snr_db: c_float,
dt_s: c_float,
freq_hz: c_float,
}
#[derive(Debug, Clone)]
pub struct Ft8DecodeResult {
pub text: String,
pub snr_db: f32,
pub dt_s: f32,
pub freq_hz: f32,
}
extern "C" {
fn ft8_decoder_create(
sample_rate: c_int,
f_min: c_float,
f_max: c_float,
time_osr: c_int,
freq_osr: c_int,
) -> *mut c_void;
fn ft8_decoder_free(dec: *mut c_void);
fn ft8_decoder_block_size(dec: *const c_void) -> c_int;
fn ft8_decoder_reset(dec: *mut c_void);
fn ft8_decoder_process(dec: *mut c_void, frame: *const c_float);
fn ft8_decoder_is_ready(dec: *const c_void) -> c_int;
fn ft8_decoder_decode(
dec: *mut c_void,
out: *mut Ft8DecodeResultRaw,
max_results: c_int,
) -> c_int;
}
pub struct Ft8Decoder {
inner: NonNull<c_void>,
block_size: usize,
sample_rate: u32,
}
// SAFETY: Ft8Decoder owns its C-side state and is not shared across threads.
// It is only moved into a single task, so Send is safe.
unsafe impl Send for Ft8Decoder {}
impl Ft8Decoder {
pub fn new(sample_rate: u32) -> Result<Self, String> {
unsafe {
let ptr = ft8_decoder_create(
sample_rate as c_int,
F_MIN_HZ,
F_MAX_HZ,
TIME_OSR as c_int,
FREQ_OSR as c_int,
);
let inner = NonNull::new(ptr).ok_or_else(|| "ft8_decoder_create failed".to_string())?;
let block_size = ft8_decoder_block_size(inner.as_ptr()) as usize;
if block_size == 0 {
ft8_decoder_free(inner.as_ptr());
return Err("invalid FT8 block size".to_string());
}
Ok(Self {
inner,
block_size,
sample_rate,
})
}
}
pub fn block_size(&self) -> usize {
self.block_size
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn reset(&mut self) {
unsafe {
ft8_decoder_reset(self.inner.as_ptr());
}
}
pub fn process_block(&mut self, block: &[f32]) {
if block.len() < self.block_size {
return;
}
unsafe {
ft8_decoder_process(self.inner.as_ptr(), block.as_ptr());
}
}
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
unsafe {
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
return Vec::new();
}
let mut raw = vec![
Ft8DecodeResultRaw {
text: [0; FTX_MAX_MESSAGE_LENGTH],
snr_db: 0.0,
dt_s: 0.0,
freq_hz: 0.0,
};
max_results
];
let count =
ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
let count = count.max(0) as usize;
let mut out = Vec::with_capacity(count);
for item in raw.into_iter().take(count) {
let text = CStr::from_ptr(item.text.as_ptr())
.to_string_lossy()
.to_string();
out.push(Ft8DecodeResult {
text,
snr_db: item.snr_db,
dt_s: item.dt_s,
freq_hz: item.freq_hz,
});
}
out
}
}
}
impl Drop for Ft8Decoder {
fn drop(&mut self) {
unsafe {
ft8_decoder_free(self.inner.as_ptr());
}
}
}