From 2570fe739ee92d6aa3fa1c45eca10d0a4e30eaa7 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 14 Mar 2026 20:20:18 +0100 Subject: [PATCH] [fix](trx-rs): improve FT2 decoder acquisition Use FT2-specific analysis settings and a wider receive span. Switch server-side FT2 decoding to a rolling async window. Widen FT2 candidate timing search in the vendored decoder. Co-authored-by: OpenAI Codex Signed-off-by: Stanislaw Grams --- external/ft8_lib/ft8/decode.c | 15 ++- src/decoders/trx-ft8/src/ft8_wrapper.c | 14 ++- src/decoders/trx-ft8/src/lib.rs | 42 +++++++-- src/trx-server/src/audio.rs | 122 +++++++++++++++++-------- 4 files changed, 145 insertions(+), 48 deletions(-) diff --git a/external/ft8_lib/ft8/decode.c b/external/ft8_lib/ft8/decode.c index 5254fcd..cc427a7 100644 --- a/external/ft8_lib/ft8/decode.c +++ b/external/ft8_lib/ft8/decode.c @@ -189,9 +189,22 @@ 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) { + bool is_ft2 = (wf->protocol == FTX_PROTOCOL_FT2); 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 time_offset_min = -10; + int time_offset_max = 20; + + if (is_ft2) + { + time_offset_min = -2; + time_offset_max = wf->num_blocks - FT2_NN + 2; + if (time_offset_max <= time_offset_min) + { + time_offset_max = time_offset_min + 1; + } + } int heap_size = 0; ftx_candidate_t candidate; @@ -203,7 +216,7 @@ int ftx_find_candidates(const ftx_waterfall_t* wf, int num_candidates, ftx_candi { for (candidate.freq_sub = 0; candidate.freq_sub < wf->freq_osr; ++candidate.freq_sub) { - for (candidate.time_offset = -10; candidate.time_offset < 20; ++candidate.time_offset) + for (candidate.time_offset = time_offset_min; candidate.time_offset < time_offset_max; ++candidate.time_offset) { for (candidate.freq_offset = 0; (candidate.freq_offset + num_tones - 1) < wf->num_bins; ++candidate.freq_offset) { diff --git a/src/decoders/trx-ft8/src/ft8_wrapper.c b/src/decoders/trx-ft8/src/ft8_wrapper.c index 24787aa..46b47fd 100644 --- a/src/decoders/trx-ft8/src/ft8_wrapper.c +++ b/src/decoders/trx-ft8/src/ft8_wrapper.c @@ -159,6 +159,13 @@ int ft8_decoder_block_size(const ft8_decoder_t* dec) return dec ? dec->mon.block_size : 0; } +int ft8_decoder_window_samples(const ft8_decoder_t* dec) +{ + if (!dec) + return 0; + return dec->mon.block_size * dec->mon.wf.max_blocks; +} + void ft8_decoder_reset(ft8_decoder_t* dec) { if (!dec) @@ -186,9 +193,10 @@ int ft8_decoder_decode(ft8_decoder_t* dec, ft8_decode_result_t* out, int max_res return 0; const ftx_waterfall_t* wf = &dec->mon.wf; - const int kMaxCandidates = 200; - const int kMinScore = 10; - const int kLdpcIters = 30; + const bool is_ft2 = (dec->cfg.protocol == FTX_PROTOCOL_FT2); + const int kMaxCandidates = is_ft2 ? 400 : 200; + const int kMinScore = is_ft2 ? 4 : 10; + const int kLdpcIters = is_ft2 ? 50 : 30; ftx_candidate_t candidate_list[kMaxCandidates]; int num_candidates = ftx_find_candidates(wf, kMaxCandidates, candidate_list, kMinScore); diff --git a/src/decoders/trx-ft8/src/lib.rs b/src/decoders/trx-ft8/src/lib.rs index c834fcb..bc17ec3 100644 --- a/src/decoders/trx-ft8/src/lib.rs +++ b/src/decoders/trx-ft8/src/lib.rs @@ -6,10 +6,14 @@ 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 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 FTX_MAX_MESSAGE_LENGTH: usize = 35; const PROTOCOL_FT4: c_int = 0; @@ -44,6 +48,7 @@ extern "C" { ) -> *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_window_samples(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; @@ -57,6 +62,7 @@ extern "C" { pub struct Ft8Decoder { inner: NonNull, block_size: usize, + window_samples: usize, sample_rate: u32, } @@ -78,24 +84,39 @@ impl Ft8Decoder { } fn new_with_protocol(sample_rate: u32, protocol: c_int, label: &str) -> Result { + let (f_min, f_max, time_osr, freq_osr) = match protocol { + PROTOCOL_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, + ), + }; 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, + f_min, + f_max, + time_osr as c_int, + freq_osr as c_int, 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; + let window_samples = ft8_decoder_window_samples(inner.as_ptr()) as usize; if block_size == 0 { ft8_decoder_free(inner.as_ptr()); return Err(format!("invalid {label} block size")); } + if window_samples == 0 { + ft8_decoder_free(inner.as_ptr()); + return Err(format!("invalid {label} analysis window")); + } Ok(Self { inner, block_size, + window_samples, sample_rate, }) } @@ -109,6 +130,10 @@ impl Ft8Decoder { self.sample_rate } + pub fn window_samples(&self) -> usize { + self.window_samples + } + pub fn reset(&mut self) { unsafe { ft8_decoder_reset(self.inner.as_ptr()); @@ -178,5 +203,6 @@ mod tests { assert!(ft2.block_size() < ft4.block_size()); assert_eq!(ft4.block_size(), 576); assert_eq!(ft2.block_size(), 288); + assert_eq!(ft2.window_samples(), 44_928); } } diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index fde0040..6772d2f 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -4,7 +4,7 @@ //! Audio capture, playback, and TCP streaming for trx-server. -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -54,6 +54,9 @@ const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); const FT8_SAMPLE_RATE: u32 = 12_000; +const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000; +const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000; +const FT2_DEDUPE_RETENTION: Duration = Duration::from_secs(8); const DECODE_AUDIO_GATE_RMS: f32 = 2.5e-4; const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1); @@ -65,6 +68,48 @@ fn current_timestamp_ms() -> i64 { } } +fn retain_ft2_window(buf: &mut Vec) { + if buf.len() > FT2_ASYNC_BUFFER_SAMPLES { + let excess = buf.len() - FT2_ASYNC_BUFFER_SAMPLES; + buf.drain(..excess); + } +} + +fn prune_recent_ft2_decodes(recent: &mut HashMap, now: Instant) { + recent.retain(|_, seen_at| now.duration_since(*seen_at) <= FT2_DEDUPE_RETENTION); +} + +fn should_emit_ft2_decode(recent: &mut HashMap, text: &str, freq_hz: f32) -> bool { + let now = Instant::now(); + prune_recent_ft2_decodes(recent, now); + let key = format!("{}|{}", text, freq_hz.round() as i32); + if recent.contains_key(&key) { + return false; + } + recent.insert(key, now); + true +} + +fn decode_ft2_window( + decoder: &mut Ft8Decoder, + samples: &[f32], + max_results: usize, +) -> Vec { + let window_samples = decoder.window_samples(); + if samples.len() < window_samples { + return Vec::new(); + } + + decoder.reset(); + let block_size = decoder.block_size(); + let start = samples.len() - window_samples; + let window = &samples[start..]; + for block in window.chunks_exact(block_size) { + decoder.process_block(block); + } + decoder.decode_if_ready(max_results) +} + struct StreamErrorLogger { label: &'static str, state: Mutex, @@ -1874,7 +1919,8 @@ pub async fn run_ft2_decoder( let mut active = state_rx.borrow().ft2_decode_enabled && matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB); let mut ft2_buf: Vec = Vec::new(); - let mut last_slot: i64 = -1; + let mut pending_decode_samples: usize = 0; + let mut recent_decodes: HashMap = HashMap::new(); loop { if !active { @@ -1890,8 +1936,9 @@ pub async fn run_ft2_decoder( last_reset_seq = state.ft2_decode_reset_seq; decoder.reset(); ft2_buf.clear(); + pending_decode_samples = 0; + recent_decodes.clear(); } - last_slot = -1; } Err(_) => break, } @@ -1902,17 +1949,6 @@ pub async fn run_ft2_decoder( recv = pcm_rx.recv() => { match recv { Ok(frame) => { - let now_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - Ok(dur) => dur.as_millis() as i64, - Err(_) => 0, - }; - let slot = now_ms / 3_750; - if slot != last_slot { - last_slot = slot; - decoder.reset(); - ft2_buf.clear(); - } - let reset_seq = { let state = state_rx.borrow(); state.ft2_decode_reset_seq @@ -1921,6 +1957,8 @@ pub async fn run_ft2_decoder( last_reset_seq = reset_seq; decoder.reset(); ft2_buf.clear(); + pending_decode_samples = 0; + recent_decodes.clear(); } let mut mono = downmix_mono(frame, channels); @@ -1930,12 +1968,15 @@ pub async fn run_ft2_decoder( break; }; ft2_buf.extend_from_slice(&resampled); + pending_decode_samples += resampled.len(); + retain_ft2_window(&mut ft2_buf); - while ft2_buf.len() >= decoder.block_size() { - let block: Vec = ft2_buf.drain(..decoder.block_size()).collect(); + while pending_decode_samples >= FT2_ASYNC_TRIGGER_SAMPLES + && ft2_buf.len() >= FT2_ASYNC_BUFFER_SAMPLES + { + pending_decode_samples -= FT2_ASYNC_TRIGGER_SAMPLES; let results = tokio::task::block_in_place(|| { - decoder.process_block(&block); - decoder.decode_if_ready(100) + decode_ft2_window(&mut decoder, &ft2_buf, 100) }); if !results.is_empty() { for res in results { @@ -1956,6 +1997,13 @@ pub async fn run_ft2_decoder( }, message: res.text, }; + if !should_emit_ft2_decode( + &mut recent_decodes, + &msg.message, + msg.freq_hz, + ) { + continue; + } histories.record_ft2_message(msg.clone()); let _ = decode_tx.send(DecodedMessage::Ft2(msg)); } @@ -1978,11 +2026,14 @@ pub async fn run_ft2_decoder( last_reset_seq = state.ft2_decode_reset_seq; decoder.reset(); ft2_buf.clear(); + pending_decode_samples = 0; + recent_decodes.clear(); } if !active { decoder.reset(); ft2_buf.clear(); - last_slot = -1; + pending_decode_samples = 0; + recent_decodes.clear(); } else { pcm_rx = pcm_rx.resubscribe(); } @@ -2443,23 +2494,12 @@ async fn run_background_ft2_decoder( } }; let mut ft2_buf: Vec = Vec::new(); - let mut last_slot: i64 = -1; + let mut pending_decode_samples: usize = 0; + let mut recent_decodes: HashMap = HashMap::new(); loop { match pcm_rx.recv().await { Ok(frame) => { - let now_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) - { - Ok(dur) => dur.as_millis() as i64, - Err(_) => 0, - }; - let slot = now_ms / 3_750; - if slot != last_slot { - last_slot = slot; - decoder.reset(); - ft2_buf.clear(); - } - let mut mono = downmix_mono(frame, channels); apply_decode_audio_gate(&mut mono); let Some(resampled) = resample_to_12k(&mono, sample_rate) else { @@ -2470,12 +2510,15 @@ async fn run_background_ft2_decoder( break; }; ft2_buf.extend_from_slice(&resampled); + pending_decode_samples += resampled.len(); + retain_ft2_window(&mut ft2_buf); - while ft2_buf.len() >= decoder.block_size() { - let block: Vec = ft2_buf.drain(..decoder.block_size()).collect(); + while pending_decode_samples >= FT2_ASYNC_TRIGGER_SAMPLES + && ft2_buf.len() >= FT2_ASYNC_BUFFER_SAMPLES + { + pending_decode_samples -= FT2_ASYNC_TRIGGER_SAMPLES; let results = tokio::task::block_in_place(|| { - decoder.process_block(&block); - decoder.decode_if_ready(100) + decode_ft2_window(&mut decoder, &ft2_buf, 100) }); for res in results { let abs_freq_hz = base_freq_hz as f64 + res.freq_hz as f64; @@ -2490,6 +2533,13 @@ async fn run_background_ft2_decoder( }, message: res.text, }; + if !should_emit_ft2_decode( + &mut recent_decodes, + &msg.message, + msg.freq_hz, + ) { + continue; + } let _ = decode_tx.send(DecodedMessage::Ft2(msg)); } }