From 316d624c95283dd7f22d8f45defd16dade87bacf Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 1 Apr 2026 21:49:17 +0200 Subject: [PATCH] [feat](trx-ftx): gate FT2 support behind ft2 feature flag, disabled by default FT2 decoder implementation, protocol constants, server decoder tasks, background decode, and registry entry are now conditional on the ft2 feature. Lightweight types (enum variants, commands, state fields) remain unconditional to avoid cascading cfg noise in macros and serde. Enable with: cargo build -p trx-server --features ft2 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Stan Grams --- src/decoders/trx-ftx/Cargo.toml | 4 +++ src/decoders/trx-ftx/src/common/decode.rs | 26 +++++++++++++++--- src/decoders/trx-ftx/src/common/monitor.rs | 1 + src/decoders/trx-ftx/src/common/protocol.rs | 20 +++++++++++++- src/decoders/trx-ftx/src/decoder.rs | 29 ++++++++++++++++++--- src/decoders/trx-ftx/src/lib.rs | 1 + src/trx-protocol/Cargo.toml | 4 +++ src/trx-protocol/src/decoders.rs | 1 + src/trx-server/Cargo.toml | 1 + src/trx-server/src/audio.rs | 18 ++++++++++++- src/trx-server/src/main.rs | 29 ++++++++++++--------- 11 files changed, 112 insertions(+), 22 deletions(-) diff --git a/src/decoders/trx-ftx/Cargo.toml b/src/decoders/trx-ftx/Cargo.toml index 8bcb92e..ae02b57 100644 --- a/src/decoders/trx-ftx/Cargo.toml +++ b/src/decoders/trx-ftx/Cargo.toml @@ -7,6 +7,10 @@ name = "trx-ftx" version.workspace = true edition = "2021" +[features] +default = [] +ft2 = [] + [dependencies] rustfft = "6" realfft = "3" diff --git a/src/decoders/trx-ftx/src/common/decode.rs b/src/decoders/trx-ftx/src/common/decode.rs index 560a3ad..d05843e 100644 --- a/src/decoders/trx-ftx/src/common/decode.rs +++ b/src/decoders/trx-ftx/src/common/decode.rs @@ -6,6 +6,7 @@ //! //! Ports `decode.c` from ft8_lib. +#[cfg(feature = "ft2")] use num_complex::Complex32; use super::constants::*; @@ -37,6 +38,7 @@ pub struct FtxMessage { pub hash: u16, } +#[cfg(feature = "ft2")] pub(crate) fn wf_elem_to_complex(elem: WfElem) -> Complex32 { Complex32::new(elem.re, elem.im) } @@ -104,12 +106,20 @@ pub fn ftx_find_candidates( max_candidates: usize, min_score: i32, ) -> Vec { + #[cfg(feature = "ft2")] let is_ft2 = wf.protocol == FtxProtocol::Ft2; + #[cfg(not(feature = "ft2"))] + let is_ft2 = false; let num_tones = if wf.protocol.uses_ft4_layout() { 4 } else { 8 }; let (time_offset_min, time_offset_max) = if is_ft2 { - let max = (wf.num_blocks as i32 - FT2_NN as i32 + 2).max(-1); - (-2i16, max as i16) + #[cfg(feature = "ft2")] + { + let max = (wf.num_blocks as i32 - FT2_NN as i32 + 2).max(-1); + (-2i16, max as i16) + } + #[cfg(not(feature = "ft2"))] + unreachable!() } else if wf.protocol == FtxProtocol::Ft4 { let max = (wf.num_blocks as i32 - FT4_NN as i32 + 34).max(-33); (-34i16, max as i16) @@ -135,7 +145,10 @@ pub fn ftx_find_candidates( }; let score = if is_ft2 { - crate::ft2::ft2_sync_score(wf, &cand) + #[cfg(feature = "ft2")] + { crate::ft2::ft2_sync_score(wf, &cand) } + #[cfg(not(feature = "ft2"))] + unreachable!() } else if wf.protocol.uses_ft4_layout() { crate::ft4::ft4_sync_score(wf, &cand) } else { @@ -267,6 +280,7 @@ pub fn ftx_decode_candidate( ) -> Option { let mut log174 = [0.0f32; FTX_LDPC_N]; + #[cfg(feature = "ft2")] if wf.protocol == FtxProtocol::Ft2 { crate::ft2::ft2_extract_likelihood(wf, cand, &mut log174); } else if wf.protocol.uses_ft4_layout() { @@ -274,6 +288,12 @@ pub fn ftx_decode_candidate( } else { crate::ft8::ft8_extract_likelihood(wf, cand, &mut log174); } + #[cfg(not(feature = "ft2"))] + if wf.protocol.uses_ft4_layout() { + crate::ft4::ft4_extract_likelihood(wf, cand, &mut log174); + } else { + crate::ft8::ft8_extract_likelihood(wf, cand, &mut log174); + } ftx_normalize_logl(&mut log174); diff --git a/src/decoders/trx-ftx/src/common/monitor.rs b/src/decoders/trx-ftx/src/common/monitor.rs index e324567..5489fb0 100644 --- a/src/decoders/trx-ftx/src/common/monitor.rs +++ b/src/decoders/trx-ftx/src/common/monitor.rs @@ -267,6 +267,7 @@ mod tests { assert_eq!(mon.block_size, 576); // 12000 * 0.048 } + #[cfg(feature = "ft2")] #[test] fn monitor_block_size_ft2() { let cfg = MonitorConfig { diff --git a/src/decoders/trx-ftx/src/common/protocol.rs b/src/decoders/trx-ftx/src/common/protocol.rs index 9353325..4dc56e1 100644 --- a/src/decoders/trx-ftx/src/common/protocol.rs +++ b/src/decoders/trx-ftx/src/common/protocol.rs @@ -7,6 +7,7 @@ pub enum FtxProtocol { Ft4, Ft8, + #[cfg(feature = "ft2")] Ft2, } @@ -16,6 +17,7 @@ impl FtxProtocol { match self { Self::Ft8 => FT8_SYMBOL_PERIOD, Self::Ft4 => FT4_SYMBOL_PERIOD, + #[cfg(feature = "ft2")] Self::Ft2 => FT2_SYMBOL_PERIOD, } } @@ -25,13 +27,18 @@ impl FtxProtocol { match self { Self::Ft8 => FT8_SLOT_TIME, Self::Ft4 => FT4_SLOT_TIME, + #[cfg(feature = "ft2")] Self::Ft2 => FT2_SLOT_TIME, } } /// Whether this protocol uses FT4-style channel layout (FT4 and FT2). pub fn uses_ft4_layout(self) -> bool { - matches!(self, Self::Ft4 | Self::Ft2) + #[cfg(feature = "ft2")] + if matches!(self, Self::Ft2) { + return true; + } + matches!(self, Self::Ft4) } /// Number of data symbols. @@ -98,7 +105,9 @@ pub const FT4_SYMBOL_PERIOD: f32 = 0.048; pub const FT4_SLOT_TIME: f32 = 7.5; // FT2 timing +#[cfg(feature = "ft2")] pub const FT2_SYMBOL_PERIOD: f32 = 0.024; +#[cfg(feature = "ft2")] pub const FT2_SLOT_TIME: f32 = 3.75; // FT8 symbol counts @@ -117,11 +126,17 @@ pub const FT4_NUM_SYNC: usize = 4; pub const FT4_SYNC_OFFSET: usize = 33; // FT2 reuses FT4 layout +#[cfg(feature = "ft2")] pub const FT2_ND: usize = FT4_ND; +#[cfg(feature = "ft2")] pub const FT2_NR: usize = FT4_NR; +#[cfg(feature = "ft2")] pub const FT2_NN: usize = FT4_NN; +#[cfg(feature = "ft2")] pub const FT2_LENGTH_SYNC: usize = FT4_LENGTH_SYNC; +#[cfg(feature = "ft2")] pub const FT2_NUM_SYNC: usize = FT4_NUM_SYNC; +#[cfg(feature = "ft2")] pub const FT2_SYNC_OFFSET: usize = FT4_SYNC_OFFSET; // LDPC parameters @@ -147,12 +162,14 @@ mod tests { fn protocol_timing() { assert!((FtxProtocol::Ft8.symbol_period() - 0.160).abs() < 1e-6); assert!((FtxProtocol::Ft4.symbol_period() - 0.048).abs() < 1e-6); + #[cfg(feature = "ft2")] assert!((FtxProtocol::Ft2.symbol_period() - 0.024).abs() < 1e-6); } #[test] fn ft4_layout() { assert!(FtxProtocol::Ft4.uses_ft4_layout()); + #[cfg(feature = "ft2")] assert!(FtxProtocol::Ft2.uses_ft4_layout()); assert!(!FtxProtocol::Ft8.uses_ft4_layout()); } @@ -161,6 +178,7 @@ mod tests { fn symbol_counts() { assert_eq!(FtxProtocol::Ft8.nn(), 79); assert_eq!(FtxProtocol::Ft4.nn(), 105); + #[cfg(feature = "ft2")] assert_eq!(FtxProtocol::Ft2.nn(), 105); } } diff --git a/src/decoders/trx-ftx/src/decoder.rs b/src/decoders/trx-ftx/src/decoder.rs index f6cab5e..0df5b35 100644 --- a/src/decoders/trx-ftx/src/decoder.rs +++ b/src/decoders/trx-ftx/src/decoder.rs @@ -19,9 +19,13 @@ const DEFAULT_F_MAX_HZ: f32 = 3000.0; const DEFAULT_TIME_OSR: i32 = 2; const DEFAULT_FREQ_OSR: i32 = 2; +#[cfg(feature = "ft2")] const FT2_F_MIN_HZ: f32 = 200.0; +#[cfg(feature = "ft2")] const FT2_F_MAX_HZ: f32 = 5000.0; +#[cfg(feature = "ft2")] const FT2_TIME_OSR: i32 = 8; +#[cfg(feature = "ft2")] const FT2_FREQ_OSR: i32 = 4; const MAX_LDPC_ITERATIONS: usize = 20; @@ -37,7 +41,7 @@ pub struct Ft8DecodeResult { pub freq_hz: f32, } -/// FTx decoder instance supporting FT8, FT4, and FT2 protocols. +/// FTx decoder instance supporting FT8, FT4, and (optionally) FT2 protocols. pub struct Ft8Decoder { protocol: FtxProtocol, sample_rate: u32, @@ -46,6 +50,7 @@ pub struct Ft8Decoder { monitor: Monitor, callsign_hash: CallsignHashTable, // FT2-specific pipeline + #[cfg(feature = "ft2")] ft2_pipeline: Option, } @@ -61,12 +66,14 @@ impl Ft8Decoder { } /// Create a new FT2 decoder. + #[cfg(feature = "ft2")] pub fn new_ft2(sample_rate: u32) -> Result { Self::new_with_protocol(sample_rate, FtxProtocol::Ft2) } fn new_with_protocol(sample_rate: u32, protocol: FtxProtocol) -> Result { let (f_min, f_max, time_osr, freq_osr) = match protocol { + #[cfg(feature = "ft2")] FtxProtocol::Ft2 => (FT2_F_MIN_HZ, FT2_F_MAX_HZ, FT2_TIME_OSR, FT2_FREQ_OSR), _ => ( DEFAULT_F_MIN_HZ, @@ -92,9 +99,16 @@ impl Ft8Decoder { return Err(format!("invalid {:?} block size", protocol)); } - let window_samples = match protocol { - FtxProtocol::Ft2 => crate::ft2::FT2_NMAX, - _ => { + let window_samples = { + #[cfg(feature = "ft2")] + if protocol == FtxProtocol::Ft2 { + crate::ft2::FT2_NMAX + } else { + let slot_time = protocol.slot_time(); + (sample_rate as f32 * slot_time) as usize + } + #[cfg(not(feature = "ft2"))] + { let slot_time = protocol.slot_time(); (sample_rate as f32 * slot_time) as usize } @@ -104,6 +118,7 @@ impl Ft8Decoder { return Err(format!("invalid {:?} analysis window", protocol)); } + #[cfg(feature = "ft2")] let ft2_pipeline = if protocol == FtxProtocol::Ft2 { Some(crate::ft2::Ft2Pipeline::new(sample_rate as i32)) } else { @@ -117,6 +132,7 @@ impl Ft8Decoder { window_samples, monitor, callsign_hash: CallsignHashTable::new(), + #[cfg(feature = "ft2")] ft2_pipeline, }) } @@ -140,6 +156,7 @@ impl Ft8Decoder { pub fn reset(&mut self) { self.monitor.reset(); self.callsign_hash.cleanup(10); + #[cfg(feature = "ft2")] if let Some(ref mut pipe) = self.ft2_pipeline { pipe.reset(); } @@ -151,6 +168,7 @@ impl Ft8Decoder { return; } + #[cfg(feature = "ft2")] if self.protocol == FtxProtocol::Ft2 { // FT2: accumulate raw audio and also feed the monitor if let Some(ref mut pipe) = self.ft2_pipeline { @@ -164,6 +182,7 @@ impl Ft8Decoder { /// Check if enough data has been collected and run the decode. /// Returns decoded messages, or empty if not ready yet. pub fn decode_if_ready(&mut self, max_results: usize) -> Vec { + #[cfg(feature = "ft2")] if self.protocol == FtxProtocol::Ft2 { return self.decode_ft2(max_results); } @@ -232,6 +251,7 @@ impl Ft8Decoder { } /// FT2-specific decode pipeline. + #[cfg(feature = "ft2")] fn decode_ft2(&mut self, max_results: usize) -> Vec { let ft2_results = { let pipe = match self.ft2_pipeline.as_mut() { @@ -295,6 +315,7 @@ mod tests { assert_eq!(dec.block_size(), 576); // 12000 * 0.048 } + #[cfg(feature = "ft2")] #[test] fn ft2_uses_distinct_block_size() { let ft4 = Ft8Decoder::new_ft4(12_000).expect("ft4 decoder"); diff --git a/src/decoders/trx-ftx/src/lib.rs b/src/decoders/trx-ftx/src/lib.rs index 0e2b488..c5f90fa 100644 --- a/src/decoders/trx-ftx/src/lib.rs +++ b/src/decoders/trx-ftx/src/lib.rs @@ -4,6 +4,7 @@ pub mod common; mod decoder; +#[cfg(feature = "ft2")] pub mod ft2; pub mod ft4; pub mod ft8; diff --git a/src/trx-protocol/Cargo.toml b/src/trx-protocol/Cargo.toml index 5ddec5d..c59b355 100644 --- a/src/trx-protocol/Cargo.toml +++ b/src/trx-protocol/Cargo.toml @@ -7,6 +7,10 @@ name = "trx-protocol" version.workspace = true edition = "2021" +[features] +default = [] +ft2 = [] + [dependencies] serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/src/trx-protocol/src/decoders.rs b/src/trx-protocol/src/decoders.rs index 4d93b8f..7d14d2f 100644 --- a/src/trx-protocol/src/decoders.rs +++ b/src/trx-protocol/src/decoders.rs @@ -97,6 +97,7 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[ background_decode: true, bookmark_selectable: true, }, + #[cfg(feature = "ft2")] DecoderDescriptor { id: "ft2", label: "FT2", diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index 25c61d7..cb99214 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -10,6 +10,7 @@ build = "build.rs" [features] default = ["soapysdr"] +ft2 = ["trx-ftx/ft2", "trx-protocol/ft2"] soapysdr = ["trx-backend/soapysdr"] [dependencies] diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 823470f..8a86876 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -4,7 +4,9 @@ //! Audio capture, playback, and TCP streaming for trx-server. -use std::collections::{HashMap, HashSet, VecDeque}; +#[cfg(feature = "ft2")] +use std::collections::HashMap; +use std::collections::{HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -63,8 +65,11 @@ const MAX_HISTORY_ENTRIES: usize = 10_000; /// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs). const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30); const FT8_SAMPLE_RATE: u32 = 12_000; +#[cfg(feature = "ft2")] const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000; +#[cfg(feature = "ft2")] const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000; +#[cfg(feature = "ft2")] 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); @@ -77,6 +82,7 @@ fn current_timestamp_ms() -> i64 { } } +#[cfg(feature = "ft2")] fn retain_ft2_window(buf: &mut Vec) { if buf.len() > FT2_ASYNC_BUFFER_SAMPLES { let excess = buf.len() - FT2_ASYNC_BUFFER_SAMPLES; @@ -84,10 +90,12 @@ fn retain_ft2_window(buf: &mut Vec) { } } +#[cfg(feature = "ft2")] fn prune_recent_ft2_decodes(recent: &mut HashMap, now: Instant) { recent.retain(|_, seen_at| now.duration_since(*seen_at) <= FT2_DEDUPE_RETENTION); } +#[cfg(feature = "ft2")] fn should_emit_ft2_decode(recent: &mut HashMap, text: &str, freq_hz: f32) -> bool { let now = Instant::now(); prune_recent_ft2_decodes(recent, now); @@ -99,6 +107,7 @@ fn should_emit_ft2_decode(recent: &mut HashMap, text: &str, fre true } +#[cfg(feature = "ft2")] fn decode_ft2_window( decoder: &mut Ft8Decoder, samples: &[f32], @@ -561,6 +570,7 @@ impl DecoderHistories { // --- FT2 --- + #[cfg_attr(not(feature = "ft2"), allow(dead_code))] fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) { let cutoff = Instant::now() - FT8_HISTORY_RETENTION; while let Some((ts, _)) = history.front() { @@ -572,6 +582,7 @@ impl DecoderHistories { } } + #[cfg_attr(not(feature = "ft2"), allow(dead_code))] pub fn record_ft2_message(&self, msg: Ft8Message) { let mut h = lock_or_recover(&self.ft2, "ft2_history"); let before = h.len(); @@ -581,6 +592,7 @@ impl DecoderHistories { self.adjust_total_count(before, h.len()); } + #[cfg_attr(not(feature = "ft2"), allow(dead_code))] pub fn snapshot_ft2_history(&self) -> Vec { let mut h = lock_or_recover(&self.ft2, "ft2_history"); let before = h.len(); @@ -2047,6 +2059,7 @@ async fn run_ftx_decoder_inner( } /// Run the FT2 decoder task. Mirrors FT4 but uses FT2 protocol timing. +#[cfg(feature = "ft2")] pub async fn run_ft2_decoder( sample_rate: u32, channels: u16, @@ -2902,6 +2915,7 @@ async fn run_background_ft4_decoder( } } +#[cfg(feature = "ft2")] async fn run_background_ft2_decoder( sample_rate: u32, channels: u16, @@ -3204,6 +3218,7 @@ async fn handle_audio_client( DecodedMessage::Ft4, AUDIO_MSG_FT4_DECODE ); + #[cfg(feature = "ft2")] push_history!( histories.snapshot_ft2_history(), DecodedMessage::Ft2, @@ -3479,6 +3494,7 @@ async fn handle_audio_client( ) .await; }), + #[cfg(feature = "ft2")] "ft2" => tokio::spawn(async move { run_background_ft2_decoder( sr, diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 052f347..6dcf4f1 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -766,19 +766,22 @@ fn spawn_rig_audio_stack( })); // Spawn FT2 decoder task - let ft2_pcm_rx = pcm_tx.subscribe(); - let ft2_state_rx = state_rx.clone(); - let ft2_decode_tx = decode_tx.clone(); - let ft2_sr = rig_cfg.audio.sample_rate; - let ft2_ch = rig_cfg.audio.channels; - let ft2_shutdown_rx = shutdown_rx.clone(); - let ft2_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_ft2_decoder(ft2_sr, ft2_ch as u16, ft2_pcm_rx, ft2_state_rx, ft2_decode_tx, ft2_histories) => {} - _ = wait_for_shutdown(ft2_shutdown_rx) => {} - } - })); + #[cfg(feature = "ft2")] + { + let ft2_pcm_rx = pcm_tx.subscribe(); + let ft2_state_rx = state_rx.clone(); + let ft2_decode_tx = decode_tx.clone(); + let ft2_sr = rig_cfg.audio.sample_rate; + let ft2_ch = rig_cfg.audio.channels; + let ft2_shutdown_rx = shutdown_rx.clone(); + let ft2_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_ft2_decoder(ft2_sr, ft2_ch as u16, ft2_pcm_rx, ft2_state_rx, ft2_decode_tx, ft2_histories) => {} + _ = wait_for_shutdown(ft2_shutdown_rx) => {} + } + })); + } // Spawn WSPR decoder task let wspr_pcm_rx = pcm_tx.subscribe();