From 8eae376c56b497f5ff8b42010cb70dd39ae5e8bd Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 14 Mar 2026 18:50:08 +0100 Subject: [PATCH] [feat](trx-rs): add FT4 decoder support Reuse the existing ft8_lib C library (FTX_PROTOCOL_FT4) and FT8 decoder infrastructure to add FT4 decoding across the full stack. Changes: - trx-ft8: add protocol param to ft8_decoder_create; add Ft8Decoder::new_ft4() - trx-core: DecodedMessage::Ft4 variant, AUDIO_MSG_FT4_DECODE (0x14), ft4_decode_enabled/ft4_decode_reset_seq state, SetFt4DecodeEnabled/ ResetFt4Decoder commands, protocol mapping - trx-server: DecoderHistories::ft4, run_ft4_decoder (7.5s slots via now*2/15), run_background_ft4_decoder, history push/replay, decoder task spawn - trx-frontend-http: ft4_history in FrontendRuntimeContext, toggle/clear endpoints, /ft4.js route, bookmark/scheduler/background decode support, DecodeHistoryPayload ft4 field - web: ft4.js plugin (7.5s period timer, reuses FT8 CSS/map infra), FT4 subtab in index.html, app.js dispatch (onServerFt4/Batch, restoreFt4History), decode-history-worker HISTORY_GROUP_KEYS Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stanislaw Grams --- src/decoders/trx-ft8/src/ft8_wrapper.c | 4 +- src/decoders/trx-ft8/src/lib.rs | 26 ++ src/trx-client/src/audio_client.rs | 3 +- src/trx-client/src/main.rs | 3 + src/trx-client/src/remote_client.rs | 1 + src/trx-client/trx-frontend/src/lib.rs | 3 + .../trx-frontend-http/assets/web/app.js | 25 +- .../assets/web/decode-history-worker.js | 2 +- .../trx-frontend-http/assets/web/index.html | 21 ++ .../assets/web/plugins/ft4.js | 191 ++++++++++++ .../trx-frontend/trx-frontend-http/src/api.rs | 43 ++- .../trx-frontend-http/src/audio.rs | 37 +++ .../src/background_decode.rs | 2 +- .../trx-frontend-http/src/scheduler.rs | 3 + .../trx-frontend-http/src/status.rs | 1 + src/trx-core/src/audio.rs | 2 + src/trx-core/src/decode.rs | 2 + src/trx-core/src/rig/command.rs | 2 + src/trx-core/src/rig/controller/handlers.rs | 2 + src/trx-core/src/rig/state.rs | 11 + src/trx-protocol/src/mapping.rs | 4 + src/trx-protocol/src/types.rs | 2 + src/trx-server/src/audio.rs | 274 +++++++++++++++++- src/trx-server/src/main.rs | 15 + src/trx-server/src/rig_task.rs | 12 + 25 files changed, 676 insertions(+), 15 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft4.js diff --git a/src/decoders/trx-ft8/src/ft8_wrapper.c b/src/decoders/trx-ft8/src/ft8_wrapper.c index fbb9762..91533a7 100644 --- a/src/decoders/trx-ft8/src/ft8_wrapper.c +++ b/src/decoders/trx-ft8/src/ft8_wrapper.c @@ -108,7 +108,7 @@ typedef struct 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* ft8_decoder_create(int sample_rate, float f_min, float f_max, int time_osr, int freq_osr, int protocol) { ft8_decoder_t* dec = (ft8_decoder_t*)calloc(1, sizeof(ft8_decoder_t)); if (!dec) @@ -120,7 +120,7 @@ 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 = FTX_PROTOCOL_FT8; + dec->cfg.protocol = (protocol == 0) ? FTX_PROTOCOL_FT4 : FTX_PROTOCOL_FT8; hashtable_init(); monitor_init(&dec->mon, &dec->cfg); diff --git a/src/decoders/trx-ft8/src/lib.rs b/src/decoders/trx-ft8/src/lib.rs index cacbb53..9aff1d6 100644 --- a/src/decoders/trx-ft8/src/lib.rs +++ b/src/decoders/trx-ft8/src/lib.rs @@ -37,6 +37,7 @@ extern "C" { f_max: c_float, time_osr: c_int, freq_osr: c_int, + protocol: c_int, ) -> *mut c_void; fn ft8_decoder_free(dec: *mut c_void); fn ft8_decoder_block_size(dec: *const c_void) -> c_int; @@ -69,6 +70,7 @@ impl Ft8Decoder { 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; @@ -84,6 +86,30 @@ impl Ft8Decoder { } } + pub fn new_ft4(sample_rate: u32) -> Result { + 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, + 0, // FTX_PROTOCOL_FT4 + ); + 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()); + } + Ok(Self { + inner, + block_size, + sample_rate, + }) + } + } + pub fn block_size(&self) -> usize { self.block_size } diff --git a/src/trx-client/src/audio_client.rs b/src/trx-client/src/audio_client.rs index dc82b25..2d85e98 100644 --- a/src/trx-client/src/audio_client.rs +++ b/src/trx-client/src/audio_client.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use trx_core::audio::{ parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, - AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_HF_APRS_DECODE, + AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, @@ -287,6 +287,7 @@ async fn handle_audio_connection( | AUDIO_MSG_HF_APRS_DECODE | AUDIO_MSG_CW_DECODE | AUDIO_MSG_FT8_DECODE + | AUDIO_MSG_FT4_DECODE | AUDIO_MSG_WSPR_DECODE, payload, )) => { diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 84fd996..32b28d1 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -363,6 +363,9 @@ async fn async_init() -> DynResult { history.push_back((now, message)); } } + DecodedMessage::Ft4(_) => { + // FT4 history is managed by the frontend HTTP audio collector + } DecodedMessage::Wspr(message) => { if let Ok(mut history) = wspr_history.lock() { history.push_back((now, message)); diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 88874dc..22b1ee1 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -776,6 +776,7 @@ mod tests { hf_aprs_decode_enabled: false, cw_decode_enabled: false, ft8_decode_enabled: false, + ft4_decode_enabled: false, wspr_decode_enabled: false, cw_auto: true, cw_wpm: 15, diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index d02540a..dcced76 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -202,6 +202,8 @@ pub struct FrontendRuntimeContext { pub cw_history: Arc>>, /// FT8 decode history (timestamp, message) pub ft8_history: Arc>>, + /// FT4 decode history (timestamp, message) + pub ft4_history: Arc>>, /// WSPR decode history (timestamp, message) pub wspr_history: Arc>>, /// Authentication tokens for HTTP-JSON frontend @@ -283,6 +285,7 @@ impl FrontendRuntimeContext { hf_aprs_history: Arc::new(Mutex::new(VecDeque::new())), cw_history: Arc::new(Mutex::new(VecDeque::new())), ft8_history: Arc::new(Mutex::new(VecDeque::new())), + ft4_history: Arc::new(Mutex::new(VecDeque::new())), wspr_history: Arc::new(Mutex::new(VecDeque::new())), auth_tokens: HashSet::new(), sse_clients: Arc::new(AtomicUsize::new(0)), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 6a1ea36..d9189a0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -214,11 +214,13 @@ function applyAuthRestrictions() { "ais-clear-btn", "vdes-clear-btn", "ft8-decode-toggle-btn", + "ft4-decode-toggle-btn", "wspr-decode-toggle-btn", "hf-aprs-decode-toggle-btn", "cw-auto", "aprs-clear-btn", "ft8-clear-btn", + "ft4-clear-btn", "wspr-clear-btn", "cw-clear-btn" ]; @@ -2847,6 +2849,13 @@ function render(update) { ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.color = ft8On ? "#00d17f" : ""; } + const ft4ToggleBtn = document.getElementById("ft4-decode-toggle-btn"); + if (ft4ToggleBtn) { + const ft4On = !!update.ft4_decode_enabled; + ft4ToggleBtn.textContent = ft4On ? "Disable FT4" : "Enable FT4"; + ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : ""; + ft4ToggleBtn.style.color = ft4On ? "#00d17f" : ""; + } const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn"); if (wsprToggleBtn) { const wsprOn = !!update.wspr_decode_enabled; @@ -7312,6 +7321,7 @@ function updateDecodeStatus(text) { const aprs = document.getElementById("aprs-status"); const cw = document.getElementById("cw-status"); const ft8 = document.getElementById("ft8-status"); + const ft4 = document.getElementById("ft4-status"); setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text); const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text; setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText); @@ -7319,6 +7329,7 @@ function updateDecodeStatus(text) { const cwText = text === "Connected, listening for packets" ? "Connected, listening for CW" : text; setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText); if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text; + if (ft4 && ft4.textContent !== "Receiving") ft4.textContent = text; } function dispatchDecodeMessage(msg) { if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg); @@ -7327,6 +7338,7 @@ function dispatchDecodeMessage(msg) { if (msg.type === "hf_aprs" && window.onServerHfAprs) window.onServerHfAprs(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); + if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg); if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg); } @@ -7355,6 +7367,10 @@ function dispatchDecodeBatch(batch) { window.onServerFt8Batch(batch); return; } + if (type === "ft4" && window.onServerFt4Batch) { + window.onServerFt4Batch(batch); + return; + } if (type === "wspr" && window.onServerWsprBatch) { window.onServerWsprBatch(batch); return; @@ -7425,6 +7441,10 @@ function restoreDecodeHistoryGroup(kind, messages) { window.restoreFt8History(messages); return; } + if (kind === "ft4" && window.restoreFt4History) { + window.restoreFt4History(messages); + return; + } if (kind === "wspr" && window.restoreWsprHistory) { window.restoreWsprHistory(messages); return; @@ -7441,6 +7461,7 @@ function connectDecode() { if (window.resetAprsHistoryView) window.resetAprsHistoryView(); if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView(); + if (window.resetFt4HistoryView) window.resetFt4HistoryView(); if (window.resetWsprHistoryView) window.resetWsprHistoryView(); // Buffer live messages until history fetch settles so history always appears @@ -7512,7 +7533,7 @@ function connectDecode() { function totalDecodeHistoryMessages(groups) { if (!groups || typeof groups !== "object") return 0; - return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "wspr"] + return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"] .reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0); } @@ -7523,7 +7544,7 @@ function connectDecode() { setDecodeHistoryReplayActive(true); updateHistoryReplayOverlay(); } - for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "wspr"]) { + for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"]) { const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : []; if (messages.length === 0) continue; for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/decode-history-worker.js index 241ef51..138e9a2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/decode-history-worker.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/decode-history-worker.js @@ -1,5 +1,5 @@ const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; -const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "wspr"]; +const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"]; function decodeCborUint(view, bytes, state, additional) { const offset = state.offset; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index e429986..11e9dd3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -374,6 +374,7 @@ + @@ -425,6 +426,7 @@ + @@ -634,6 +636,24 @@
+