diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index 583989b..4487538 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -3606,7 +3606,7 @@ async function initializeApp() {
updateAuthUI();
connect();
connectDecode();
- initSchedulerUI();
+ initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
return;
@@ -3620,7 +3620,7 @@ async function initializeApp() {
applyAuthRestrictions();
connect();
connectDecode();
- initSchedulerUI();
+ initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
@@ -3631,11 +3631,15 @@ async function initializeApp() {
}
}
-function initSchedulerUI() {
+function initSettingsUI() {
if (typeof initScheduler === "function") {
initScheduler(lastActiveRigId, authRole);
wireSchedulerEvents();
}
+ if (typeof initBackgroundDecode === "function") {
+ initBackgroundDecode(lastActiveRigId, authRole);
+ wireBackgroundDecodeEvents();
+ }
}
// Setup auth form
@@ -3655,7 +3659,7 @@ document.getElementById("auth-form").addEventListener("submit", async (e) => {
applyAuthRestrictions();
connect();
connectDecode();
- initSchedulerUI();
+ initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
@@ -3678,7 +3682,7 @@ if (guestBtn) {
applyAuthRestrictions();
connect();
connectDecode();
- initSchedulerUI();
+ initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index eb27bec..df33473 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -674,6 +674,7 @@
+
+
+
+
+
+
Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Runtime Status
+
No background decode bookmarks configured.
+
+
+
Authenticated as: --
@@ -819,6 +858,7 @@
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/background-decode.js
new file mode 100644
index 0000000..35dc331
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/background-decode.js
@@ -0,0 +1,355 @@
+// SPDX-FileCopyrightText: 2026 Stanislaw Grams
+//
+// SPDX-License-Identifier: BSD-2-Clause
+
+(function () {
+ "use strict";
+
+ const SUPPORTED_DECODERS = ["ft8", "wspr", "hf-aprs"];
+
+ let backgroundDecodeRole = null;
+ let currentRigId = null;
+ let currentConfig = null;
+ let bookmarkList = [];
+ let statusInterval = null;
+
+ function initBackgroundDecode(rigId, role) {
+ backgroundDecodeRole = role;
+ currentRigId = rigId || null;
+ renderRigSelect();
+ loadBackgroundDecode();
+ startStatusPolling();
+ }
+
+ function renderRigSelect() {
+ const sel = document.getElementById("background-decode-rig-select");
+ if (!sel) return;
+ const rigs = typeof getAvailableRigIds === "function" ? getAvailableRigIds() : [];
+ if (!rigs.length) return;
+ sel.innerHTML = "";
+ rigs.forEach(function (rigId) {
+ const opt = document.createElement("option");
+ opt.value = rigId;
+ opt.textContent = rigId;
+ if (rigId === currentRigId) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ if (!currentRigId || !rigs.includes(currentRigId)) {
+ currentRigId = rigs[0];
+ sel.value = currentRigId;
+ }
+ }
+
+ function apiGetConfig(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiPutConfig(rigId, config) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiResetConfig(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId), {
+ method: "DELETE",
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetStatus(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetBookmarks() {
+ return fetch("/bookmarks").then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function loadBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
+ .then(function ([config, bookmarks]) {
+ currentConfig = config || { rig_id: rigId, enabled: false, bookmark_ids: [] };
+ bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
+ renderBookmarkPick();
+ renderBackgroundDecode();
+ pollBackgroundDecodeStatus();
+ })
+ .catch(function (err) {
+ console.error("background decode load failed", err);
+ });
+ }
+
+ function supportedBookmarks() {
+ return bookmarkList.filter(function (bookmark) {
+ return bookmarkDecoderKinds(bookmark).length > 0;
+ });
+ }
+
+ function bookmarkDecoderKinds(bookmark) {
+ const decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
+ return decoders
+ .map(function (item) { return String(item || "").trim().toLowerCase(); })
+ .filter(function (item, index, arr) {
+ return SUPPORTED_DECODERS.includes(item) && arr.indexOf(item) === index;
+ });
+ }
+
+ function renderBookmarkPick() {
+ const sel = document.getElementById("background-decode-bookmark-pick");
+ if (!sel) return;
+ const selectedIds = new Set(currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []);
+ sel.innerHTML = '';
+ supportedBookmarks().forEach(function (bookmark) {
+ if (selectedIds.has(bookmark.id)) return;
+ const opt = document.createElement("option");
+ opt.value = bookmark.id;
+ opt.textContent = bookmark.name + " (" + formatFreq(bookmark.freq_hz) + " " + bookmark.mode + ")";
+ sel.appendChild(opt);
+ });
+ }
+
+ function renderBackgroundDecode() {
+ if (!currentConfig) {
+ currentConfig = { rig_id: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ setCheckbox("background-decode-enabled", !!currentConfig.enabled);
+ renderBookmarkList();
+
+ const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
+ const panel = document.getElementById("background-decode-panel");
+ if (panel) {
+ panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
+ el.disabled = !isControl;
+ });
+ }
+ const saveBtn = document.getElementById("background-decode-save-btn");
+ const resetBtn = document.getElementById("background-decode-reset-btn");
+ if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
+ if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
+ }
+
+ function renderBookmarkList() {
+ const container = document.getElementById("background-decode-bookmark-list");
+ if (!container) return;
+ container.innerHTML = "";
+ const ids = currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : [];
+ if (!ids.length) {
+ container.textContent = "No background decode bookmarks selected.";
+ return;
+ }
+ ids.forEach(function (id) {
+ const bookmark = bookmarkList.find(function (item) { return item.id === id; });
+ const chip = document.createElement("div");
+ chip.className = "bgd-bookmark-chip";
+ const decoders = bookmarkDecoderKinds(bookmark);
+ chip.innerHTML =
+ '' + escHtml(bookmark ? bookmark.name : id) + '' +
+ '' + escHtml(bookmark ? (formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) : "Missing bookmark") + '';
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "bgd-bookmark-chip-remove sch-write";
+ btn.textContent = "×";
+ btn.addEventListener("click", function () {
+ removeBookmark(id);
+ });
+ chip.appendChild(btn);
+ container.appendChild(chip);
+ });
+ }
+
+ function removeBookmark(id) {
+ if (!currentConfig || !Array.isArray(currentConfig.bookmark_ids)) return;
+ currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (item) { return item !== id; });
+ renderBookmarkPick();
+ renderBackgroundDecode();
+ }
+
+ function addBookmark() {
+ const sel = document.getElementById("background-decode-bookmark-pick");
+ if (!sel || !sel.value) return;
+ if (!currentConfig) {
+ currentConfig = { rig_id: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
+ if (!currentConfig.bookmark_ids.includes(sel.value)) currentConfig.bookmark_ids.push(sel.value);
+ sel.value = "";
+ renderBookmarkPick();
+ renderBackgroundDecode();
+ }
+
+ function saveBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ const payload = {
+ rig_id: rigId,
+ enabled: !!document.getElementById("background-decode-enabled").checked,
+ bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
+ };
+ const btn = document.getElementById("background-decode-save-btn");
+ if (btn) btn.disabled = true;
+ apiPutConfig(rigId, payload)
+ .then(function (saved) {
+ currentConfig = saved;
+ renderBookmarkPick();
+ renderBackgroundDecode();
+ pollBackgroundDecodeStatus();
+ showToast("Background decode saved.");
+ })
+ .catch(function (err) {
+ showToast("Save failed: " + err.message, true);
+ })
+ .finally(function () {
+ if (btn) btn.disabled = false;
+ });
+ }
+
+ function resetBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ apiResetConfig(rigId)
+ .then(function (saved) {
+ currentConfig = saved;
+ renderBookmarkPick();
+ renderBackgroundDecode();
+ pollBackgroundDecodeStatus();
+ showToast("Background decode reset.");
+ })
+ .catch(function (err) {
+ showToast("Reset failed: " + err.message, true);
+ });
+ }
+
+ function startStatusPolling() {
+ if (statusInterval) clearInterval(statusInterval);
+ statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
+ }
+
+ function pollBackgroundDecodeStatus() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ apiGetStatus(rigId)
+ .then(renderStatus)
+ .catch(function () {});
+ }
+
+ function renderStatus(status) {
+ const card = document.getElementById("background-decode-status-card");
+ if (!card) return;
+ const entries = Array.isArray(status && status.entries) ? status.entries : [];
+ if (!entries.length) {
+ card.textContent = "No background decode bookmarks configured.";
+ return;
+ }
+ const summary = [];
+ if (status.active_rig) {
+ if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
+ if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
+ } else {
+ summary.push("This rig is not currently selected for audio.");
+ }
+ let html = summary.length ? '' + escHtml(summary.join(" · ")) + "
" : "";
+ html += '';
+ entries.forEach(function (entry) {
+ const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
+ const parts = [];
+ if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
+ if (entry.mode) parts.push(entry.mode);
+ if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
+ parts.push(entry.decoder_kinds.join("/").toUpperCase());
+ }
+ html +=
+ '
' +
+ '
' +
+ '
' + escHtml(name) + '
' +
+ '
' + escHtml(parts.join(" · ")) + '
' +
+ '
' +
+ '
' + escHtml(prettyState(entry.state)) + '
' +
+ '
';
+ });
+ html += "
";
+ card.innerHTML = html;
+ }
+
+ function prettyState(state) {
+ switch (state) {
+ case "active": return "Active";
+ case "out_of_span": return "Out of span";
+ case "waiting_for_spectrum": return "Waiting";
+ case "missing_bookmark": return "Missing";
+ case "no_supported_decoders": return "Unsupported";
+ case "disabled": return "Disabled";
+ default: return "Inactive";
+ }
+ }
+
+ function setCheckbox(id, value) {
+ const el = document.getElementById(id);
+ if (el) el.checked = !!value;
+ }
+
+ function formatFreq(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return "--";
+ if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
+ return hz + " Hz";
+ }
+
+ function escHtml(value) {
+ return String(value == null ? "" : value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function showToast(msg, isError) {
+ const el = document.getElementById("background-decode-toast");
+ if (!el) return;
+ el.textContent = msg;
+ el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
+ el.style.display = "block";
+ setTimeout(function () {
+ el.style.display = "none";
+ }, 3000);
+ }
+
+ function wireBackgroundDecodeEvents() {
+ const rigSel = document.getElementById("background-decode-rig-select");
+ if (rigSel) {
+ rigSel.addEventListener("change", function () {
+ currentRigId = rigSel.value;
+ loadBackgroundDecode();
+ });
+ }
+
+ const addBtn = document.getElementById("background-decode-bookmark-add");
+ if (addBtn) addBtn.addEventListener("click", addBookmark);
+
+ const saveBtn = document.getElementById("background-decode-save-btn");
+ if (saveBtn) saveBtn.addEventListener("click", saveBackgroundDecode);
+
+ const resetBtn = document.getElementById("background-decode-reset-btn");
+ if (resetBtn) resetBtn.addEventListener("click", resetBackgroundDecode);
+ }
+
+ window.initBackgroundDecode = initBackgroundDecode;
+ window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 0b62825..84f6d95 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -3491,6 +3491,94 @@ button:focus-visible, input:focus-visible, select:focus-visible {
line-height: 1;
}
.sch-extra-bm-rm:hover { opacity: 1; }
+.bgd-toggle-wrap {
+ min-width: 18rem;
+}
+.bgd-toggle-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-height: var(--control-height);
+ color: var(--text);
+ font-weight: 500;
+}
+.bgd-add-row {
+ display: flex;
+ gap: 0.55rem;
+ align-items: center;
+}
+.bgd-bookmark-pick {
+ min-width: min(34rem, 100%);
+}
+.bgd-bookmark-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+}
+.bgd-bookmark-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ border: 1px solid var(--border-light);
+ background: var(--btn-bg);
+ color: var(--text);
+ font-size: 0.85rem;
+}
+.bgd-bookmark-chip-meta {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+}
+.bgd-bookmark-chip-remove {
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0;
+ height: auto;
+}
+.bgd-bookmark-chip-remove:hover {
+ color: var(--text);
+}
+.bgd-status-list {
+ display: grid;
+ gap: 0.65rem;
+}
+.bgd-status-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--border-light);
+ border-radius: 0.55rem;
+ background: color-mix(in srgb, var(--card-bg) 74%, transparent);
+}
+.bgd-status-name {
+ font-weight: 600;
+}
+.bgd-status-meta {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+.bgd-status-state {
+ align-self: center;
+ white-space: nowrap;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--accent-green);
+}
+.bgd-status-state[data-state="out_of_span"],
+.bgd-status-state[data-state="waiting_for_spectrum"],
+.bgd-status-state[data-state="inactive"] {
+ color: var(--accent-yellow);
+}
+.bgd-status-state[data-state="missing_bookmark"],
+.bgd-status-state[data-state="no_supported_decoders"] {
+ color: var(--accent-red);
+}
@media (max-width: 600px) {
.sch-row {
flex-direction: column;
@@ -3498,4 +3586,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-label {
min-width: 100%;
}
+ .bgd-add-row,
+ .bgd-status-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
index a76cbbe..436578f 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
@@ -1300,6 +1300,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::scheduler::put_scheduler)
.service(crate::server::scheduler::delete_scheduler)
.service(crate::server::scheduler::get_scheduler_status)
+ .service(crate::server::background_decode::get_background_decode)
+ .service(crate::server::background_decode::put_background_decode)
+ .service(crate::server::background_decode::delete_background_decode)
+ .service(crate::server::background_decode::get_background_decode_status)
.service(crate::server::audio::audio_ws)
.service(favicon)
.service(favicon_png)
@@ -1317,6 +1321,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(cw_js)
.service(bookmarks_js)
.service(scheduler_js)
+ .service(background_decode_js)
.service(vchan_js)
// Virtual channels
.service(list_channels)
@@ -1487,6 +1492,16 @@ async fn scheduler_js() -> impl Responder {
.body(status::SCHEDULER_JS)
}
+#[get("/background-decode.js")]
+async fn background_decode_js() -> impl Responder {
+ HttpResponse::Ok()
+ .insert_header((
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ ))
+ .body(status::BACKGROUND_DECODE_JS)
+}
+
#[get("/vchan.js")]
async fn vchan_js() -> impl Responder {
HttpResponse::Ok()
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs
new file mode 100644
index 0000000..363df8d
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs
@@ -0,0 +1,507 @@
+// SPDX-FileCopyrightText: 2026 Stanislaw Grams
+//
+// SPDX-License-Identifier: BSD-2-Clause
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
+use std::time::Duration;
+
+use actix_web::{delete, get, put, web, HttpResponse, Responder};
+use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
+use serde::{Deserialize, Serialize};
+use tokio::sync::broadcast;
+use tokio::time;
+use tracing::warn;
+use trx_frontend::{FrontendRuntimeContext, SharedSpectrum, VChanAudioCmd};
+use uuid::Uuid;
+
+use crate::server::bookmarks::{Bookmark, BookmarkStore};
+
+const SUPPORTED_DECODER_KINDS: &[&str] = &["ft8", "wspr", "hf-aprs"];
+const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct BackgroundDecodeConfig {
+ pub rig_id: String,
+ #[serde(default)]
+ pub enabled: bool,
+ #[serde(default)]
+ pub bookmark_ids: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Default)]
+pub struct BackgroundDecodeBookmarkStatus {
+ pub bookmark_id: String,
+ pub bookmark_name: Option,
+ pub freq_hz: Option,
+ pub mode: Option,
+ #[serde(default)]
+ pub decoder_kinds: Vec,
+ pub state: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub channel_kind: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Default)]
+pub struct BackgroundDecodeStatus {
+ pub rig_id: String,
+ pub enabled: bool,
+ pub active_rig: bool,
+ pub center_hz: Option,
+ pub sample_rate: Option,
+ #[serde(default)]
+ pub entries: Vec,
+}
+
+#[derive(Debug)]
+struct VirtualBackgroundDecodeChannel {
+ uuid: Uuid,
+ rig_id: String,
+ bookmark_id: String,
+ freq_hz: u64,
+ mode: String,
+ bandwidth_hz: u32,
+ decoder_kinds: Vec,
+}
+
+#[derive(Default)]
+struct BackgroundRuntimeState {
+ current_rig_id: Option,
+ active_channels: HashMap,
+}
+
+pub struct BackgroundDecodeStore {
+ db: Arc>,
+}
+
+impl BackgroundDecodeStore {
+ pub fn open(path: &Path) -> Self {
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let db = if path.exists() {
+ PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
+ .unwrap_or_else(|_| {
+ PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
+ })
+ } else {
+ PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
+ };
+ Self {
+ db: Arc::new(RwLock::new(db)),
+ }
+ }
+
+ pub fn default_path() -> PathBuf {
+ dirs::config_dir()
+ .map(|p| p.join("trx-rs").join("background_decode.db"))
+ .unwrap_or_else(|| PathBuf::from("background_decode.db"))
+ }
+
+ pub fn get(&self, rig_id: &str) -> Option {
+ let db = self.db.read().unwrap_or_else(|e| e.into_inner());
+ db.get::(&format!("bgd:{rig_id}"))
+ }
+
+ pub fn upsert(&self, config: &BackgroundDecodeConfig) -> bool {
+ let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
+ db.set(&format!("bgd:{}", config.rig_id), config).is_ok()
+ }
+
+ pub fn remove(&self, rig_id: &str) -> bool {
+ let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
+ db.rem(&format!("bgd:{rig_id}")).unwrap_or(false)
+ }
+}
+
+pub struct BackgroundDecodeManager {
+ store: Arc,
+ bookmarks: Arc,
+ context: Arc,
+ status: Arc>>,
+ notify_tx: broadcast::Sender<()>,
+}
+
+impl BackgroundDecodeManager {
+ pub fn new(
+ store: Arc,
+ bookmarks: Arc,
+ context: Arc,
+ ) -> Arc {
+ let (notify_tx, _) = broadcast::channel(16);
+ Arc::new(Self {
+ store,
+ bookmarks,
+ context,
+ status: Arc::new(RwLock::new(HashMap::new())),
+ notify_tx,
+ })
+ }
+
+ pub fn spawn(self: &Arc) {
+ let manager = self.clone();
+ tokio::spawn(async move {
+ manager.run().await;
+ });
+ }
+
+ pub fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
+ self.store.get(rig_id).unwrap_or_else(|| BackgroundDecodeConfig {
+ rig_id: rig_id.to_string(),
+ enabled: false,
+ bookmark_ids: Vec::new(),
+ })
+ }
+
+ pub fn put_config(&self, mut config: BackgroundDecodeConfig) -> Option {
+ config.bookmark_ids = dedup_ids(&config.bookmark_ids);
+ if self.store.upsert(&config) {
+ self.trigger();
+ Some(config)
+ } else {
+ None
+ }
+ }
+
+ pub fn reset_config(&self, rig_id: &str) -> bool {
+ let removed = self.store.remove(rig_id);
+ self.trigger();
+ removed
+ }
+
+ pub fn status(&self, rig_id: &str) -> BackgroundDecodeStatus {
+ if let Ok(status) = self.status.read() {
+ if let Some(entry) = status.get(rig_id) {
+ return entry.clone();
+ }
+ }
+ let cfg = self.get_config(rig_id);
+ let bookmarks: HashMap = self
+ .bookmarks
+ .list()
+ .into_iter()
+ .map(|bookmark| (bookmark.id.clone(), bookmark))
+ .collect();
+ BackgroundDecodeStatus {
+ rig_id: rig_id.to_string(),
+ enabled: cfg.enabled,
+ active_rig: self.active_rig_id().as_deref() == Some(rig_id),
+ center_hz: None,
+ sample_rate: None,
+ entries: cfg
+ .bookmark_ids
+ .into_iter()
+ .map(|bookmark_id| {
+ let bookmark = bookmarks.get(&bookmark_id);
+ BackgroundDecodeBookmarkStatus {
+ bookmark_id,
+ bookmark_name: bookmark.map(|item| item.name.clone()),
+ freq_hz: bookmark.map(|item| item.freq_hz),
+ mode: bookmark.map(|item| item.mode.clone()),
+ decoder_kinds: bookmark
+ .map(|item| supported_decoder_kinds(&item.decoders))
+ .unwrap_or_default(),
+ state: "inactive".to_string(),
+ channel_kind: None,
+ }
+ })
+ .collect(),
+ }
+ }
+
+ pub fn trigger(&self) {
+ let _ = self.notify_tx.send(());
+ }
+
+ fn active_rig_id(&self) -> Option {
+ self.context
+ .remote_active_rig_id
+ .lock()
+ .ok()
+ .and_then(|guard| guard.clone())
+ }
+
+ fn send_audio_cmd(&self, cmd: VChanAudioCmd) {
+ if let Ok(guard) = self.context.vchan_audio_cmd.lock() {
+ if let Some(tx) = guard.as_ref() {
+ let _ = tx.try_send(cmd);
+ }
+ }
+ }
+
+ fn remove_channel(&self, channel: &VirtualBackgroundDecodeChannel) {
+ self.send_audio_cmd(VChanAudioCmd::Remove(channel.uuid));
+ }
+
+ fn clear_runtime_channels(&self, runtime: &mut BackgroundRuntimeState) {
+ let channels: Vec =
+ runtime.active_channels.drain().map(|(_, ch)| ch).collect();
+ for channel in channels {
+ self.remove_channel(&channel);
+ }
+ runtime.current_rig_id = None;
+ }
+
+ fn desired_channel(
+ &self,
+ rig_id: &str,
+ bookmark: &Bookmark,
+ decoder_kinds: Vec,
+ ) -> VirtualBackgroundDecodeChannel {
+ VirtualBackgroundDecodeChannel {
+ uuid: Uuid::new_v4(),
+ rig_id: rig_id.to_string(),
+ bookmark_id: bookmark.id.clone(),
+ freq_hz: bookmark.freq_hz,
+ mode: bookmark.mode.clone(),
+ bandwidth_hz: bookmark
+ .bandwidth_hz
+ .unwrap_or(0)
+ .min(u32::MAX as u64) as u32,
+ decoder_kinds,
+ }
+ }
+
+ fn channel_matches(
+ channel: &VirtualBackgroundDecodeChannel,
+ desired: &VirtualBackgroundDecodeChannel,
+ ) -> bool {
+ channel.rig_id == desired.rig_id
+ && channel.bookmark_id == desired.bookmark_id
+ && channel.freq_hz == desired.freq_hz
+ && channel.mode == desired.mode
+ && channel.bandwidth_hz == desired.bandwidth_hz
+ && channel.decoder_kinds == desired.decoder_kinds
+ }
+
+ fn reconcile(&self, runtime: &mut BackgroundRuntimeState, spectrum: &SharedSpectrum) {
+ let active_rig_id = self.active_rig_id();
+
+ if runtime.current_rig_id != active_rig_id {
+ if let Some(prev_rig_id) = runtime.current_rig_id.clone() {
+ if let Ok(mut guard) = self.status.write() {
+ if let Some(prev_status) = guard.get_mut(&prev_rig_id) {
+ prev_status.active_rig = false;
+ }
+ }
+ }
+ self.clear_runtime_channels(runtime);
+ }
+
+ let Some(rig_id) = active_rig_id else {
+ return;
+ };
+ runtime.current_rig_id = Some(rig_id.clone());
+
+ let config = self.get_config(&rig_id);
+ let selected = dedup_ids(&config.bookmark_ids);
+ let selected_bookmarks: HashMap = self
+ .bookmarks
+ .list()
+ .into_iter()
+ .filter(|bookmark| selected.iter().any(|id| id == &bookmark.id))
+ .map(|bookmark| (bookmark.id.clone(), bookmark))
+ .collect();
+
+ let frame = spectrum.frame.as_ref().map(Arc::as_ref);
+ let center_hz = frame.map(|frame| frame.center_hz);
+ let sample_rate = frame.map(|frame| frame.sample_rate);
+ let half_span_hz = frame.map(|frame| i64::from(frame.sample_rate) / 2);
+
+ let mut statuses = Vec::new();
+ let mut desired_channels = HashMap::new();
+
+ for bookmark_id in selected {
+ let Some(bookmark) = selected_bookmarks.get(&bookmark_id) else {
+ statuses.push(BackgroundDecodeBookmarkStatus {
+ bookmark_id,
+ state: "missing_bookmark".to_string(),
+ ..BackgroundDecodeBookmarkStatus::default()
+ });
+ continue;
+ };
+
+ let decoder_kinds = supported_decoder_kinds(&bookmark.decoders);
+ let mut status = BackgroundDecodeBookmarkStatus {
+ bookmark_id: bookmark.id.clone(),
+ bookmark_name: Some(bookmark.name.clone()),
+ freq_hz: Some(bookmark.freq_hz),
+ mode: Some(bookmark.mode.clone()),
+ decoder_kinds: decoder_kinds.clone(),
+ state: "disabled".to_string(),
+ channel_kind: None,
+ };
+
+ if decoder_kinds.is_empty() {
+ status.state = "no_supported_decoders".to_string();
+ statuses.push(status);
+ continue;
+ }
+
+ if !config.enabled {
+ statuses.push(status);
+ continue;
+ }
+
+ let (Some(center_hz), Some(half_span_hz)) = (center_hz, half_span_hz) else {
+ status.state = "waiting_for_spectrum".to_string();
+ statuses.push(status);
+ continue;
+ };
+
+ let offset_hz = bookmark.freq_hz as i64 - center_hz as i64;
+ if offset_hz.abs() > half_span_hz {
+ status.state = "out_of_span".to_string();
+ statuses.push(status);
+ continue;
+ }
+
+ status.state = "active".to_string();
+ status.channel_kind = Some(CHANNEL_KIND_NAME.to_string());
+ let desired = self.desired_channel(&rig_id, bookmark, decoder_kinds);
+ desired_channels.insert(bookmark.id.clone(), desired);
+ statuses.push(status);
+ }
+
+ let mut to_remove = Vec::new();
+ for (bookmark_id, channel) in &runtime.active_channels {
+ if let Some(desired) = desired_channels.get(bookmark_id) {
+ if !Self::channel_matches(channel, desired) {
+ to_remove.push(bookmark_id.clone());
+ }
+ } else {
+ to_remove.push(bookmark_id.clone());
+ }
+ }
+ for bookmark_id in to_remove {
+ if let Some(channel) = runtime.active_channels.remove(&bookmark_id) {
+ self.remove_channel(&channel);
+ }
+ }
+
+ for (bookmark_id, desired) in desired_channels {
+ if runtime.active_channels.contains_key(&bookmark_id) {
+ continue;
+ }
+ self.send_audio_cmd(VChanAudioCmd::SubscribeBackground {
+ uuid: desired.uuid,
+ freq_hz: desired.freq_hz,
+ mode: desired.mode.clone(),
+ bandwidth_hz: desired.bandwidth_hz,
+ decoder_kinds: desired.decoder_kinds.clone(),
+ });
+ runtime.active_channels.insert(bookmark_id, desired);
+ }
+
+ if let Ok(mut guard) = self.status.write() {
+ guard.insert(
+ rig_id.clone(),
+ BackgroundDecodeStatus {
+ rig_id,
+ enabled: config.enabled,
+ active_rig: true,
+ center_hz,
+ sample_rate,
+ entries: statuses,
+ },
+ );
+ }
+ }
+
+ async fn run(self: Arc) {
+ let mut runtime = BackgroundRuntimeState::default();
+ let mut notify_rx = self.notify_tx.subscribe();
+ let mut spectrum_rx = self.context.spectrum.subscribe();
+ let mut interval = time::interval(Duration::from_secs(2));
+
+ loop {
+ self.reconcile(&mut runtime, &spectrum_rx.borrow().clone());
+ tokio::select! {
+ changed = spectrum_rx.changed() => {
+ if changed.is_err() {
+ warn!("background decode: spectrum watch closed");
+ self.clear_runtime_channels(&mut runtime);
+ break;
+ }
+ }
+ recv = notify_rx.recv() => {
+ match recv {
+ Ok(()) => {}
+ Err(broadcast::error::RecvError::Lagged(_)) => {}
+ Err(broadcast::error::RecvError::Closed) => break,
+ }
+ }
+ _ = interval.tick() => {}
+ }
+ }
+ }
+}
+
+fn dedup_ids(ids: &[String]) -> Vec {
+ let mut out = Vec::new();
+ for id in ids {
+ if !out.iter().any(|existing| existing == id) {
+ out.push(id.clone());
+ }
+ }
+ out
+}
+
+fn supported_decoder_kinds(decoders: &[String]) -> Vec {
+ let mut out = Vec::new();
+ for decoder in decoders {
+ let decoder = decoder.trim().to_ascii_lowercase();
+ if SUPPORTED_DECODER_KINDS.contains(&decoder.as_str())
+ && !out.iter().any(|existing| existing == &decoder)
+ {
+ out.push(decoder);
+ }
+ }
+ out
+}
+
+#[get("/background-decode/{rig_id}")]
+pub async fn get_background_decode(
+ path: web::Path,
+ manager: web::Data>,
+) -> impl Responder {
+ HttpResponse::Ok().json(manager.get_config(&path.into_inner()))
+}
+
+#[put("/background-decode/{rig_id}")]
+pub async fn put_background_decode(
+ path: web::Path,
+ body: web::Json,
+ manager: web::Data>,
+) -> impl Responder {
+ let rig_id = path.into_inner();
+ let mut config = body.into_inner();
+ config.rig_id = rig_id;
+ match manager.put_config(config) {
+ Some(saved) => HttpResponse::Ok().json(saved),
+ None => HttpResponse::InternalServerError().body("failed to save background decode config"),
+ }
+}
+
+#[delete("/background-decode/{rig_id}")]
+pub async fn delete_background_decode(
+ path: web::Path,
+ manager: web::Data>,
+) -> impl Responder {
+ let rig_id = path.into_inner();
+ manager.reset_config(&rig_id);
+ HttpResponse::Ok().json(BackgroundDecodeConfig {
+ rig_id,
+ enabled: false,
+ bookmark_ids: Vec::new(),
+ })
+}
+
+#[get("/background-decode/{rig_id}/status")]
+pub async fn get_background_decode_status(
+ path: web::Path,
+ manager: web::Data>,
+) -> impl Responder {
+ HttpResponse::Ok().json(manager.status(&path.into_inner()))
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
index b1e88de..542a46c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs
@@ -6,6 +6,8 @@
mod api;
#[path = "audio.rs"]
pub mod audio;
+#[path = "background_decode.rs"]
+pub mod background_decode;
#[path = "auth.rs"]
pub mod auth;
#[path = "bookmarks.rs"]
@@ -37,6 +39,7 @@ use trx_core::RigState;
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
use auth::{AuthConfig, AuthState, SameSite};
+use background_decode::{BackgroundDecodeManager, BackgroundDecodeStore};
use scheduler::{SchedulerStatusMap, SchedulerStore};
use vchan::ClientChannelManager;
@@ -71,17 +74,27 @@ async fn serve(
let scheduler_path = SchedulerStore::default_path();
let scheduler_store = Arc::new(SchedulerStore::open(&scheduler_path));
let bookmark_path = bookmarks::BookmarkStore::default_path();
- let bookmark_store_for_scheduler = Arc::new(bookmarks::BookmarkStore::open(&bookmark_path));
+ let bookmark_store = Arc::new(bookmarks::BookmarkStore::open(&bookmark_path));
let scheduler_status: SchedulerStatusMap = Arc::new(RwLock::new(HashMap::new()));
scheduler::spawn_scheduler_task(
context.clone(),
rig_tx.clone(),
scheduler_store.clone(),
- bookmark_store_for_scheduler,
+ bookmark_store.clone(),
scheduler_status.clone(),
);
+ let background_decode_path = BackgroundDecodeStore::default_path();
+ let background_decode_store =
+ Arc::new(BackgroundDecodeStore::open(&background_decode_path));
+ let background_decode_mgr = BackgroundDecodeManager::new(
+ background_decode_store,
+ bookmark_store.clone(),
+ context.clone(),
+ );
+ background_decode_mgr.spawn();
+
let vchan_mgr = Arc::new(ClientChannelManager::new(4));
// Wire the audio-command sender so allocate/delete/freq/mode operations on
@@ -110,7 +123,18 @@ async fn serve(
});
}
- let server = build_server(addr, state_rx, rig_tx, callsign, context, scheduler_store, scheduler_status, vchan_mgr)?;
+ let server = build_server(
+ addr,
+ state_rx,
+ rig_tx,
+ callsign,
+ context,
+ bookmark_store,
+ scheduler_store,
+ scheduler_status,
+ vchan_mgr,
+ background_decode_mgr,
+ )?;
let handle = server.handle();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
@@ -129,9 +153,11 @@ fn build_server(
rig_tx: mpsc::Sender,
_callsign: Option,
context: Arc,
+ bookmark_store: Arc,
scheduler_store: Arc,
scheduler_status: SchedulerStatusMap,
vchan_mgr: Arc,
+ background_decode_mgr: Arc,
) -> Result {
let state_data = web::Data::new(state_rx);
let rig_tx = web::Data::new(rig_tx);
@@ -139,12 +165,12 @@ fn build_server(
// scheduler task can observe the connected-client count.
let clients = web::Data::new(context.sse_clients.clone());
- let bookmark_path = bookmarks::BookmarkStore::default_path();
- let bookmark_store = web::Data::new(Arc::new(bookmarks::BookmarkStore::open(&bookmark_path)));
+ let bookmark_store = web::Data::new(bookmark_store);
let scheduler_store = web::Data::new(scheduler_store);
let scheduler_status = web::Data::new(scheduler_status);
let vchan_mgr = web::Data::new(vchan_mgr);
+ let background_decode_mgr = web::Data::new(background_decode_mgr);
// Extract auth config values before moving context
let same_site = match context.http_auth_cookie_same_site.as_str() {
@@ -188,6 +214,7 @@ fn build_server(
.app_data(scheduler_store.clone())
.app_data(scheduler_status.clone())
.app_data(vchan_mgr.clone())
+ .app_data(background_decode_mgr.clone())
.wrap(Compress::default())
.wrap(
DefaultHeaders::new()
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
index d2e9e5c..af98b42 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
@@ -21,6 +21,8 @@ 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 BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
+pub const BACKGROUND_DECODE_JS: &str =
+ include_str!("../assets/web/plugins/background-decode.js");
pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
pub fn index_html() -> String {