[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 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-14 18:50:08 +01:00
parent a1b9b7f762
commit 8eae376c56
25 changed files with 676 additions and 15 deletions
+270 -4
View File
@@ -24,9 +24,9 @@ use trx_aprs::AprsDecoder;
use trx_core::audio::{
parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame,
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_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_AIS_DECODE, AUDIO_MSG_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_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,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE,
@@ -157,6 +157,7 @@ pub struct DecoderHistories {
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
}
@@ -169,6 +170,7 @@ impl DecoderHistories {
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
})
}
@@ -363,6 +365,35 @@ impl DecoderHistories {
self.ft8.lock().expect("ft8 history mutex poisoned").clear();
}
// --- FT4 ---
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
let cutoff = Instant::now() - FT8_HISTORY_RETENTION;
while let Some((ts, _)) = history.front() {
if *ts < cutoff {
history.pop_front();
} else {
break;
}
}
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = self.ft4.lock().expect("ft4 history mutex poisoned");
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = self.ft4.lock().expect("ft4 history mutex poisoned");
Self::prune_ft4(&mut h);
h.iter().map(|(_, msg)| msg.clone()).collect()
}
pub fn clear_ft4_history(&self) {
self.ft4.lock().expect("ft4 history mutex poisoned").clear();
}
// --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
@@ -404,8 +435,9 @@ impl DecoderHistories {
let hf_aprs = self.hf_aprs.lock().map(|h| h.len()).unwrap_or(0);
let cw = self.cw.lock().map(|h| h.len()).unwrap_or(0);
let ft8 = self.ft8.lock().map(|h| h.len()).unwrap_or(0);
let ft4 = self.ft4.lock().map(|h| h.len()).unwrap_or(0);
let wspr = self.wspr.lock().map(|h| h.len()).unwrap_or(0);
ais + vdes + aprs + hf_aprs + cw + ft8 + wspr
ais + vdes + aprs + hf_aprs + cw + ft8 + ft4 + wspr
}
}
@@ -1646,6 +1678,148 @@ pub async fn run_ft8_decoder(
}
}
/// Run the FT4 decoder task. Mirrors FT8 but uses FT4 protocol (7.5s slots).
pub async fn run_ft4_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>,
) {
info!("FT4 decoder started ({}Hz, {} ch)", sample_rate, channels);
let mut decoder = match Ft8Decoder::new_ft4(FT8_SAMPLE_RATE) {
Ok(decoder) => decoder,
Err(err) => {
warn!("FT4 decoder init failed: {}", err);
return;
}
};
let mut last_reset_seq: u64 = 0;
let mut active = state_rx.borrow().ft4_decode_enabled
&& matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB);
let mut ft4_buf: Vec<f32> = Vec::new();
let mut last_slot: i64 = -1;
loop {
if !active {
match state_rx.changed().await {
Ok(()) => {
let state = state_rx.borrow();
active = state.ft4_decode_enabled
&& matches!(state.status.mode, RigMode::DIG | RigMode::USB);
if active {
pcm_rx = pcm_rx.resubscribe();
}
if state.ft4_decode_reset_seq != last_reset_seq {
last_reset_seq = state.ft4_decode_reset_seq;
decoder.reset();
ft4_buf.clear();
}
last_slot = -1;
}
Err(_) => break,
}
continue;
}
tokio::select! {
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,
Err(_) => 0,
};
// FT4 slot period is 7.5s; use now * 2 / 15 for integer slot index
let slot = now * 2 / 15;
if slot != last_slot {
last_slot = slot;
decoder.reset();
ft4_buf.clear();
}
let reset_seq = {
let state = state_rx.borrow();
state.ft4_decode_reset_seq
};
if reset_seq != last_reset_seq {
last_reset_seq = reset_seq;
decoder.reset();
ft4_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 {
warn!("FT4 decoder: unsupported sample rate {}", sample_rate);
break;
};
ft4_buf.extend_from_slice(&resampled);
while ft4_buf.len() >= decoder.block_size() {
let block: Vec<f32> = ft4_buf.drain(..decoder.block_size()).collect();
let results = tokio::task::block_in_place(|| {
decoder.process_block(&block);
decoder.decode_if_ready(100)
});
if !results.is_empty() {
for res in results {
let ts_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_millis() as i64,
Err(_) => 0,
};
let base_freq_hz = state_rx.borrow().status.freq.hz as f64;
let abs_freq_hz = base_freq_hz + res.freq_hz as f64;
let msg = Ft8Message {
ts_ms,
snr_db: res.snr_db,
dt_s: res.dt_s,
freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
abs_freq_hz as f32
} else {
res.freq_hz
},
message: res.text,
};
histories.record_ft4_message(msg.clone());
let _ = decode_tx.send(DecodedMessage::Ft4(msg));
}
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("FT4 decoder: dropped {} PCM frames", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
changed = state_rx.changed() => {
match changed {
Ok(()) => {
let state = state_rx.borrow();
active = state.ft4_decode_enabled
&& matches!(state.status.mode, RigMode::DIG | RigMode::USB);
if state.ft4_decode_reset_seq != last_reset_seq {
last_reset_seq = state.ft4_decode_reset_seq;
decoder.reset();
ft4_buf.clear();
}
if !active {
decoder.reset();
ft4_buf.clear();
last_slot = -1;
} else {
pcm_rx = pcm_rx.resubscribe();
}
}
Err(_) => break,
}
}
}
}
}
/// Run the WSPR decoder task. Mirrors FT8 lifecycle/slot behavior.
///
/// Note: decoding engine integration is intentionally staged; this task already
@@ -1997,6 +2171,85 @@ async fn run_background_ft8_decoder(
}
}
async fn run_background_ft4_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
base_freq_hz: u64,
decode_tx: broadcast::Sender<DecodedMessage>,
) {
info!(
"Background FT4 decoder started ({}Hz, {} ch @ {} Hz)",
sample_rate, channels, base_freq_hz
);
let mut decoder = match Ft8Decoder::new_ft4(FT8_SAMPLE_RATE) {
Ok(decoder) => decoder,
Err(err) => {
warn!("Background FT4 decoder init failed: {}", err);
return;
}
};
let mut ft4_buf: Vec<f32> = Vec::new();
let mut last_slot: i64 = -1;
loop {
match pcm_rx.recv().await {
Ok(frame) => {
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
{
Ok(dur) => dur.as_secs() as i64,
Err(_) => 0,
};
// FT4 slot period is 7.5s; use now * 2 / 15 for integer slot index
let slot = now * 2 / 15;
if slot != last_slot {
last_slot = slot;
decoder.reset();
ft4_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 {
warn!(
"Background FT4 decoder: unsupported sample rate {}",
sample_rate
);
break;
};
ft4_buf.extend_from_slice(&resampled);
while ft4_buf.len() >= decoder.block_size() {
let block: Vec<f32> = ft4_buf.drain(..decoder.block_size()).collect();
let results = tokio::task::block_in_place(|| {
decoder.process_block(&block);
decoder.decode_if_ready(100)
});
for res in results {
let abs_freq_hz = base_freq_hz as f64 + res.freq_hz as f64;
let msg = Ft8Message {
ts_ms: current_timestamp_ms(),
snr_db: res.snr_db,
dt_s: res.dt_s,
freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
abs_freq_hz as f32
} else {
res.freq_hz
},
message: res.text,
};
let _ = decode_tx.send(DecodedMessage::Ft4(msg));
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("Background FT4 decoder: dropped {} PCM frames", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
async fn run_background_wspr_decoder(
sample_rate: u32,
channels: u16,
@@ -2198,6 +2451,7 @@ async fn handle_audio_client(
push_history!(histories.snapshot_aprs_history(), DecodedMessage::Aprs, AUDIO_MSG_APRS_DECODE);
push_history!(histories.snapshot_hf_aprs_history(), DecodedMessage::HfAprs, AUDIO_MSG_HF_APRS_DECODE);
push_history!(histories.snapshot_ft8_history(), DecodedMessage::Ft8, AUDIO_MSG_FT8_DECODE);
push_history!(histories.snapshot_ft4_history(), DecodedMessage::Ft4, AUDIO_MSG_FT4_DECODE);
push_history!(histories.snapshot_wspr_history(), DecodedMessage::Wspr, AUDIO_MSG_WSPR_DECODE);
push_history!(histories.snapshot_cw_history(), DecodedMessage::Cw, AUDIO_MSG_CW_DECODE);
@@ -2283,6 +2537,7 @@ async fn handle_audio_client(
DecodedMessage::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
};
if let Ok(json) = serde_json::to_vec(&msg) {
@@ -2308,6 +2563,7 @@ async fn handle_audio_client(
DecodedMessage::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
};
if let Ok(json) = serde_json::to_vec(&msg) {
@@ -2438,6 +2694,16 @@ async fn handle_audio_client(
)
.await;
}),
"ft4" => tokio::spawn(async move {
run_background_ft4_decoder(
sr,
ch_count,
task_rx,
base_freq_hz,
decode_tx,
)
.await;
}),
"wspr" => tokio::spawn(async move {
run_background_wspr_decoder(
sr,
+15
View File
@@ -726,6 +726,21 @@ fn spawn_rig_audio_stack(
}
}));
// Spawn FT4 decoder task
let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone();
let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_ch = rig_cfg.audio.channels;
let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
}
}));
// Spawn WSPR decoder task
let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone();
+12
View File
@@ -470,6 +470,12 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetFt4DecodeEnabled(en) => {
ctx.state.ft4_decode_enabled = en;
info!("FT4 decode {}", if en { "enabled" } else { "disabled" });
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWsprDecodeEnabled(en) => {
ctx.state.wspr_decode_enabled = en;
info!("WSPR decode {}", if en { "enabled" } else { "disabled" });
@@ -505,6 +511,12 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetFt4Decoder => {
ctx.histories.clear_ft4_history();
ctx.state.ft4_decode_reset_seq += 1;
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetWsprDecoder => {
ctx.histories.clear_wspr_history();
ctx.state.wspr_decode_reset_seq += 1;