[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:
+270
-4
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user