[feat](trx-rs): add ft8 decoder

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-09 21:19:56 +01:00
parent 7bd1a70607
commit 1199ab85e9
206 changed files with 9613 additions and 5 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"
+38
View File
@@ -0,0 +1,38 @@
// 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"))
.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")
.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) ? 0 : (hash_type == FTX_CALLSIGN_HASH_12) ? 10 : 12;
uint32_t mask = (hash_type == FTX_CALLSIGN_HASH_22) ? 0x3FFFFFu : (hash_type == FTX_CALLSIGN_HASH_12) ? 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;
}
+135
View File
@@ -0,0 +1,135 @@
// 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;
#[repr(C)]
struct Ft8DecodeResultRaw {
text: [libc::c_char; 64],
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,
}
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; 64],
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 = unsafe { 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());
}
}
}