[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
+2 -2
View File
@@ -108,7 +108,7 @@ typedef struct
float freq_hz; float freq_hz;
} ft8_decode_result_t; } 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)); ft8_decoder_t* dec = (ft8_decoder_t*)calloc(1, sizeof(ft8_decoder_t));
if (!dec) 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.sample_rate = sample_rate;
dec->cfg.time_osr = time_osr; dec->cfg.time_osr = time_osr;
dec->cfg.freq_osr = freq_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(); hashtable_init();
monitor_init(&dec->mon, &dec->cfg); monitor_init(&dec->mon, &dec->cfg);
+26
View File
@@ -37,6 +37,7 @@ extern "C" {
f_max: c_float, f_max: c_float,
time_osr: c_int, time_osr: c_int,
freq_osr: c_int, freq_osr: c_int,
protocol: c_int,
) -> *mut c_void; ) -> *mut c_void;
fn ft8_decoder_free(dec: *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_block_size(dec: *const c_void) -> c_int;
@@ -69,6 +70,7 @@ impl Ft8Decoder {
F_MAX_HZ, F_MAX_HZ,
TIME_OSR as c_int, TIME_OSR as c_int,
FREQ_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 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 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<Self, String> {
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 { pub fn block_size(&self) -> usize {
self.block_size self.block_size
} }
+2 -1
View File
@@ -24,7 +24,7 @@ use uuid::Uuid;
use trx_core::audio::{ use trx_core::audio::{
parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, 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, 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_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_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_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_HF_APRS_DECODE
| AUDIO_MSG_CW_DECODE | AUDIO_MSG_CW_DECODE
| AUDIO_MSG_FT8_DECODE | AUDIO_MSG_FT8_DECODE
| AUDIO_MSG_FT4_DECODE
| AUDIO_MSG_WSPR_DECODE, | AUDIO_MSG_WSPR_DECODE,
payload, payload,
)) => { )) => {
+3
View File
@@ -363,6 +363,9 @@ async fn async_init() -> DynResult<AppState> {
history.push_back((now, message)); history.push_back((now, message));
} }
} }
DecodedMessage::Ft4(_) => {
// FT4 history is managed by the frontend HTTP audio collector
}
DecodedMessage::Wspr(message) => { DecodedMessage::Wspr(message) => {
if let Ok(mut history) = wspr_history.lock() { if let Ok(mut history) = wspr_history.lock() {
history.push_back((now, message)); history.push_back((now, message));
+1
View File
@@ -776,6 +776,7 @@ mod tests {
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
ft8_decode_enabled: false, ft8_decode_enabled: false,
ft4_decode_enabled: false,
wspr_decode_enabled: false, wspr_decode_enabled: false,
cw_auto: true, cw_auto: true,
cw_wpm: 15, cw_wpm: 15,
+3
View File
@@ -202,6 +202,8 @@ pub struct FrontendRuntimeContext {
pub cw_history: Arc<Mutex<VecDeque<(Instant, CwEvent)>>>, pub cw_history: Arc<Mutex<VecDeque<(Instant, CwEvent)>>>,
/// FT8 decode history (timestamp, message) /// FT8 decode history (timestamp, message)
pub ft8_history: Arc<Mutex<VecDeque<(Instant, Ft8Message)>>>, pub ft8_history: Arc<Mutex<VecDeque<(Instant, Ft8Message)>>>,
/// FT4 decode history (timestamp, message)
pub ft4_history: Arc<Mutex<VecDeque<(Instant, Ft8Message)>>>,
/// WSPR decode history (timestamp, message) /// WSPR decode history (timestamp, message)
pub wspr_history: Arc<Mutex<VecDeque<(Instant, WsprMessage)>>>, pub wspr_history: Arc<Mutex<VecDeque<(Instant, WsprMessage)>>>,
/// Authentication tokens for HTTP-JSON frontend /// Authentication tokens for HTTP-JSON frontend
@@ -283,6 +285,7 @@ impl FrontendRuntimeContext {
hf_aprs_history: Arc::new(Mutex::new(VecDeque::new())), hf_aprs_history: Arc::new(Mutex::new(VecDeque::new())),
cw_history: Arc::new(Mutex::new(VecDeque::new())), cw_history: Arc::new(Mutex::new(VecDeque::new())),
ft8_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())), wspr_history: Arc::new(Mutex::new(VecDeque::new())),
auth_tokens: HashSet::new(), auth_tokens: HashSet::new(),
sse_clients: Arc::new(AtomicUsize::new(0)), sse_clients: Arc::new(AtomicUsize::new(0)),
@@ -214,11 +214,13 @@ function applyAuthRestrictions() {
"ais-clear-btn", "ais-clear-btn",
"vdes-clear-btn", "vdes-clear-btn",
"ft8-decode-toggle-btn", "ft8-decode-toggle-btn",
"ft4-decode-toggle-btn",
"wspr-decode-toggle-btn", "wspr-decode-toggle-btn",
"hf-aprs-decode-toggle-btn", "hf-aprs-decode-toggle-btn",
"cw-auto", "cw-auto",
"aprs-clear-btn", "aprs-clear-btn",
"ft8-clear-btn", "ft8-clear-btn",
"ft4-clear-btn",
"wspr-clear-btn", "wspr-clear-btn",
"cw-clear-btn" "cw-clear-btn"
]; ];
@@ -2847,6 +2849,13 @@ function render(update) {
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
ft8ToggleBtn.style.color = 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"); const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn");
if (wsprToggleBtn) { if (wsprToggleBtn) {
const wsprOn = !!update.wspr_decode_enabled; const wsprOn = !!update.wspr_decode_enabled;
@@ -7312,6 +7321,7 @@ function updateDecodeStatus(text) {
const aprs = document.getElementById("aprs-status"); const aprs = document.getElementById("aprs-status");
const cw = document.getElementById("cw-status"); const cw = document.getElementById("cw-status");
const ft8 = document.getElementById("ft8-status"); const ft8 = document.getElementById("ft8-status");
const ft4 = document.getElementById("ft4-status");
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text); setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text; const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text;
setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText); 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; const cwText = text === "Connected, listening for packets" ? "Connected, listening for CW" : text;
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText); setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text; if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
if (ft4 && ft4.textContent !== "Receiving") ft4.textContent = text;
} }
function dispatchDecodeMessage(msg) { function dispatchDecodeMessage(msg) {
if (msg.type === "ais" && window.onServerAis) window.onServerAis(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 === "hf_aprs" && window.onServerHfAprs) window.onServerHfAprs(msg);
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(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); if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
} }
@@ -7355,6 +7367,10 @@ function dispatchDecodeBatch(batch) {
window.onServerFt8Batch(batch); window.onServerFt8Batch(batch);
return; return;
} }
if (type === "ft4" && window.onServerFt4Batch) {
window.onServerFt4Batch(batch);
return;
}
if (type === "wspr" && window.onServerWsprBatch) { if (type === "wspr" && window.onServerWsprBatch) {
window.onServerWsprBatch(batch); window.onServerWsprBatch(batch);
return; return;
@@ -7425,6 +7441,10 @@ function restoreDecodeHistoryGroup(kind, messages) {
window.restoreFt8History(messages); window.restoreFt8History(messages);
return; return;
} }
if (kind === "ft4" && window.restoreFt4History) {
window.restoreFt4History(messages);
return;
}
if (kind === "wspr" && window.restoreWsprHistory) { if (kind === "wspr" && window.restoreWsprHistory) {
window.restoreWsprHistory(messages); window.restoreWsprHistory(messages);
return; return;
@@ -7441,6 +7461,7 @@ function connectDecode() {
if (window.resetAprsHistoryView) window.resetAprsHistoryView(); if (window.resetAprsHistoryView) window.resetAprsHistoryView();
if (window.resetCwHistoryView) window.resetCwHistoryView(); if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView(); if (window.resetFt8HistoryView) window.resetFt8HistoryView();
if (window.resetFt4HistoryView) window.resetFt4HistoryView();
if (window.resetWsprHistoryView) window.resetWsprHistoryView(); if (window.resetWsprHistoryView) window.resetWsprHistoryView();
// Buffer live messages until history fetch settles so history always appears // Buffer live messages until history fetch settles so history always appears
@@ -7512,7 +7533,7 @@ function connectDecode() {
function totalDecodeHistoryMessages(groups) { function totalDecodeHistoryMessages(groups) {
if (!groups || typeof groups !== "object") return 0; 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); .reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0);
} }
@@ -7523,7 +7544,7 @@ function connectDecode() {
setDecodeHistoryReplayActive(true); setDecodeHistoryReplayActive(true);
updateHistoryReplayOverlay(); 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] : []; const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : [];
if (messages.length === 0) continue; if (messages.length === 0) continue;
for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) { for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) {
@@ -1,5 +1,5 @@
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; 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) { function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset; const offset = state.offset;
@@ -374,6 +374,7 @@
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-aprs" value="aprs" /> APRS</label> <label class="bm-decoder-check"><input type="checkbox" id="bm-dec-aprs" value="aprs" /> APRS</label>
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ais" value="ais" /> AIS</label> <label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ais" value="ais" /> AIS</label>
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label> <label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label>
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft4" value="ft4" /> FT4</label>
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-wspr" value="wspr" /> WSPR</label> <label class="bm-decoder-check"><input type="checkbox" id="bm-dec-wspr" value="wspr" /> WSPR</label>
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-hf-aprs" value="hf-aprs" /> HF APRS</label> <label class="bm-decoder-check"><input type="checkbox" id="bm-dec-hf-aprs" value="hf-aprs" /> HF APRS</label>
</div> </div>
@@ -425,6 +426,7 @@
<button class="sub-tab" data-subtab="hf-aprs">HF APRS</button> <button class="sub-tab" data-subtab="hf-aprs">HF APRS</button>
<button class="sub-tab" data-subtab="cw">CW</button> <button class="sub-tab" data-subtab="cw">CW</button>
<button class="sub-tab" data-subtab="ft8">FT8</button> <button class="sub-tab" data-subtab="ft8">FT8</button>
<button class="sub-tab" data-subtab="ft4">FT4</button>
<button class="sub-tab" data-subtab="wspr">WSPR</button> <button class="sub-tab" data-subtab="wspr">WSPR</button>
<button class="sub-tab" data-subtab="rds">RDS</button> <button class="sub-tab" data-subtab="rds">RDS</button>
</div> </div>
@@ -634,6 +636,24 @@
</div> </div>
<div id="ft8-messages"></div> <div id="ft8-messages"></div>
</div> </div>
<div id="subtab-ft4" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="ft4-decode-toggle-btn" type="button">Enable FT4</button>
<button id="ft4-pause-btn" type="button">Pause</button>
<button id="ft4-clear-btn" type="button">Clear</button>
<input id="ft4-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. CQ, DL4)" />
<small id="ft4-status" style="color:var(--text-muted);">Waiting for server decode</small>
<small id="ft4-period" style="color:var(--text-muted);">Next slot --s</small>
</div>
<div class="ft8-header">
<span class="ft8-time">Time</span>
<span class="ft8-snr">SNR</span>
<span class="ft8-dt">DT</span>
<span class="ft8-freq">RF</span>
<span class="ft8-msg">Message</span>
</div>
<div id="ft4-messages"></div>
</div>
<div id="subtab-wspr" class="sub-tab-panel" style="display:none;"> <div id="subtab-wspr" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls"> <div class="ft8-controls">
<button id="wspr-decode-toggle-btn" type="button">Enable WSPR</button> <button id="wspr-decode-toggle-btn" type="button">Enable WSPR</button>
@@ -910,6 +930,7 @@
<script src="/aprs.js"></script> <script src="/aprs.js"></script>
<script src="/hf-aprs.js"></script> <script src="/hf-aprs.js"></script>
<script src="/ft8.js"></script> <script src="/ft8.js"></script>
<script src="/ft4.js"></script>
<script src="/wspr.js"></script> <script src="/wspr.js"></script>
<script src="/cw.js"></script> <script src="/cw.js"></script>
<script src="/bookmarks.js"></script> <script src="/bookmarks.js"></script>
@@ -0,0 +1,191 @@
// --- FT4 Decoder Plugin (server-side decode) ---
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
// SPDX-License-Identifier: BSD-2-Clause
function ft8RenderMessage(message) {
if (typeof renderFt8Message === "function") return renderFt8Message(message);
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
return message;
}
const ft4Status = document.getElementById("ft4-status");
const ft4PeriodEl = document.getElementById("ft4-period");
const ft4MessagesEl = document.getElementById("ft4-messages");
const ft4FilterInput = document.getElementById("ft4-filter");
const ft4PauseBtn = document.getElementById("ft4-pause-btn");
const FT4_PERIOD_MS = 7500;
let ft4FilterText = "";
let ft4MessageHistory = [];
let ft4Paused = false;
let ft4BufferedWhilePaused = 0;
function currentFt4HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt4MessageHistory() {
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt4Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
function normalizeFt4DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function updateFt4PeriodTimer() {
if (!ft4PeriodEl) return;
const nowMs = Date.now();
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
}
updateFt4PeriodTimer();
setInterval(updateFt4PeriodTimer, 250);
function renderFt4Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft4";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = ft8RenderMessage(rawMessage);
const tsMs = msg._tsMs ?? msg.ts_ms;
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
return row;
}
function updateFt4PauseUi() {
if (!ft4PauseBtn) return;
ft4PauseBtn.textContent = ft4Paused ? "Resume" : "Pause";
ft4PauseBtn.classList.toggle("active", ft4Paused);
}
function renderFt4History() {
pruneFt4MessageHistory();
if (!ft4MessagesEl || ft4Paused) { updateFt4PauseUi(); return; }
const filter = ft4FilterText;
const fragment = document.createDocumentFragment();
for (let i = 0; i < ft4MessageHistory.length; i++) {
const msg = ft4MessageHistory[i];
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
fragment.appendChild(renderFt4Row(msg));
}
ft4MessagesEl.replaceChildren(fragment);
updateFt4PauseUi();
}
function addFt4Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft4MessageHistory.unshift(msg);
pruneFt4MessageHistory();
if (ft4Paused) { ft4BufferedWhilePaused += 1; updateFt4PauseUi(); return; }
scheduleFt4HistoryRender();
}
function normalizeServerFt4Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
const grids = locatorDetails.length > 0
? locatorDetails.map((d) => d.grid)
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
return {
raw, grids, station, rfHz, locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt4Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (ft4Status) ft4Status.textContent = ft4Paused ? "Paused" : "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(next.raw, next.grids, "ft8", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft4MessageHistory = normalized.concat(ft4MessageHistory);
pruneFt4MessageHistory();
if (ft4Paused) { ft4BufferedWhilePaused += messages.length; updateFt4PauseUi(); return; }
scheduleFt4HistoryRender();
};
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
window.resetFt4HistoryView = function() {
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
ft4MessageHistory = [];
ft4BufferedWhilePaused = 0;
renderFt4History();
};
if (ft4FilterInput) {
ft4FilterInput.addEventListener("input", () => {
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
renderFt4History();
});
}
if (ft4PauseBtn) {
ft4PauseBtn.addEventListener("click", () => {
ft4Paused = !ft4Paused;
if (!ft4Paused) { ft4BufferedWhilePaused = 0; renderFt4History(); } else { updateFt4PauseUi(); }
});
}
document.getElementById("ft4-decode-toggle-btn")?.addEventListener("click", async () => {
try { await postPath("/toggle_ft4_decode"); } catch (e) { console.error("FT4 toggle failed", e); }
});
document.getElementById("ft4-clear-btn")?.addEventListener("click", async () => {
try {
await postPath("/clear_ft4_decode");
window.resetFt4HistoryView();
} catch (e) { console.error("FT4 clear failed", e); }
});
window.onServerFt4 = function(msg) {
if (ft4Status) ft4Status.textContent = ft4Paused ? "Paused" : "Receiving";
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(next.raw, next.grids, "ft8", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt4Message(next.history);
};
updateFt4PauseUi();
@@ -450,6 +450,7 @@ struct DecodeHistoryPayload {
hf_aprs: Vec<trx_core::decode::AprsPacket>, hf_aprs: Vec<trx_core::decode::AprsPacket>,
cw: Vec<trx_core::decode::CwEvent>, cw: Vec<trx_core::decode::CwEvent>,
ft8: Vec<trx_core::decode::Ft8Message>, ft8: Vec<trx_core::decode::Ft8Message>,
ft4: Vec<trx_core::decode::Ft8Message>,
wspr: Vec<trx_core::decode::WsprMessage>, wspr: Vec<trx_core::decode::WsprMessage>,
} }
@@ -461,6 +462,7 @@ impl DecodeHistoryPayload {
+ self.hf_aprs.len() + self.hf_aprs.len()
+ self.cw.len() + self.cw.len()
+ self.ft8.len() + self.ft8.len()
+ self.ft4.len()
+ self.wspr.len() + self.wspr.len()
} }
} }
@@ -474,6 +476,7 @@ fn collect_decode_history(context: &FrontendRuntimeContext) -> DecodeHistoryPayl
hf_aprs: crate::server::audio::snapshot_hf_aprs_history(context), hf_aprs: crate::server::audio::snapshot_hf_aprs_history(context),
cw: crate::server::audio::snapshot_cw_history(context), cw: crate::server::audio::snapshot_cw_history(context),
ft8: crate::server::audio::snapshot_ft8_history(context), ft8: crate::server::audio::snapshot_ft8_history(context),
ft4: crate::server::audio::snapshot_ft4_history(context),
wspr: crate::server::audio::snapshot_wspr_history(context), wspr: crate::server::audio::snapshot_wspr_history(context),
} }
} }
@@ -1002,6 +1005,15 @@ pub async fn toggle_ft8_decode(
send_command(&rig_tx, RigCommand::SetFt8DecodeEnabled(!enabled)).await send_command(&rig_tx, RigCommand::SetFt8DecodeEnabled(!enabled)).await
} }
#[post("/toggle_ft4_decode")]
pub async fn toggle_ft4_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft4_decode_enabled;
send_command(&rig_tx, RigCommand::SetFt4DecodeEnabled(!enabled)).await
}
#[post("/toggle_wspr_decode")] #[post("/toggle_wspr_decode")]
pub async fn toggle_wspr_decode( pub async fn toggle_wspr_decode(
state: web::Data<watch::Receiver<RigState>>, state: web::Data<watch::Receiver<RigState>>,
@@ -1020,6 +1032,15 @@ pub async fn clear_ft8_decode(
send_command(&rig_tx, RigCommand::ResetFt8Decoder).await send_command(&rig_tx, RigCommand::ResetFt8Decoder).await
} }
#[post("/clear_ft4_decode")]
pub async fn clear_ft4_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ft4_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetFt4Decoder).await
}
#[post("/clear_wspr_decode")] #[post("/clear_wspr_decode")]
pub async fn clear_wspr_decode( pub async fn clear_wspr_decode(
context: web::Data<Arc<FrontendRuntimeContext>>, context: web::Data<Arc<FrontendRuntimeContext>>,
@@ -1502,6 +1523,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_cw_wpm) .service(set_cw_wpm)
.service(set_cw_tone) .service(set_cw_tone)
.service(toggle_ft8_decode) .service(toggle_ft8_decode)
.service(toggle_ft4_decode)
.service(toggle_wspr_decode) .service(toggle_wspr_decode)
.service(clear_ais_decode) .service(clear_ais_decode)
.service(clear_vdes_decode) .service(clear_vdes_decode)
@@ -1509,6 +1531,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(clear_hf_aprs_decode) .service(clear_hf_aprs_decode)
.service(clear_cw_decode) .service(clear_cw_decode)
.service(clear_ft8_decode) .service(clear_ft8_decode)
.service(clear_ft4_decode)
.service(clear_wspr_decode) .service(clear_wspr_decode)
.service(select_rig) .service(select_rig)
// Bookmark CRUD // Bookmark CRUD
@@ -1542,6 +1565,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(aprs_js) .service(aprs_js)
.service(hf_aprs_js) .service(hf_aprs_js)
.service(ft8_js) .service(ft8_js)
.service(ft4_js)
.service(wspr_js) .service(wspr_js)
.service(cw_js) .service(cw_js)
.service(bookmarks_js) .service(bookmarks_js)
@@ -1715,6 +1739,13 @@ async fn ft8_js() -> impl Responder {
.body(status::FT8_JS) .body(status::FT8_JS)
} }
#[get("/ft4.js")]
async fn ft4_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.body(status::FT4_JS)
}
#[get("/wspr.js")] #[get("/wspr.js")]
async fn wspr_js() -> impl Responder { async fn wspr_js() -> impl Responder {
HttpResponse::Ok() HttpResponse::Ok()
@@ -1848,10 +1879,11 @@ async fn send_command_to_rig(
fn bookmark_decoder_state( fn bookmark_decoder_state(
bookmark: &crate::server::bookmarks::Bookmark, bookmark: &crate::server::bookmarks::Bookmark,
) -> (bool, bool, bool, bool) { ) -> (bool, bool, bool, bool, bool) {
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT"); let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
let mut want_hf_aprs = false; let mut want_hf_aprs = false;
let mut want_ft8 = false; let mut want_ft8 = false;
let mut want_ft4 = false;
let mut want_wspr = false; let mut want_wspr = false;
for decoder in bookmark for decoder in bookmark
@@ -1863,12 +1895,13 @@ fn bookmark_decoder_state(
"aprs" => want_aprs = true, "aprs" => want_aprs = true,
"hf-aprs" => want_hf_aprs = true, "hf-aprs" => want_hf_aprs = true,
"ft8" => want_ft8 = true, "ft8" => want_ft8 = true,
"ft4" => want_ft4 = true,
"wspr" => want_wspr = true, "wspr" => want_wspr = true,
_ => {} _ => {}
} }
} }
(want_aprs, want_hf_aprs, want_ft8, want_wspr) (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_wspr)
} }
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> { fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
@@ -1880,7 +1913,7 @@ fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<
{ {
if matches!( if matches!(
decoder.as_str(), decoder.as_str(),
"aprs" | "ais" | "ft8" | "wspr" | "hf-aprs" "aprs" | "ais" | "ft8" | "ft4" | "wspr" | "hf-aprs"
) && !out.iter().any(|existing| existing == &decoder) ) && !out.iter().any(|existing| existing == &decoder)
{ {
out.push(decoder); out.push(decoder);
@@ -1935,11 +1968,12 @@ async fn apply_selected_channel(
let Some(bookmark) = bookmark_store.get(bookmark_id) else { let Some(bookmark) = bookmark_store.get(bookmark_id) else {
return Ok(()); return Ok(());
}; };
let (want_aprs, want_hf_aprs, want_ft8, want_wspr) = bookmark_decoder_state(&bookmark); let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_wspr) = bookmark_decoder_state(&bookmark);
let desired = [ let desired = [
RigCommand::SetAprsDecodeEnabled(want_aprs), RigCommand::SetAprsDecodeEnabled(want_aprs),
RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs), RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs),
RigCommand::SetFt8DecodeEnabled(want_ft8), RigCommand::SetFt8DecodeEnabled(want_ft8),
RigCommand::SetFt4DecodeEnabled(want_ft4),
RigCommand::SetWsprDecodeEnabled(want_wspr), RigCommand::SetWsprDecodeEnabled(want_wspr),
]; ];
for cmd in desired { for cmd in desired {
@@ -1987,6 +2021,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
cw_wpm: state.cw_wpm, cw_wpm: state.cw_wpm,
cw_tone_hz: state.cw_tone_hz, cw_tone_hz: state.cw_tone_hz,
ft8_decode_enabled: state.ft8_decode_enabled, ft8_decode_enabled: state.ft8_decode_enabled,
ft4_decode_enabled: state.ft4_decode_enabled,
wspr_decode_enabled: state.wspr_decode_enabled, wspr_decode_enabled: state.wspr_decode_enabled,
filter: state.filter.clone(), filter: state.filter.clone(),
spectrum: None, spectrum: None,
@@ -147,6 +147,16 @@ fn prune_ft8_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(I
} }
} }
fn prune_ft4_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, Ft8Message)>) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_wspr_history( fn prune_wspr_history(
context: &FrontendRuntimeContext, context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, WsprMessage)>, history: &mut VecDeque<(Instant, WsprMessage)>,
@@ -202,6 +212,15 @@ fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) {
prune_ft8_history(context, &mut history); prune_ft8_history(context, &mut history);
} }
fn record_ft4(context: &FrontendRuntimeContext, msg: Ft8Message) {
let mut history = context
.ft4_history
.lock()
.expect("ft4 history mutex poisoned");
history.push_back((Instant::now(), msg));
prune_ft4_history(context, &mut history);
}
fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) { fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
let mut history = context let mut history = context
.wspr_history .wspr_history
@@ -280,6 +299,15 @@ pub fn snapshot_ft8_history(context: &FrontendRuntimeContext) -> Vec<Ft8Message>
history.iter().map(|(_, msg)| msg.clone()).collect() history.iter().map(|(_, msg)| msg.clone()).collect()
} }
pub fn snapshot_ft4_history(context: &FrontendRuntimeContext) -> Vec<Ft8Message> {
let mut history = context
.ft4_history
.lock()
.expect("ft4 history mutex poisoned");
prune_ft4_history(context, &mut history);
history.iter().map(|(_, msg)| msg.clone()).collect()
}
pub fn snapshot_wspr_history(context: &FrontendRuntimeContext) -> Vec<WsprMessage> { pub fn snapshot_wspr_history(context: &FrontendRuntimeContext) -> Vec<WsprMessage> {
let mut history = context let mut history = context
.wspr_history .wspr_history
@@ -337,6 +365,14 @@ pub fn clear_ft8_history(context: &FrontendRuntimeContext) {
history.clear(); history.clear();
} }
pub fn clear_ft4_history(context: &FrontendRuntimeContext) {
let mut history = context
.ft4_history
.lock()
.expect("ft4 history mutex poisoned");
history.clear();
}
pub fn clear_wspr_history(context: &FrontendRuntimeContext) { pub fn clear_wspr_history(context: &FrontendRuntimeContext) {
let mut history = context let mut history = context
.wspr_history .wspr_history
@@ -374,6 +410,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt), DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt),
DecodedMessage::Cw(evt) => record_cw(&context, evt), DecodedMessage::Cw(evt) => record_cw(&context, evt),
DecodedMessage::Ft8(msg) => record_ft8(&context, msg), DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
DecodedMessage::Wspr(msg) => record_wspr(&context, msg), DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
}, },
Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Lagged(_)) => continue,
@@ -21,7 +21,7 @@ use crate::server::bookmarks::{Bookmark, BookmarkStore};
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager}; use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
use crate::server::vchan::{ClientChannel, ClientChannelManager}; use crate::server::vchan::{ClientChannel, ClientChannelManager};
const SUPPORTED_DECODER_KINDS: &[&str] = &["aprs", "ais", "ft8", "wspr", "hf-aprs"]; const SUPPORTED_DECODER_KINDS: &[&str] = &["aprs", "ais", "ft8", "ft4", "wspr", "hf-aprs"];
const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel"; const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel"; const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
@@ -672,6 +672,7 @@ async fn apply_scheduler_decoders(
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT"); let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
let mut want_hf_aprs = false; let mut want_hf_aprs = false;
let mut want_ft8 = false; let mut want_ft8 = false;
let mut want_ft4 = false;
let mut want_wspr = false; let mut want_wspr = false;
let mut update_from = |bm: &crate::server::bookmarks::Bookmark| { let mut update_from = |bm: &crate::server::bookmarks::Bookmark| {
@@ -680,6 +681,7 @@ async fn apply_scheduler_decoders(
"aprs" => want_aprs = true, "aprs" => want_aprs = true,
"hf-aprs" => want_hf_aprs = true, "hf-aprs" => want_hf_aprs = true,
"ft8" => want_ft8 = true, "ft8" => want_ft8 = true,
"ft4" => want_ft4 = true,
"wspr" => want_wspr = true, "wspr" => want_wspr = true,
_ => {} _ => {}
} }
@@ -695,6 +697,7 @@ async fn apply_scheduler_decoders(
("APRS", RigCommand::SetAprsDecodeEnabled(want_aprs)), ("APRS", RigCommand::SetAprsDecodeEnabled(want_aprs)),
("HF APRS", RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs)), ("HF APRS", RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs)),
("FT8", RigCommand::SetFt8DecodeEnabled(want_ft8)), ("FT8", RigCommand::SetFt8DecodeEnabled(want_ft8)),
("FT4", RigCommand::SetFt4DecodeEnabled(want_ft4)),
("WSPR", RigCommand::SetWsprDecodeEnabled(want_wspr)), ("WSPR", RigCommand::SetWsprDecodeEnabled(want_wspr)),
]; ];
@@ -19,6 +19,7 @@ pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js"); pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js"); pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js"); pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js");
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js"); pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js"); pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js"); pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
+2
View File
@@ -62,6 +62,8 @@ pub const AUDIO_MSG_VCHAN_DESTROYED: u8 = 0x12;
/// Client → server: update the audio filter bandwidth of an existing virtual channel. /// Client → server: update the audio filter bandwidth of an existing virtual channel.
/// Payload: JSON `{"uuid": "<uuid>", "bandwidth_hz": <u32>}`. /// Payload: JSON `{"uuid": "<uuid>", "bandwidth_hz": <u32>}`.
pub const AUDIO_MSG_VCHAN_BW: u8 = 0x13; pub const AUDIO_MSG_VCHAN_BW: u8 = 0x13;
/// Server → client: FT4 decoded message (JSON `DecodedMessage::Ft4`).
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
/// Maximum payload size for normal messages (1 MB). /// Maximum payload size for normal messages (1 MB).
const MAX_PAYLOAD_SIZE: u32 = 1_048_576; const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
+2
View File
@@ -22,6 +22,8 @@ pub enum DecodedMessage {
Cw(CwEvent), Cw(CwEvent),
#[serde(rename = "ft8")] #[serde(rename = "ft8")]
Ft8(Ft8Message), Ft8(Ft8Message),
#[serde(rename = "ft4")]
Ft4(Ft8Message),
#[serde(rename = "wspr")] #[serde(rename = "wspr")]
Wspr(WsprMessage), Wspr(WsprMessage),
} }
+2
View File
@@ -28,11 +28,13 @@ pub enum RigCommand {
SetCwWpm(u32), SetCwWpm(u32),
SetCwToneHz(u32), SetCwToneHz(u32),
SetFt8DecodeEnabled(bool), SetFt8DecodeEnabled(bool),
SetFt4DecodeEnabled(bool),
SetWsprDecodeEnabled(bool), SetWsprDecodeEnabled(bool),
ResetAprsDecoder, ResetAprsDecoder,
ResetHfAprsDecoder, ResetHfAprsDecoder,
ResetCwDecoder, ResetCwDecoder,
ResetFt8Decoder, ResetFt8Decoder,
ResetFt4Decoder,
ResetWsprDecoder, ResetWsprDecoder,
SetBandwidth(u32), SetBandwidth(u32),
SetFirTaps(u32), SetFirTaps(u32),
@@ -509,12 +509,14 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetCwWpm(_) | RigCommand::SetCwWpm(_)
| RigCommand::SetCwToneHz(_) | RigCommand::SetCwToneHz(_)
| RigCommand::SetFt8DecodeEnabled(_) | RigCommand::SetFt8DecodeEnabled(_)
| RigCommand::SetFt4DecodeEnabled(_)
| RigCommand::SetWsprDecodeEnabled(_) | RigCommand::SetWsprDecodeEnabled(_)
| RigCommand::SetHfAprsDecodeEnabled(_) | RigCommand::SetHfAprsDecodeEnabled(_)
| RigCommand::ResetHfAprsDecoder | RigCommand::ResetHfAprsDecoder
| RigCommand::ResetAprsDecoder | RigCommand::ResetAprsDecoder
| RigCommand::ResetCwDecoder | RigCommand::ResetCwDecoder
| RigCommand::ResetFt8Decoder | RigCommand::ResetFt8Decoder
| RigCommand::ResetFt4Decoder
| RigCommand::ResetWsprDecoder | RigCommand::ResetWsprDecoder
| RigCommand::SetBandwidth(_) | RigCommand::SetBandwidth(_)
| RigCommand::SetFirTaps(_) | RigCommand::SetFirTaps(_)
+11
View File
@@ -38,6 +38,8 @@ pub struct RigState {
#[serde(default)] #[serde(default)]
pub ft8_decode_enabled: bool, pub ft8_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub ft4_decode_enabled: bool,
#[serde(default)]
pub wspr_decode_enabled: bool, pub wspr_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub cw_auto: bool, pub cw_auto: bool,
@@ -66,6 +68,8 @@ pub struct RigState {
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub ft8_decode_reset_seq: u64, pub ft8_decode_reset_seq: u64,
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub ft4_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub wspr_decode_reset_seq: u64, pub wspr_decode_reset_seq: u64,
} }
@@ -143,6 +147,7 @@ impl RigState {
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: true, cw_decode_enabled: true,
ft8_decode_enabled: false, ft8_decode_enabled: false,
ft4_decode_enabled: false,
wspr_decode_enabled: false, wspr_decode_enabled: false,
cw_auto: true, cw_auto: true,
cw_wpm: 15, cw_wpm: 15,
@@ -154,6 +159,7 @@ impl RigState {
hf_aprs_decode_reset_seq: 0, hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0, cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0, ft8_decode_reset_seq: 0,
ft4_decode_reset_seq: 0,
wspr_decode_reset_seq: 0, wspr_decode_reset_seq: 0,
} }
} }
@@ -210,6 +216,7 @@ impl RigState {
cw_wpm: snapshot.cw_wpm, cw_wpm: snapshot.cw_wpm,
cw_tone_hz: snapshot.cw_tone_hz, cw_tone_hz: snapshot.cw_tone_hz,
ft8_decode_enabled: snapshot.ft8_decode_enabled, ft8_decode_enabled: snapshot.ft8_decode_enabled,
ft4_decode_enabled: snapshot.ft4_decode_enabled,
wspr_decode_enabled: snapshot.wspr_decode_enabled, wspr_decode_enabled: snapshot.wspr_decode_enabled,
filter: snapshot.filter, filter: snapshot.filter,
spectrum: None, // spectrum flows through /api/spectrum, not persistent state spectrum: None, // spectrum flows through /api/spectrum, not persistent state
@@ -218,6 +225,7 @@ impl RigState {
hf_aprs_decode_reset_seq: 0, hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0, cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0, ft8_decode_reset_seq: 0,
ft4_decode_reset_seq: 0,
wspr_decode_reset_seq: 0, wspr_decode_reset_seq: 0,
} }
} }
@@ -252,6 +260,7 @@ impl RigState {
cw_wpm: self.cw_wpm, cw_wpm: self.cw_wpm,
cw_tone_hz: self.cw_tone_hz, cw_tone_hz: self.cw_tone_hz,
ft8_decode_enabled: self.ft8_decode_enabled, ft8_decode_enabled: self.ft8_decode_enabled,
ft4_decode_enabled: self.ft4_decode_enabled,
wspr_decode_enabled: self.wspr_decode_enabled, wspr_decode_enabled: self.wspr_decode_enabled,
filter: self.filter.clone(), filter: self.filter.clone(),
spectrum: self.spectrum.clone(), spectrum: self.spectrum.clone(),
@@ -416,6 +425,8 @@ pub struct RigSnapshot {
#[serde(default)] #[serde(default)]
pub ft8_decode_enabled: bool, pub ft8_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub ft4_decode_enabled: bool,
#[serde(default)]
pub wspr_decode_enabled: bool, pub wspr_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub cw_auto: bool, pub cw_auto: bool,
+4
View File
@@ -42,6 +42,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm), ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz), ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz),
ClientCommand::SetFt8DecodeEnabled { enabled } => RigCommand::SetFt8DecodeEnabled(enabled), ClientCommand::SetFt8DecodeEnabled { enabled } => RigCommand::SetFt8DecodeEnabled(enabled),
ClientCommand::SetFt4DecodeEnabled { enabled } => RigCommand::SetFt4DecodeEnabled(enabled),
ClientCommand::SetWsprDecodeEnabled { enabled } => { ClientCommand::SetWsprDecodeEnabled { enabled } => {
RigCommand::SetWsprDecodeEnabled(enabled) RigCommand::SetWsprDecodeEnabled(enabled)
} }
@@ -49,6 +50,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
ClientCommand::ResetHfAprsDecoder => RigCommand::ResetHfAprsDecoder, ClientCommand::ResetHfAprsDecoder => RigCommand::ResetHfAprsDecoder,
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder, ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder, ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder,
ClientCommand::ResetFt4Decoder => RigCommand::ResetFt4Decoder,
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder, ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz), ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps), ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps),
@@ -100,6 +102,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm }, RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
RigCommand::SetCwToneHz(tone_hz) => ClientCommand::SetCwToneHz { tone_hz }, RigCommand::SetCwToneHz(tone_hz) => ClientCommand::SetCwToneHz { tone_hz },
RigCommand::SetFt8DecodeEnabled(enabled) => ClientCommand::SetFt8DecodeEnabled { enabled }, RigCommand::SetFt8DecodeEnabled(enabled) => ClientCommand::SetFt8DecodeEnabled { enabled },
RigCommand::SetFt4DecodeEnabled(enabled) => ClientCommand::SetFt4DecodeEnabled { enabled },
RigCommand::SetWsprDecodeEnabled(enabled) => { RigCommand::SetWsprDecodeEnabled(enabled) => {
ClientCommand::SetWsprDecodeEnabled { enabled } ClientCommand::SetWsprDecodeEnabled { enabled }
} }
@@ -107,6 +110,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
RigCommand::ResetHfAprsDecoder => ClientCommand::ResetHfAprsDecoder, RigCommand::ResetHfAprsDecoder => ClientCommand::ResetHfAprsDecoder,
RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder, RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder,
RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder, RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder,
RigCommand::ResetFt4Decoder => ClientCommand::ResetFt4Decoder,
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder, RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz }, RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps }, RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps },
+2
View File
@@ -33,11 +33,13 @@ pub enum ClientCommand {
SetCwWpm { wpm: u32 }, SetCwWpm { wpm: u32 },
SetCwToneHz { tone_hz: u32 }, SetCwToneHz { tone_hz: u32 },
SetFt8DecodeEnabled { enabled: bool }, SetFt8DecodeEnabled { enabled: bool },
SetFt4DecodeEnabled { enabled: bool },
SetWsprDecodeEnabled { enabled: bool }, SetWsprDecodeEnabled { enabled: bool },
ResetAprsDecoder, ResetAprsDecoder,
ResetHfAprsDecoder, ResetHfAprsDecoder,
ResetCwDecoder, ResetCwDecoder,
ResetFt8Decoder, ResetFt8Decoder,
ResetFt4Decoder,
ResetWsprDecoder, ResetWsprDecoder,
SetBandwidth { bandwidth_hz: u32 }, SetBandwidth { bandwidth_hz: u32 },
SetFirTaps { taps: u32 }, SetFirTaps { taps: u32 },
+270 -4
View File
@@ -24,9 +24,9 @@ use trx_aprs::AprsDecoder;
use trx_core::audio::{ use trx_core::audio::{
parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame,
write_vchan_uuid_msg, AudioStreamInfo, write_vchan_uuid_msg, AudioStreamInfo,
AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT4_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, 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_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_REMOVE,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE, 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 hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>, pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>, pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>, pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
} }
@@ -169,6 +170,7 @@ impl DecoderHistories {
hf_aprs: Mutex::new(VecDeque::new()), hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()), cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()), ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()), wspr: Mutex::new(VecDeque::new()),
}) })
} }
@@ -363,6 +365,35 @@ impl DecoderHistories {
self.ft8.lock().expect("ft8 history mutex poisoned").clear(); 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 --- // --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) { 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 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 cw = self.cw.lock().map(|h| h.len()).unwrap_or(0);
let ft8 = self.ft8.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); 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. /// Run the WSPR decoder task. Mirrors FT8 lifecycle/slot behavior.
/// ///
/// Note: decoding engine integration is intentionally staged; this task already /// 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( async fn run_background_wspr_decoder(
sample_rate: u32, sample_rate: u32,
channels: u16, 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_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_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_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_wspr_history(), DecodedMessage::Wspr, AUDIO_MSG_WSPR_DECODE);
push_history!(histories.snapshot_cw_history(), DecodedMessage::Cw, AUDIO_MSG_CW_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::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE, DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE, DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE, DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
}; };
if let Ok(json) = serde_json::to_vec(&msg) { 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::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE, DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE, DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE, DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
}; };
if let Ok(json) = serde_json::to_vec(&msg) { if let Ok(json) = serde_json::to_vec(&msg) {
@@ -2438,6 +2694,16 @@ async fn handle_audio_client(
) )
.await; .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 { "wspr" => tokio::spawn(async move {
run_background_wspr_decoder( run_background_wspr_decoder(
sr, 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 // Spawn WSPR decoder task
let wspr_pcm_rx = pcm_tx.subscribe(); let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone(); 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()); let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state); 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) => { RigCommand::SetWsprDecodeEnabled(en) => {
ctx.state.wspr_decode_enabled = en; ctx.state.wspr_decode_enabled = en;
info!("WSPR decode {}", if en { "enabled" } else { "disabled" }); info!("WSPR decode {}", if en { "enabled" } else { "disabled" });
@@ -505,6 +511,12 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone()); let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state); 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 => { RigCommand::ResetWsprDecoder => {
ctx.histories.clear_wspr_history(); ctx.histories.clear_wspr_history();
ctx.state.wspr_decode_reset_seq += 1; ctx.state.wspr_decode_reset_seq += 1;