[feat](trx-rs): add FT2 decoder protocol support

Implement a distinct FT2 protocol path in the decoder stack and align\nits timing with the confirmed FT2 framing used by Decodium.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-14 19:49:05 +01:00
parent d547c45a9c
commit ad6aa6aab4
9 changed files with 116 additions and 46 deletions
+2 -2
View File
@@ -54,8 +54,8 @@ static void waterfall_free(ftx_waterfall_t* me)
void monitor_init(monitor_t* me, const monitor_config_t* cfg)
{
float slot_time = (cfg->protocol == FTX_PROTOCOL_FT4) ? FT4_SLOT_TIME : FT8_SLOT_TIME;
float symbol_period = (cfg->protocol == FTX_PROTOCOL_FT4) ? FT4_SYMBOL_PERIOD : FT8_SYMBOL_PERIOD;
float slot_time = ftx_protocol_slot_time(cfg->protocol);
float symbol_period = ftx_protocol_symbol_period(cfg->protocol);
// Compute DSP parameters that depend on the sample rate
me->block_size = (int)(cfg->sample_rate * symbol_period); // samples corresponding to one FSK symbol
me->subblock_size = me->block_size / cfg->time_osr;
+32 -1
View File
@@ -14,6 +14,9 @@ extern "C"
#define FT4_SYMBOL_PERIOD (0.048f) ///< FT4 symbol duration, defines tone deviation in Hz and symbol rate
#define FT4_SLOT_TIME (7.5f) ///< FT4 slot period
#define FT2_SYMBOL_PERIOD (0.024f) ///< FT2 symbol duration (288 samples @ 12 kHz)
#define FT2_SLOT_TIME (3.75f) ///< FT2 slot period
// Define FT8 symbol counts
// FT8 message structure:
// S D1 S D2 S
@@ -38,6 +41,14 @@ extern "C"
#define FT4_NUM_SYNC (4) ///< Number of sync groups
#define FT4_SYNC_OFFSET (33) ///< Offset between sync groups
// FT2 reuses the FT4 channel structure with a shorter slot and symbol period.
#define FT2_ND FT4_ND
#define FT2_NR FT4_NR
#define FT2_NN FT4_NN
#define FT2_LENGTH_SYNC FT4_LENGTH_SYNC
#define FT2_NUM_SYNC FT4_NUM_SYNC
#define FT2_SYNC_OFFSET FT4_SYNC_OFFSET
// Define LDPC parameters
#define FTX_LDPC_N (174) ///< Number of bits in the encoded message (payload with LDPC checksum bits)
#define FTX_LDPC_K (91) ///< Number of payload bits (including CRC)
@@ -52,9 +63,29 @@ extern "C"
typedef enum
{
FTX_PROTOCOL_FT4,
FTX_PROTOCOL_FT8
FTX_PROTOCOL_FT8,
FTX_PROTOCOL_FT2
} ftx_protocol_t;
static inline float ftx_protocol_symbol_period(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SYMBOL_PERIOD
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SYMBOL_PERIOD : FT4_SYMBOL_PERIOD);
}
static inline float ftx_protocol_slot_time(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SLOT_TIME
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SLOT_TIME : FT4_SLOT_TIME);
}
static inline int ftx_protocol_uses_ft4_layout(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT4) || (protocol == FTX_PROTOCOL_FT2);
}
/// Costas 7x7 tone pattern for synchronization
extern const uint8_t kFT8_Costas_pattern[7];
extern const uint8_t kFT4_Costas_pattern[4][4];
+6 -5
View File
@@ -189,8 +189,9 @@ static int ft4_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* cand
int ftx_find_candidates(const ftx_waterfall_t* wf, int num_candidates, ftx_candidate_t heap[], int min_score)
{
int (*sync_fun)(const ftx_waterfall_t*, const ftx_candidate_t*) = (wf->protocol == FTX_PROTOCOL_FT4) ? ft4_sync_score : ft8_sync_score;
int num_tones = (wf->protocol == FTX_PROTOCOL_FT4) ? 4 : 8;
int (*sync_fun)(const ftx_waterfall_t*, const ftx_candidate_t*) =
ftx_protocol_uses_ft4_layout(wf->protocol) ? ft4_sync_score : ft8_sync_score;
int num_tones = ftx_protocol_uses_ft4_layout(wf->protocol) ? 4 : 8;
int heap_size = 0;
ftx_candidate_t candidate;
@@ -327,7 +328,7 @@ static void ftx_normalize_logl(float* log174)
bool ftx_decode_candidate(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, int max_iterations, ftx_message_t* message, ftx_decode_status_t* status)
{
float log174[FTX_LDPC_N]; // message bits encoded as likelihood
if (wf->protocol == FTX_PROTOCOL_FT4)
if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
ft4_extract_likelihood(wf, cand, log174);
}
@@ -366,7 +367,7 @@ bool ftx_decode_candidate(const ftx_waterfall_t* wf, const ftx_candidate_t* cand
// Reuse CRC value as a hash for the message (TODO: 14 bits only, should perhaps use full 16 or 32 bits?)
message->hash = status->crc_calculated;
if (wf->protocol == FTX_PROTOCOL_FT4)
if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
// '[..] for FT4 only, in order to avoid transmitting a long string of zeros when sending CQ messages,
// the assembled 77-bit message is bitwise exclusive-ORed with [a] pseudorandom sequence before computing the CRC and FEC parity bits'
@@ -589,4 +590,4 @@ static void pack_bits(const uint8_t bit_array[], int num_bits, uint8_t packed[])
++byte_idx;
}
}
}
}
+5
View File
@@ -193,3 +193,8 @@ void ft4_encode(const uint8_t* payload, uint8_t* tones)
}
}
}
void ft2_encode(const uint8_t* payload, uint8_t* tones)
{
ft4_encode(payload, tones);
}
+6
View File
@@ -34,6 +34,12 @@ void ft8_encode(const uint8_t* payload, uint8_t* tones);
/// @param[out] tones - array of FT4_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft4_encode(const uint8_t* payload, uint8_t* tones);
/// Generate FT2 tone sequence from payload data.
/// FT2 uses the FT4 framing with a doubled symbol rate.
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT2_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft2_encode(const uint8_t* payload, uint8_t* tones);
#ifdef __cplusplus
}
#endif
+9
View File
@@ -31,12 +31,21 @@ fn main() {
println!("cargo:rerun-if-changed=src/ft8_wrapper.c");
println!("cargo:rerun-if-changed={base}/common/monitor.c");
println!("cargo:rerun-if-changed={base}/common/monitor.h");
println!("cargo:rerun-if-changed={base}/fft/kiss_fft.c");
println!("cargo:rerun-if-changed={base}/fft/kiss_fft.h");
println!("cargo:rerun-if-changed={base}/fft/kiss_fftr.c");
println!("cargo:rerun-if-changed={base}/fft/kiss_fftr.h");
println!("cargo:rerun-if-changed={base}/ft8/constants.c");
println!("cargo:rerun-if-changed={base}/ft8/constants.h");
println!("cargo:rerun-if-changed={base}/ft8/crc.c");
println!("cargo:rerun-if-changed={base}/ft8/crc.h");
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
println!("cargo:rerun-if-changed={base}/ft8/decode.h");
println!("cargo:rerun-if-changed={base}/ft8/ldpc.c");
println!("cargo:rerun-if-changed={base}/ft8/ldpc.h");
println!("cargo:rerun-if-changed={base}/ft8/message.c");
println!("cargo:rerun-if-changed={base}/ft8/message.h");
println!("cargo:rerun-if-changed={base}/ft8/text.c");
println!("cargo:rerun-if-changed={base}/ft8/text.h");
}
+20 -1
View File
@@ -11,6 +11,13 @@
#include <string.h>
#include <stdio.h>
enum
{
TRX_FTX_PROTOCOL_FT4 = 0,
TRX_FTX_PROTOCOL_FT8 = 1,
TRX_FTX_PROTOCOL_FT2 = 2,
};
// Callsign hash table (from demo/decode_ft8.c)
#define CALLSIGN_HASHTABLE_SIZE 256
@@ -120,7 +127,19 @@ ft8_decoder_t* ft8_decoder_create(int sample_rate, float f_min, float f_max, int
dec->cfg.sample_rate = sample_rate;
dec->cfg.time_osr = time_osr;
dec->cfg.freq_osr = freq_osr;
dec->cfg.protocol = (protocol == 0) ? FTX_PROTOCOL_FT4 : FTX_PROTOCOL_FT8;
switch (protocol)
{
case TRX_FTX_PROTOCOL_FT4:
dec->cfg.protocol = FTX_PROTOCOL_FT4;
break;
case TRX_FTX_PROTOCOL_FT2:
dec->cfg.protocol = FTX_PROTOCOL_FT2;
break;
case TRX_FTX_PROTOCOL_FT8:
default:
dec->cfg.protocol = FTX_PROTOCOL_FT8;
break;
}
hashtable_init();
monitor_init(&dec->mon, &dec->cfg);
+29 -28
View File
@@ -12,6 +12,9 @@ const TIME_OSR: i32 = 2;
const FREQ_OSR: i32 = 2;
const FTX_MAX_MESSAGE_LENGTH: usize = 35;
const PROTOCOL_FT4: c_int = 0;
const PROTOCOL_FT8: c_int = 1;
const PROTOCOL_FT2: c_int = 2;
#[repr(C)]
#[derive(Clone, Copy)]
@@ -63,30 +66,18 @@ 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,
1, // FTX_PROTOCOL_FT8
);
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,
})
}
Self::new_with_protocol(sample_rate, PROTOCOL_FT8, "FT8")
}
pub fn new_ft4(sample_rate: u32) -> Result<Self, String> {
Self::new_with_protocol(sample_rate, PROTOCOL_FT4, "FT4")
}
pub fn new_ft2(sample_rate: u32) -> Result<Self, String> {
Self::new_with_protocol(sample_rate, PROTOCOL_FT2, "FT2")
}
fn new_with_protocol(sample_rate: u32, protocol: c_int, label: &str) -> Result<Self, String> {
unsafe {
let ptr = ft8_decoder_create(
sample_rate as c_int,
@@ -94,13 +85,13 @@ impl Ft8Decoder {
F_MAX_HZ,
TIME_OSR as c_int,
FREQ_OSR as c_int,
0, // FTX_PROTOCOL_FT4
protocol,
);
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 FT4 block size".to_string());
return Err(format!("invalid {label} block size"));
}
Ok(Self {
inner,
@@ -110,11 +101,6 @@ impl Ft8Decoder {
}
}
pub fn new_ft2(sample_rate: u32) -> Result<Self, String> {
// Wired to FT4 protocol pending a dedicated FT2 implementation.
Self::new_ft4(sample_rate)
}
pub fn block_size(&self) -> usize {
self.block_size
}
@@ -179,3 +165,18 @@ impl Drop for Ft8Decoder {
}
}
}
#[cfg(test)]
mod tests {
use super::Ft8Decoder;
#[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);
}
}
+7 -9
View File
@@ -1853,7 +1853,7 @@ pub async fn run_ft4_decoder(
}
}
/// Run the FT2 decoder task. Mirrors FT4 but uses FT2 protocol (7.5s slots for now).
/// Run the FT2 decoder task. Mirrors FT4 but uses FT2 protocol timing.
pub async fn run_ft2_decoder(
sample_rate: u32,
channels: u16,
@@ -1902,12 +1902,11 @@ pub async fn run_ft2_decoder(
recv = pcm_rx.recv() => {
match recv {
Ok(frame) => {
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_secs() as i64,
let now_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_millis() as i64,
Err(_) => 0,
};
// FT2 slot period is 7.5s (same as FT4 for now); use now * 2 / 15
let slot = now * 2 / 15;
let slot = now_ms / 3_750;
if slot != last_slot {
last_slot = slot;
decoder.reset();
@@ -2449,13 +2448,12 @@ async fn run_background_ft2_decoder(
loop {
match pcm_rx.recv().await {
Ok(frame) => {
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
let now_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
{
Ok(dur) => dur.as_secs() as i64,
Ok(dur) => dur.as_millis() as i64,
Err(_) => 0,
};
// FT2 slot period is 7.5s (same as FT4 for now); use now * 2 / 15
let slot = now * 2 / 15;
let slot = now_ms / 3_750;
if slot != last_slot {
last_slot = slot;
decoder.reset();