[feat](trx-rs): add ft8 decoder
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -16,7 +16,8 @@ use tracing::{info, warn};
|
||||
|
||||
use trx_core::audio::{
|
||||
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_APRS_DECODE,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO,
|
||||
AUDIO_MSG_TX_FRAME,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
|
||||
@@ -88,7 +89,7 @@ async fn handle_audio_connection(
|
||||
Ok((AUDIO_MSG_RX_FRAME, payload)) => {
|
||||
let _ = rx_tx.send(Bytes::from(payload));
|
||||
}
|
||||
Ok((AUDIO_MSG_APRS_DECODE | AUDIO_MSG_CW_DECODE, payload)) => {
|
||||
Ok((AUDIO_MSG_APRS_DECODE | AUDIO_MSG_CW_DECODE | AUDIO_MSG_FT8_DECODE, payload)) => {
|
||||
if let Ok(msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
let _ = decode_tx.send(msg);
|
||||
}
|
||||
|
||||
@@ -277,8 +277,10 @@ async fn async_init() -> DynResult<AppState> {
|
||||
cw_auto: true,
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
ft8_decode_enabled: false,
|
||||
aprs_decode_reset_seq: 0,
|
||||
cw_decode_reset_seq: 0,
|
||||
ft8_decode_reset_seq: 0,
|
||||
};
|
||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||
|
||||
|
||||
@@ -151,8 +151,10 @@ fn map_rig_command(cmd: trx_core::RigCommand) -> ClientCommand {
|
||||
trx_core::RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
|
||||
trx_core::RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
|
||||
trx_core::RigCommand::SetCwToneHz(tone_hz) => ClientCommand::SetCwToneHz { tone_hz },
|
||||
trx_core::RigCommand::SetFt8DecodeEnabled(enabled) => ClientCommand::SetFt8DecodeEnabled { enabled },
|
||||
trx_core::RigCommand::ResetAprsDecoder => ClientCommand::ResetAprsDecoder,
|
||||
trx_core::RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder,
|
||||
trx_core::RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +198,10 @@ pub fn state_from_snapshot(snapshot: trx_core::RigSnapshot) -> RigState {
|
||||
cw_auto: snapshot.cw_auto,
|
||||
cw_wpm: snapshot.cw_wpm,
|
||||
cw_tone_hz: snapshot.cw_tone_hz,
|
||||
ft8_decode_enabled: snapshot.ft8_decode_enabled,
|
||||
aprs_decode_reset_seq: 0,
|
||||
cw_decode_reset_seq: 0,
|
||||
ft8_decode_reset_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,8 +145,10 @@ async fn handle_client(
|
||||
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
|
||||
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
|
||||
ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz),
|
||||
ClientCommand::SetFt8DecodeEnabled { enabled } => RigCommand::SetFt8DecodeEnabled(enabled),
|
||||
ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder,
|
||||
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
|
||||
ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder,
|
||||
};
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
|
||||
@@ -248,12 +248,17 @@ function render(update) {
|
||||
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
const cwStatus = document.getElementById("cw-status");
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
|
||||
aprsStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
|
||||
cwStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
const ft8Enabled = !!update.ft8_decode_enabled;
|
||||
if (ft8Status && (!ft8Enabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && ft8Status.textContent === "Receiving") {
|
||||
ft8Status.textContent = "Connected, listening for packets";
|
||||
}
|
||||
if (update.status && typeof update.status.tx_en === "boolean") {
|
||||
lastTxEn = update.status.tx_en;
|
||||
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
|
||||
@@ -267,6 +272,13 @@ function render(update) {
|
||||
pttBtn.style.color = "";
|
||||
}
|
||||
}
|
||||
const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
||||
if (ft8ToggleBtn) {
|
||||
const ft8On = !!update.ft8_decode_enabled;
|
||||
ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8";
|
||||
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
|
||||
ft8ToggleBtn.style.color = ft8On ? "#00d17f" : "";
|
||||
}
|
||||
const cwAutoEl = document.getElementById("cw-auto");
|
||||
const cwWpmEl = document.getElementById("cw-wpm");
|
||||
const cwToneEl = document.getElementById("cw-tone");
|
||||
@@ -1156,8 +1168,10 @@ let decodeConnected = false;
|
||||
function updateDecodeStatus(text) {
|
||||
const aprs = document.getElementById("aprs-status");
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
|
||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||
}
|
||||
function connectDecode() {
|
||||
if (decodeSource) { decodeSource.close(); }
|
||||
@@ -1171,6 +1185,7 @@ function connectDecode() {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
|
||||
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
|
||||
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
<button class="sub-tab" data-subtab="map">Map</button>
|
||||
<button class="sub-tab" data-subtab="aprs">APRS</button>
|
||||
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
||||
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
@@ -150,6 +151,14 @@
|
||||
</div>
|
||||
<div id="aprs-packets"></div>
|
||||
</div>
|
||||
<div id="subtab-ft8" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="ft8-decode-toggle-btn" type="button">Enable FT8</button>
|
||||
<button id="ft8-clear-btn" type="button">Clear</button>
|
||||
<small id="ft8-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div id="ft8-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-cw" class="sub-tab-panel" style="display:none;">
|
||||
<div class="cw-controls">
|
||||
<button id="cw-clear-btn" type="button">Clear</button>
|
||||
@@ -184,6 +193,7 @@
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/aprs.js"></script>
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/cw.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// --- FT8 Decoder Plugin (server-side decode) ---
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const ft8MessagesEl = document.getElementById("ft8-messages");
|
||||
const FT8_MAX_MESSAGES = 200;
|
||||
|
||||
function fmtTime(tsMs) {
|
||||
if (!tsMs) return "--:--:--";
|
||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function renderFt8Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
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 freq = Number.isFinite(msg.freq_hz) ? msg.freq_hz.toFixed(0) : "--";
|
||||
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${msg.message || ""}</span>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function addFt8Message(msg) {
|
||||
ft8MessagesEl.prepend(renderFt8Row(msg));
|
||||
while (ft8MessagesEl.children.length > FT8_MAX_MESSAGES) {
|
||||
ft8MessagesEl.removeChild(ft8MessagesEl.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("ft8-decode-toggle-btn").addEventListener("click", async () => {
|
||||
try { await postPath("/toggle_ft8_decode"); } catch (e) { console.error("FT8 toggle failed", e); }
|
||||
});
|
||||
|
||||
document.getElementById("ft8-clear-btn").addEventListener("click", async () => {
|
||||
ft8MessagesEl.innerHTML = "";
|
||||
try { await postPath("/clear_ft8_decode"); } catch (e) { console.error("FT8 clear failed", e); }
|
||||
});
|
||||
|
||||
// --- Server-side FT8 decode handler ---
|
||||
window.onServerFt8 = function(msg) {
|
||||
ft8Status.textContent = "Receiving";
|
||||
addFt8Message({
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
dt_s: msg.dt_s,
|
||||
freq_hz: msg.freq_hz,
|
||||
message: msg.message,
|
||||
});
|
||||
};
|
||||
@@ -223,6 +223,15 @@ small { color: var(--text-muted); }
|
||||
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||
.aprs-pos:hover { text-decoration: underline; }
|
||||
.aprs-byte { color: var(--accent-yellow); background: rgba(255, 214, 0, 0.12); border: 1px solid rgba(255, 214, 0, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-size: 0.78em; }
|
||||
.ft8-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||
#ft8-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; }
|
||||
.ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; }
|
||||
.ft8-row:last-child { border-bottom: none; }
|
||||
.ft8-time { color: var(--text-muted); min-width: 4.6rem; }
|
||||
.ft8-snr { color: var(--accent-yellow); min-width: 3.6rem; text-align: right; }
|
||||
.ft8-dt { color: var(--text-muted); min-width: 3.6rem; text-align: right; }
|
||||
.ft8-freq { color: var(--accent-green); min-width: 4.6rem; text-align: right; }
|
||||
.ft8-msg { flex: 1; }
|
||||
|
||||
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||
.cw-config { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
|
||||
@@ -321,6 +321,22 @@ pub async fn set_cw_tone(
|
||||
send_command(&rig_tx, RigCommand::SetCwToneHz(query.tone_hz)).await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft8_decode")]
|
||||
pub async fn toggle_ft8_decode(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().ft8_decode_enabled;
|
||||
send_command(&rig_tx, RigCommand::SetFt8DecodeEnabled(!enabled)).await
|
||||
}
|
||||
|
||||
#[post("/clear_ft8_decode")]
|
||||
pub async fn clear_ft8_decode(
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::ResetFt8Decoder).await
|
||||
}
|
||||
|
||||
#[post("/clear_aprs_decode")]
|
||||
pub async fn clear_aprs_decode(
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
@@ -353,14 +369,17 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(set_cw_auto)
|
||||
.service(set_cw_wpm)
|
||||
.service(set_cw_tone)
|
||||
.service(toggle_ft8_decode)
|
||||
.service(clear_aprs_decode)
|
||||
.service(clear_cw_decode)
|
||||
.service(clear_ft8_decode)
|
||||
.service(crate::server::audio::audio_ws)
|
||||
.service(favicon)
|
||||
.service(logo)
|
||||
.service(style_css)
|
||||
.service(app_js)
|
||||
.service(aprs_js)
|
||||
.service(ft8_js)
|
||||
.service(cw_js);
|
||||
}
|
||||
|
||||
@@ -406,6 +425,13 @@ async fn aprs_js() -> impl Responder {
|
||||
.body(status::APRS_JS)
|
||||
}
|
||||
|
||||
#[get("/ft8.js")]
|
||||
async fn ft8_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
|
||||
.body(status::FT8_JS)
|
||||
}
|
||||
|
||||
#[get("/cw.js")]
|
||||
async fn cw_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
@@ -488,6 +514,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
|
||||
cw_auto: state.cw_auto,
|
||||
cw_wpm: state.cw_wpm,
|
||||
cw_tone_hz: state.cw_tone_hz,
|
||||
ft8_decode_enabled: state.ft8_decode_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
||||
|
||||
pub fn index_html() -> String {
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const AUDIO_MSG_RX_FRAME: u8 = 0x01;
|
||||
pub const AUDIO_MSG_TX_FRAME: u8 = 0x02;
|
||||
pub const AUDIO_MSG_APRS_DECODE: u8 = 0x03;
|
||||
pub const AUDIO_MSG_CW_DECODE: u8 = 0x04;
|
||||
pub const AUDIO_MSG_FT8_DECODE: u8 = 0x05;
|
||||
|
||||
/// Maximum payload size (1 MB) to reject bogus frames early.
|
||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||
|
||||
@@ -26,8 +26,10 @@ pub enum ClientCommand {
|
||||
SetCwAuto { enabled: bool },
|
||||
SetCwWpm { wpm: u32 },
|
||||
SetCwToneHz { tone_hz: u32 },
|
||||
SetFt8DecodeEnabled { enabled: bool },
|
||||
ResetAprsDecoder,
|
||||
ResetCwDecoder,
|
||||
ResetFt8Decoder,
|
||||
}
|
||||
|
||||
/// Envelope for client commands with optional authentication token.
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum DecodedMessage {
|
||||
Aprs(AprsPacket),
|
||||
#[serde(rename = "cw")]
|
||||
Cw(CwEvent),
|
||||
#[serde(rename = "ft8")]
|
||||
Ft8(Ft8Message),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -46,3 +48,17 @@ pub struct CwEvent {
|
||||
/// Whether a CW tone is currently detected
|
||||
pub signal_on: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Ft8Message {
|
||||
/// UTC timestamp (milliseconds since epoch)
|
||||
pub ts_ms: i64,
|
||||
/// Approximate SNR (dB)
|
||||
pub snr_db: f32,
|
||||
/// Time offset within slot (seconds)
|
||||
pub dt_s: f32,
|
||||
/// Audio frequency (Hz)
|
||||
pub freq_hz: f32,
|
||||
/// Decoded message text
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ pub enum RigCommand {
|
||||
SetCwAuto(bool),
|
||||
SetCwWpm(u32),
|
||||
SetCwToneHz(u32),
|
||||
SetFt8DecodeEnabled(bool),
|
||||
ResetAprsDecoder,
|
||||
ResetCwDecoder,
|
||||
ResetFt8Decoder,
|
||||
}
|
||||
|
||||
@@ -507,8 +507,10 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
||||
| RigCommand::SetCwAuto(_)
|
||||
| RigCommand::SetCwWpm(_)
|
||||
| RigCommand::SetCwToneHz(_)
|
||||
| RigCommand::SetFt8DecodeEnabled(_)
|
||||
| RigCommand::ResetAprsDecoder
|
||||
| RigCommand::ResetCwDecoder => Box::new(GetSnapshotCommand),
|
||||
| RigCommand::ResetCwDecoder
|
||||
| RigCommand::ResetFt8Decoder => Box::new(GetSnapshotCommand),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct RigState {
|
||||
#[serde(default)]
|
||||
pub cw_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub ft8_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub cw_auto: bool,
|
||||
#[serde(default)]
|
||||
pub cw_wpm: u32,
|
||||
@@ -37,6 +39,8 @@ pub struct RigState {
|
||||
pub aprs_decode_reset_seq: u64,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub cw_decode_reset_seq: u64,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub ft8_decode_reset_seq: u64,
|
||||
}
|
||||
|
||||
/// Mode supported by the rig.
|
||||
@@ -87,6 +91,7 @@ impl RigState {
|
||||
cw_auto: self.cw_auto,
|
||||
cw_wpm: self.cw_wpm,
|
||||
cw_tone_hz: self.cw_tone_hz,
|
||||
ft8_decode_enabled: self.ft8_decode_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +139,8 @@ pub struct RigSnapshot {
|
||||
#[serde(default)]
|
||||
pub cw_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub ft8_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub cw_auto: bool,
|
||||
#[serde(default)]
|
||||
pub cw_wpm: u32,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-ft8"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
fn main() {
|
||||
let base = "../../external/ft8_lib";
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.include(base)
|
||||
.include(format!("{base}/common"))
|
||||
.include(format!("{base}/fft"))
|
||||
.include(format!("{base}/ft8"))
|
||||
.file("src/ft8_wrapper.c")
|
||||
.file(format!("{base}/common/monitor.c"))
|
||||
.file(format!("{base}/fft/kiss_fft.c"))
|
||||
.file(format!("{base}/fft/kiss_fftr.c"))
|
||||
.file(format!("{base}/ft8/constants.c"))
|
||||
.file(format!("{base}/ft8/crc.c"))
|
||||
.file(format!("{base}/ft8/decode.c"))
|
||||
.file(format!("{base}/ft8/ldpc.c"))
|
||||
.file(format!("{base}/ft8/message.c"))
|
||||
.file(format!("{base}/ft8/text.c"))
|
||||
.flag_if_supported("-std=c99")
|
||||
.compile("trx_ft8");
|
||||
|
||||
println!("cargo:rustc-link-lib=m");
|
||||
|
||||
println!("cargo:rerun-if-changed=src/ft8_wrapper.c");
|
||||
println!("cargo:rerun-if-changed={base}/common/monitor.c");
|
||||
println!("cargo:rerun-if-changed={base}/fft/kiss_fft.c");
|
||||
println!("cargo:rerun-if-changed={base}/fft/kiss_fftr.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/constants.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/crc.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/ldpc.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/message.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/text.c");
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#include <ft8/decode.h>
|
||||
#include <ft8/message.h>
|
||||
#include <ft8/text.h>
|
||||
#include <common/monitor.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Callsign hash table (from demo/decode_ft8.c)
|
||||
#define CALLSIGN_HASHTABLE_SIZE 256
|
||||
|
||||
typedef struct
|
||||
{
|
||||
uint32_t hash;
|
||||
char callsign[12];
|
||||
} callsign_hashtable_entry_t;
|
||||
|
||||
static callsign_hashtable_entry_t callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
|
||||
static int callsign_hashtable_size = 0;
|
||||
|
||||
static void hashtable_init(void)
|
||||
{
|
||||
callsign_hashtable_size = 0;
|
||||
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
|
||||
}
|
||||
|
||||
static void hashtable_cleanup(uint8_t max_age)
|
||||
{
|
||||
for (int idx_hash = 0; idx_hash < CALLSIGN_HASHTABLE_SIZE; ++idx_hash)
|
||||
{
|
||||
if (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
uint8_t age = (uint8_t)(callsign_hashtable[idx_hash].hash >> 24);
|
||||
if (age >= max_age)
|
||||
{
|
||||
callsign_hashtable[idx_hash].callsign[0] = '\0';
|
||||
callsign_hashtable[idx_hash].hash = 0;
|
||||
callsign_hashtable_size--;
|
||||
}
|
||||
else
|
||||
{
|
||||
callsign_hashtable[idx_hash].hash = (((uint32_t)age + 1u) << 24) | (callsign_hashtable[idx_hash].hash & 0x3FFFFFu);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void hashtable_add(const char* callsign, uint32_t hash)
|
||||
{
|
||||
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
|
||||
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
|
||||
{
|
||||
callsign_hashtable[idx_hash].hash &= 0x3FFFFFu;
|
||||
return;
|
||||
}
|
||||
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
}
|
||||
callsign_hashtable_size++;
|
||||
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
|
||||
callsign_hashtable[idx_hash].callsign[11] = '\0';
|
||||
callsign_hashtable[idx_hash].hash = hash;
|
||||
}
|
||||
|
||||
static bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
|
||||
{
|
||||
int hash_shift = (hash_type == FTX_CALLSIGN_HASH_22) ? 0 : (hash_type == FTX_CALLSIGN_HASH_12) ? 10 : 12;
|
||||
uint32_t mask = (hash_type == FTX_CALLSIGN_HASH_22) ? 0x3FFFFFu : (hash_type == FTX_CALLSIGN_HASH_12) ? 0xFFFu : 0x3FFu;
|
||||
|
||||
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
|
||||
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) >> hash_shift) == (hash & mask))
|
||||
{
|
||||
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
|
||||
return true;
|
||||
}
|
||||
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
}
|
||||
callsign[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
static ftx_callsign_hash_interface_t hash_if = {
|
||||
.lookup_hash = hashtable_lookup,
|
||||
.save_hash = hashtable_add,
|
||||
};
|
||||
|
||||
// Decoder wrapper
|
||||
|
||||
typedef struct
|
||||
{
|
||||
monitor_t mon;
|
||||
monitor_config_t cfg;
|
||||
} ft8_decoder_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char text[FTX_MAX_MESSAGE_LENGTH];
|
||||
float snr_db;
|
||||
float dt_s;
|
||||
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* dec = (ft8_decoder_t*)calloc(1, sizeof(ft8_decoder_t));
|
||||
if (!dec)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
dec->cfg.f_min = f_min;
|
||||
dec->cfg.f_max = f_max;
|
||||
dec->cfg.sample_rate = sample_rate;
|
||||
dec->cfg.time_osr = time_osr;
|
||||
dec->cfg.freq_osr = freq_osr;
|
||||
dec->cfg.protocol = FTX_PROTOCOL_FT8;
|
||||
|
||||
hashtable_init();
|
||||
monitor_init(&dec->mon, &dec->cfg);
|
||||
return dec;
|
||||
}
|
||||
|
||||
void ft8_decoder_free(ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return;
|
||||
monitor_free(&dec->mon);
|
||||
free(dec);
|
||||
}
|
||||
|
||||
int ft8_decoder_block_size(const ft8_decoder_t* dec)
|
||||
{
|
||||
return dec ? dec->mon.block_size : 0;
|
||||
}
|
||||
|
||||
void ft8_decoder_reset(ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return;
|
||||
monitor_reset(&dec->mon);
|
||||
}
|
||||
|
||||
void ft8_decoder_process(ft8_decoder_t* dec, const float* frame)
|
||||
{
|
||||
if (!dec || !frame)
|
||||
return;
|
||||
monitor_process(&dec->mon, frame);
|
||||
}
|
||||
|
||||
int ft8_decoder_is_ready(const ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return 0;
|
||||
return (dec->mon.wf.num_blocks >= dec->mon.wf.max_blocks) ? 1 : 0;
|
||||
}
|
||||
|
||||
int ft8_decoder_decode(ft8_decoder_t* dec, ft8_decode_result_t* out, int max_results)
|
||||
{
|
||||
if (!dec || !out || max_results <= 0)
|
||||
return 0;
|
||||
|
||||
const ftx_waterfall_t* wf = &dec->mon.wf;
|
||||
const int kMaxCandidates = 200;
|
||||
const int kMinScore = 10;
|
||||
const int kLdpcIters = 30;
|
||||
|
||||
ftx_candidate_t candidate_list[kMaxCandidates];
|
||||
int num_candidates = ftx_find_candidates(wf, kMaxCandidates, candidate_list, kMinScore);
|
||||
|
||||
int num_decoded = 0;
|
||||
ftx_message_t decoded[200];
|
||||
ftx_message_t* decoded_hashtable[200];
|
||||
for (int i = 0; i < 200; ++i)
|
||||
{
|
||||
decoded_hashtable[i] = NULL;
|
||||
}
|
||||
|
||||
for (int idx = 0; idx < num_candidates && num_decoded < max_results; ++idx)
|
||||
{
|
||||
const ftx_candidate_t* cand = &candidate_list[idx];
|
||||
|
||||
float freq_hz = (dec->mon.min_bin + cand->freq_offset + (float)cand->freq_sub / wf->freq_osr) / dec->mon.symbol_period;
|
||||
float time_sec = (cand->time_offset + (float)cand->time_sub / wf->time_osr) * dec->mon.symbol_period;
|
||||
|
||||
ftx_message_t message;
|
||||
ftx_decode_status_t status;
|
||||
if (!ftx_decode_candidate(wf, cand, kLdpcIters, &message, &status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int idx_hash = message.hash % 200;
|
||||
bool found_empty_slot = false;
|
||||
bool found_duplicate = false;
|
||||
do
|
||||
{
|
||||
if (decoded_hashtable[idx_hash] == NULL)
|
||||
{
|
||||
found_empty_slot = true;
|
||||
}
|
||||
else if ((decoded_hashtable[idx_hash]->hash == message.hash) && (0 == memcmp(decoded_hashtable[idx_hash]->payload, message.payload, sizeof(message.payload))))
|
||||
{
|
||||
found_duplicate = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
idx_hash = (idx_hash + 1) % 200;
|
||||
}
|
||||
} while (!found_empty_slot && !found_duplicate);
|
||||
|
||||
if (!found_empty_slot)
|
||||
continue;
|
||||
|
||||
memcpy(&decoded[idx_hash], &message, sizeof(message));
|
||||
decoded_hashtable[idx_hash] = &decoded[idx_hash];
|
||||
|
||||
char text[FTX_MAX_MESSAGE_LENGTH];
|
||||
ftx_message_offsets_t offsets;
|
||||
ftx_message_rc_t unpack_status = ftx_message_decode(&message, &hash_if, text, &offsets);
|
||||
if (unpack_status != FTX_MESSAGE_RC_OK)
|
||||
{
|
||||
snprintf(text, sizeof(text), "Error [%d] while unpacking!", (int)unpack_status);
|
||||
}
|
||||
|
||||
ft8_decode_result_t* dst = &out[num_decoded];
|
||||
strncpy(dst->text, text, sizeof(dst->text) - 1);
|
||||
dst->text[sizeof(dst->text) - 1] = '\0';
|
||||
dst->dt_s = time_sec;
|
||||
dst->freq_hz = freq_hz;
|
||||
dst->snr_db = cand->score * 0.5f;
|
||||
|
||||
num_decoded++;
|
||||
}
|
||||
|
||||
hashtable_cleanup(10);
|
||||
return num_decoded;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use libc::{c_float, c_int, c_void};
|
||||
use std::ffi::CStr;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
const F_MIN_HZ: f32 = 200.0;
|
||||
const F_MAX_HZ: f32 = 3000.0;
|
||||
const TIME_OSR: i32 = 2;
|
||||
const FREQ_OSR: i32 = 2;
|
||||
|
||||
#[repr(C)]
|
||||
struct Ft8DecodeResultRaw {
|
||||
text: [libc::c_char; 64],
|
||||
snr_db: c_float,
|
||||
dt_s: c_float,
|
||||
freq_hz: c_float,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ft8DecodeResult {
|
||||
pub text: String,
|
||||
pub snr_db: f32,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn ft8_decoder_create(
|
||||
sample_rate: c_int,
|
||||
f_min: c_float,
|
||||
f_max: c_float,
|
||||
time_osr: c_int,
|
||||
freq_osr: c_int,
|
||||
) -> *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_reset(dec: *mut c_void);
|
||||
fn ft8_decoder_process(dec: *mut c_void, frame: *const c_float);
|
||||
fn ft8_decoder_is_ready(dec: *const c_void) -> c_int;
|
||||
fn ft8_decoder_decode(dec: *mut c_void, out: *mut Ft8DecodeResultRaw, max_results: c_int) -> c_int;
|
||||
}
|
||||
|
||||
pub struct Ft8Decoder {
|
||||
inner: NonNull<c_void>,
|
||||
block_size: usize,
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
impl Ft8Decoder {
|
||||
pub fn new(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,
|
||||
);
|
||||
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 FT8 block size".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
inner,
|
||||
block_size,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block_size(&self) -> usize {
|
||||
self.block_size
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
unsafe {
|
||||
ft8_decoder_reset(self.inner.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, block: &[f32]) {
|
||||
if block.len() < self.block_size {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
ft8_decoder_process(self.inner.as_ptr(), block.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||
unsafe {
|
||||
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut raw = vec![Ft8DecodeResultRaw {
|
||||
text: [0; 64],
|
||||
snr_db: 0.0,
|
||||
dt_s: 0.0,
|
||||
freq_hz: 0.0,
|
||||
}; max_results];
|
||||
let count = ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
|
||||
let count = count.max(0) as usize;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for item in raw.into_iter().take(count) {
|
||||
let text = unsafe { CStr::from_ptr(item.text.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
out.push(Ft8DecodeResult {
|
||||
text,
|
||||
snr_db: item.snr_db,
|
||||
dt_s: item.dt_s,
|
||||
freq_hz: item.freq_hz,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Ft8Decoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
ft8_decoder_free(self.inner.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,4 @@ cpal = "0.15"
|
||||
opus = "0.3"
|
||||
trx-backend = { path = "trx-backend" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-ft8 = { path = "../trx-ft8" }
|
||||
|
||||
+198
-2
@@ -16,15 +16,19 @@ use tracing::{error, info, warn};
|
||||
|
||||
use trx_core::audio::{
|
||||
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_APRS_DECODE,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO,
|
||||
AUDIO_MSG_TX_FRAME,
|
||||
};
|
||||
use trx_core::decode::{AprsPacket, DecodedMessage};
|
||||
use trx_core::decode::{AprsPacket, DecodedMessage, Ft8Message};
|
||||
use trx_core::rig::state::{RigMode, RigState};
|
||||
use trx_ft8::Ft8Decoder;
|
||||
|
||||
use crate::config::AudioConfig;
|
||||
use crate::decode;
|
||||
|
||||
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||
|
||||
fn aprs_history() -> &'static Mutex<VecDeque<(Instant, AprsPacket)>> {
|
||||
static HISTORY: OnceLock<Mutex<VecDeque<(Instant, AprsPacket)>>> = OnceLock::new();
|
||||
@@ -59,6 +63,39 @@ pub fn clear_aprs_history() {
|
||||
history.clear();
|
||||
}
|
||||
|
||||
fn ft8_history() -> &'static Mutex<VecDeque<(Instant, Ft8Message)>> {
|
||||
static HISTORY: OnceLock<Mutex<VecDeque<(Instant, Ft8Message)>>> = OnceLock::new();
|
||||
HISTORY.get_or_init(|| Mutex::new(VecDeque::new()))
|
||||
}
|
||||
|
||||
fn prune_ft8_history(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_ft8_message(msg: Ft8Message) {
|
||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
||||
history.push_back((Instant::now(), msg));
|
||||
prune_ft8_history(&mut history);
|
||||
}
|
||||
|
||||
pub fn snapshot_ft8_history() -> Vec<Ft8Message> {
|
||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
||||
prune_ft8_history(&mut history);
|
||||
history.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft8_history() {
|
||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
/// Spawn the audio capture thread.
|
||||
///
|
||||
/// Opens the configured input device via cpal, accumulates PCM samples into
|
||||
@@ -539,6 +576,155 @@ pub async fn run_cw_decoder(
|
||||
}
|
||||
}
|
||||
|
||||
fn downmix_mono(frame: Vec<f32>, channels: u16) -> Vec<f32> {
|
||||
if channels <= 1 {
|
||||
return frame;
|
||||
}
|
||||
let num_frames = frame.len() / channels as usize;
|
||||
let mut mono = Vec::with_capacity(num_frames);
|
||||
for i in 0..num_frames {
|
||||
mono.push(frame[i * channels as usize]);
|
||||
}
|
||||
mono
|
||||
}
|
||||
|
||||
fn resample_to_12k(samples: &[f32], sample_rate: u32) -> Option<Vec<f32>> {
|
||||
if sample_rate == FT8_SAMPLE_RATE {
|
||||
return Some(samples.to_vec());
|
||||
}
|
||||
if sample_rate % FT8_SAMPLE_RATE != 0 {
|
||||
return None;
|
||||
}
|
||||
let factor = (sample_rate / FT8_SAMPLE_RATE) as usize;
|
||||
if factor == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(samples.len() / factor);
|
||||
for chunk in samples.chunks_exact(factor) {
|
||||
let mut acc = 0.0f32;
|
||||
for &s in chunk {
|
||||
acc += s;
|
||||
}
|
||||
out.push(acc / factor as f32);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Run the FT8 decoder task. Only processes PCM when rig mode is DIG/USB and enabled.
|
||||
pub async fn run_ft8_decoder(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
) {
|
||||
info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||
let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
|
||||
Ok(decoder) => decoder,
|
||||
Err(err) => {
|
||||
warn!("FT8 decoder init failed: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut last_reset_seq: u64 = 0;
|
||||
let mut active = state_rx.borrow().ft8_decode_enabled
|
||||
&& matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB);
|
||||
let mut ft8_buf: Vec<f32> = Vec::new();
|
||||
|
||||
loop {
|
||||
if !active {
|
||||
match state_rx.changed().await {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = state.ft8_decode_enabled
|
||||
&& matches!(state.status.mode, RigMode::DIG | RigMode::USB);
|
||||
if active {
|
||||
pcm_rx = pcm_rx.resubscribe();
|
||||
}
|
||||
if state.ft8_decode_reset_seq != last_reset_seq {
|
||||
last_reset_seq = state.ft8_decode_reset_seq;
|
||||
decoder.reset();
|
||||
ft8_buf.clear();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
recv = pcm_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
let state = state_rx.borrow();
|
||||
if state.ft8_decode_reset_seq != last_reset_seq {
|
||||
last_reset_seq = state.ft8_decode_reset_seq;
|
||||
decoder.reset();
|
||||
ft8_buf.clear();
|
||||
}
|
||||
|
||||
let mono = downmix_mono(frame, channels);
|
||||
let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
|
||||
warn!("FT8 decoder: unsupported sample rate {}", sample_rate);
|
||||
break;
|
||||
};
|
||||
ft8_buf.extend_from_slice(&resampled);
|
||||
|
||||
while ft8_buf.len() >= decoder.block_size() {
|
||||
let block: Vec<f32> = ft8_buf.drain(..decoder.block_size()).collect();
|
||||
decoder.process_block(&block);
|
||||
let results = decoder.decode_if_ready(100);
|
||||
if !results.is_empty() {
|
||||
decoder.reset();
|
||||
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 msg = Ft8Message {
|
||||
ts_ms,
|
||||
snr_db: res.snr_db,
|
||||
dt_s: res.dt_s,
|
||||
freq_hz: res.freq_hz,
|
||||
message: res.text,
|
||||
};
|
||||
record_ft8_message(msg.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::Ft8(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("FT8 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.ft8_decode_enabled
|
||||
&& matches!(state.status.mode, RigMode::DIG | RigMode::USB);
|
||||
if state.ft8_decode_reset_seq != last_reset_seq {
|
||||
last_reset_seq = state.ft8_decode_reset_seq;
|
||||
decoder.reset();
|
||||
ft8_buf.clear();
|
||||
}
|
||||
if !active {
|
||||
decoder.reset();
|
||||
ft8_buf.clear();
|
||||
} else {
|
||||
pcm_rx = pcm_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the audio TCP listener, accepting client connections.
|
||||
pub async fn run_audio_listener(
|
||||
addr: SocketAddr,
|
||||
@@ -594,6 +780,15 @@ async fn handle_audio_client(
|
||||
write_audio_msg(&mut writer, msg_type, &json).await?;
|
||||
}
|
||||
}
|
||||
// Send FT8 history to newly connected client.
|
||||
let history = snapshot_ft8_history();
|
||||
for msg in history {
|
||||
let msg = DecodedMessage::Ft8(msg);
|
||||
let msg_type = AUDIO_MSG_FT8_DECODE;
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
write_audio_msg(&mut writer, msg_type, &json).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn RX + decode forwarding task (shares the writer)
|
||||
let mut rx_sub = rx_audio.subscribe();
|
||||
@@ -622,6 +817,7 @@ async fn handle_audio_client(
|
||||
let msg_type = match &msg {
|
||||
DecodedMessage::Aprs(_) => AUDIO_MSG_APRS_DECODE,
|
||||
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
|
||||
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||
|
||||
@@ -193,8 +193,10 @@ fn map_command(cmd: ClientCommand) -> RigCommand {
|
||||
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
|
||||
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
|
||||
ClientCommand::SetCwToneHz { tone_hz } => RigCommand::SetCwToneHz(tone_hz),
|
||||
ClientCommand::SetFt8DecodeEnabled { enabled } => RigCommand::SetFt8DecodeEnabled(enabled),
|
||||
ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder,
|
||||
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
|
||||
ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,8 +227,10 @@ fn build_initial_state(cfg: &ServerConfig, resolved: &ResolvedConfig) -> RigStat
|
||||
cw_auto: true,
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
ft8_decode_enabled: false,
|
||||
aprs_decode_reset_seq: 0,
|
||||
cw_decode_reset_seq: 0,
|
||||
ft8_decode_reset_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +373,16 @@ async fn main() -> DynResult<()> {
|
||||
tokio::spawn(audio::run_cw_decoder(
|
||||
cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx,
|
||||
));
|
||||
|
||||
// Spawn FT8 decoder task
|
||||
let ft8_pcm_rx = pcm_tx.subscribe();
|
||||
let ft8_state_rx = _state_rx.clone();
|
||||
let ft8_decode_tx = decode_tx.clone();
|
||||
let ft8_sr = cfg.audio.sample_rate;
|
||||
let ft8_ch = cfg.audio.channels;
|
||||
tokio::spawn(audio::run_ft8_decoder(
|
||||
ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx,
|
||||
));
|
||||
}
|
||||
if cfg.audio.tx_enabled {
|
||||
let _playback_thread = audio::spawn_audio_playback(&cfg.audio, tx_audio_rx);
|
||||
|
||||
@@ -128,8 +128,10 @@ pub async fn run_rig_task(
|
||||
cw_auto: true,
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
ft8_decode_enabled: false,
|
||||
aprs_decode_reset_seq: 0,
|
||||
cw_decode_reset_seq: 0,
|
||||
ft8_decode_reset_seq: 0,
|
||||
};
|
||||
|
||||
// Polling configuration
|
||||
@@ -378,6 +380,11 @@ async fn process_command(
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::SetFt8DecodeEnabled(en) => {
|
||||
ctx.state.ft8_decode_enabled = en;
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::ResetAprsDecoder => {
|
||||
audio::clear_aprs_history();
|
||||
ctx.state.aprs_decode_reset_seq += 1;
|
||||
@@ -389,6 +396,12 @@ async fn process_command(
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::ResetFt8Decoder => {
|
||||
audio::clear_ft8_history();
|
||||
ctx.state.ft8_decode_reset_seq += 1;
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
_ => {} // fall through to normal rig handler
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user