diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
index a6d143b5..2759354e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
@@ -4758,6 +4758,13 @@ function render(update) {
}
}
}
+ if (typeof update.filter.sdr_dig_sideband === "string") {
+ sdrDigSidebandSupported = true;
+ if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
+ sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
+ }
+ updateWfmControls();
+ }
}
if (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
@@ -6624,6 +6631,9 @@ var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-control
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
var sdrNbSupported = false;
+var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
+var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
+var sdrDigSidebandSupported = false;
fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none";
}).catch(() => {
@@ -7019,11 +7029,26 @@ if (sdrNbThresholdEl) {
}
});
}
+function submitSdrDigSideband() {
+ if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
+ const policy = sdrDigSidebandEl.value || "auto";
+ if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
+ postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
+ });
+}
+if (sdrDigSidebandEl) {
+ sdrDigSidebandEl.addEventListener("change", () => {
+ submitSdrDigSideband();
+ });
+}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
+ if (sdrDigSidebandWrapEl) {
+ sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
+ }
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
index c18690db..31293dc6 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
@@ -214,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label);
});
}
+function bmSyncDigSidebandVisibility() {
+ const label = bmOptionalEl("bm-dig-sideband-label");
+ if (!label) return;
+ const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
+ label.style.display = mode === "DIG" ? "" : "none";
+}
function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -227,7 +233,9 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
+ bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
+ bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
bmEl("bm-name").focus();
@@ -263,6 +271,7 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
+ const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -279,7 +288,8 @@ async function bmSave(e) {
locator: locator || null,
category,
comment,
- decoders
+ decoders,
+ dig_sideband
};
try {
let resp;
@@ -344,6 +354,12 @@ function bmApply(bm) {
}
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
+ if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
+ const p = bm.dig_sideband.toLowerCase();
+ if (p === "auto" || p === "usb" || p === "lsb") {
+ await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
+ }
+ }
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -596,6 +612,7 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event);
});
+ bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap");
if (formWrap) {
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 d47094ae..7caecf67 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
@@ -385,6 +385,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
@@ -721,6 +729,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
index 31ffbbf7..48b04a6c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
@@ -12,7 +12,9 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry,
};
-use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
+use trx_core::{
+ DecoderConfig, DigSidebandPolicy, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel,
+};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
@@ -49,6 +51,7 @@ fn main() -> Result<(), Box> {
export!(RigStatus);
export!(DecoderConfig);
export!(WfmDenoiseLevel);
+ export!(DigSidebandPolicy);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
index 13a84a2c..243a8bd5 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
@@ -55,7 +55,14 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
-export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
+export type DigSidebandPolicy = "auto" | "usb" | "lsb";
+
+export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
+/**
+ * Current DIG sideband policy (SDR backends only). Surfaces in the UI as
+ * the advanced-controls "DIG sideband" selector.
+ */
+sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/**
* Co-Channel Interference level (0–100 scale).
*/
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
index 768f2473..33ce2e05 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
@@ -3681,6 +3681,13 @@ function render(update: AppUpdate) {
}
}
}
+ if (typeof update.filter.sdr_dig_sideband === "string") {
+ sdrDigSidebandSupported = true;
+ if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
+ sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
+ }
+ updateWfmControls();
+ }
}
if (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
@@ -5611,6 +5618,9 @@ const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-contr
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
let sdrNbSupported = false;
+const sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
+const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
+let sdrDigSidebandSupported = false;
// Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => {
@@ -6062,6 +6072,17 @@ if (sdrNbThresholdEl) {
}
});
}
+function submitSdrDigSideband() {
+ if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
+ const policy = sdrDigSidebandEl.value || "auto";
+ if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
+ postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
+}
+if (sdrDigSidebandEl) {
+ sdrDigSidebandEl.addEventListener("change", () => {
+ submitSdrDigSideband();
+ });
+}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
@@ -6069,6 +6090,11 @@ function updateWfmControls() {
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
+ // DIG sideband selector is only meaningful in DIG mode on an SDR backend.
+ if (sdrDigSidebandWrapEl) {
+ sdrDigSidebandWrapEl.style.display =
+ sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
+ }
}
// Show compatibility warning for non-Chromium browsers
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
index 5c990b00..8b0af42d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
@@ -19,6 +19,8 @@ interface Bookmark {
category?: string | null;
comment?: string | null;
decoders?: string[];
+ /** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
+ dig_sideband?: string | null;
scope?: string;
}
@@ -321,6 +323,14 @@ function bmBuildDecoderCheckboxes() {
});
}
+/** Show the DIG-sideband override selector only when the mode field is DIG. */
+function bmSyncDigSidebandVisibility(): void {
+ const label = bmOptionalEl("bm-dig-sideband-label");
+ if (!label) return;
+ const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
+ label.style.display = mode === "DIG" ? "" : "none";
+}
+
function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -338,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
+ bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []);
+ bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
@@ -383,6 +395,9 @@ async function bmSave(e: Event): Promise {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
+ // Only meaningful for DIG; stored empty otherwise so it never overrides.
+ const dig_sideband =
+ mode.toUpperCase() === "DIG" ? (bmEl("bm-dig-sideband").value || null) : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
@@ -403,6 +418,7 @@ async function bmSave(e: Event): Promise {
category,
comment,
decoders,
+ dig_sideband,
};
try {
@@ -477,6 +493,16 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
+ // For a DIG bookmark carrying a sideband override, set the global DIG
+ // policy first so the backend resolves the intended sideband when DIG is
+ // applied. "auto" is honoured explicitly; an empty override is left alone.
+ if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
+ const p = bm.dig_sideband.toLowerCase();
+ if (p === "auto" || p === "usb" || p === "lsb") {
+ await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
+ }
+ }
+
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -770,6 +796,9 @@ function bmPopulateScopePicker() {
// Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
+ // Reveal the DIG-sideband override only while the mode field reads DIG.
+ bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
+
// Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs
index e23ee60a..c8f1a5c3 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
pub comment: Option,
pub category: Option,
pub decoders: Option>,
+ pub dig_sideband: Option,
}
/// A bookmark with its owning scope tag for the list response.
@@ -88,6 +89,17 @@ fn normalize_bookmark_locator(locator: Option) -> Option {
})
}
+/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
+/// absent or unrecognized (leaves the current policy untouched on apply).
+fn normalize_dig_sideband(value: Option) -> Option {
+ value.and_then(|v| match v.trim().to_lowercase().as_str() {
+ "auto" => Some("auto".to_string()),
+ "usb" => Some("usb".to_string()),
+ "lsb" => Some("lsb".to_string()),
+ _ => None,
+ })
+}
+
// ============================================================================
// Endpoints
// ============================================================================
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
+ dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
@@ -219,6 +232,7 @@ pub async fn update_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
+ dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index 31a5270c..59cbaaa7 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -595,6 +595,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(rig::set_sdr_agc)
.service(rig::set_sdr_squelch)
.service(rig::set_sdr_noise_blanker)
+ .service(rig::set_sdr_dig_sideband)
.service(rig::set_wfm_deemphasis)
.service(rig::set_wfm_stereo)
.service(rig::set_wfm_denoise)
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
index e681ef2c..3c6e6504 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
@@ -14,7 +14,7 @@ use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
-use trx_core::{RigCommand, RigRequest, RigState};
+use trx_core::{DigSidebandPolicy, RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode;
@@ -299,6 +299,23 @@ pub async fn set_sdr_noise_blanker(
.await
}
+#[derive(serde::Deserialize)]
+pub struct SdrDigSidebandQuery {
+ /// `auto`, `usb`, or `lsb`.
+ pub policy: DigSidebandPolicy,
+ pub remote: Option,
+}
+
+/// Set how the SDR backend resolves DIG mode to a sideband.
+#[post("/set_sdr_dig_sideband")]
+pub async fn set_sdr_dig_sideband(
+ query: web::Query,
+ rig_tx: web::Data>,
+) -> Result {
+ let q = query.into_inner();
+ send_command(&rig_tx, RigCommand::SetSdrDigSideband(q.policy), q.remote).await
+}
+
// ============================================================================
// WFM / SAM settings
// ============================================================================
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs
index 09d4bffd..a01ba0fd 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs
@@ -21,6 +21,10 @@ pub struct Bookmark {
pub comment: String,
pub category: String,
pub decoders: Vec,
+ /// For `DIG` bookmarks, the sideband policy to apply on tune: `auto`,
+ /// `usb`, or `lsb`. `None` (or non-DIG) leaves the current policy alone.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub dig_sideband: Option,
}
pub struct BookmarkStore {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs
index 8eb32de3..7fb44684 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs
@@ -592,6 +592,21 @@ async fn apply_scheduler_target(
)
.await?;
+ // Apply any DIG sideband override before switching to DIG so the SDR
+ // backend resolves the intended sideband for this bookmark.
+ if let Some(policy) = bookmark
+ .dig_sideband
+ .as_deref()
+ .and_then(trx_core::DigSidebandPolicy::parse)
+ {
+ scheduler_send(
+ rig_tx,
+ RigCommand::SetSdrDigSideband(policy),
+ remote.to_string(),
+ )
+ .await?;
+ }
+
scheduler_send(
rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs
index 733a16a9..1b0a805f 100644
--- a/src/trx-config/src/server.rs
+++ b/src/trx-config/src/server.rs
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig;
-use trx_core::rig::state::RigMode;
+use trx_core::rig::state::{DigSidebandPolicy, RigMode};
/// Every decoder the server knows how to run, by config name.
///
@@ -393,6 +393,10 @@ pub struct SdrConfig {
pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig,
+ /// How DIG mode picks a sideband: `auto` (USB ≥ 10 MHz, LSB below),
+ /// `usb`, or `lsb`. Overridable at runtime and per-bookmark.
+ #[serde(default)]
+ pub dig_sideband: DigSidebandPolicy,
/// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec,
/// Maximum number of simultaneous virtual channels (including the primary).
@@ -433,6 +437,7 @@ impl Default for SdrConfig {
gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(),
+ dig_sideband: DigSidebandPolicy::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
diff --git a/src/trx-core/src/lib.rs b/src/trx-core/src/lib.rs
index acb7af34..8c28709b 100644
--- a/src/trx-core/src/lib.rs
+++ b/src/trx-core/src/lib.rs
@@ -17,6 +17,6 @@ pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
- WfmDenoiseLevel,
+ DigSidebandPolicy, WfmDenoiseLevel,
};
pub use rig::AudioSource;
diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs
index d36a5bfb..8b38d59c 100644
--- a/src/trx-core/src/rig/command.rs
+++ b/src/trx-core/src/rig/command.rs
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq;
-use crate::rig::state::WfmDenoiseLevel;
+use crate::rig::state::{DigSidebandPolicy, WfmDenoiseLevel};
use crate::RigMode;
/// Internal command handled by the rig task.
@@ -50,6 +50,8 @@ pub enum RigCommand {
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
+ /// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
+ SetSdrDigSideband(DigSidebandPolicy),
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs
index 0010b677..86b18460 100644
--- a/src/trx-core/src/rig/controller/handlers.rs
+++ b/src/trx-core/src/rig/controller/handlers.rs
@@ -470,6 +470,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box {
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. }
+ | RigCommand::SetSdrDigSideband(_)
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs
index a9fbd28b..51582fa6 100644
--- a/src/trx-core/src/rig/mod.rs
+++ b/src/trx-core/src/rig/mod.rs
@@ -260,6 +260,17 @@ pub trait RigSdr: Send {
)))
}
+ /// Set how DIG mode resolves to a sideband (SDR backends only).
+ fn set_sdr_dig_sideband<'a>(
+ &'a mut self,
+ _policy: crate::rig::state::DigSidebandPolicy,
+ ) -> Pin> + Send + 'a>> {
+ Box::pin(std::future::ready(Err(
+ Box::new(response::RigError::not_supported("set_sdr_dig_sideband"))
+ as Box,
+ )))
+ }
+
fn set_wfm_stereo<'a>(
&'a mut self,
_enabled: bool,
diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs
index 18d39e20..4f71eb00 100644
--- a/src/trx-core/src/rig/state.rs
+++ b/src/trx-core/src/rig/state.rs
@@ -338,6 +338,10 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option,
+ /// Current DIG sideband policy (SDR backends only). Surfaces in the UI as
+ /// the advanced-controls "DIG sideband" selector.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sdr_dig_sideband: Option,
#[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")]
@@ -370,6 +374,89 @@ pub enum WfmDenoiseLevel {
High,
}
+/// How the SDR backend resolves the [`RigMode::DIG`] mode to a concrete
+/// sideband when demodulating.
+///
+/// DIG (data / digital) has no inherent sideband; by ham-radio convention it
+/// follows the SSB convention for the band. `Auto` applies that convention
+/// (USB at/above 10 MHz, LSB below); `Usb`/`Lsb` force a fixed sideband. The
+/// setting is a global SDR control (see advanced radio controls) and may be
+/// overridden per-bookmark.
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[serde(rename_all = "lowercase")]
+pub enum DigSidebandPolicy {
+ /// Pick USB or LSB from the dial frequency (USB ≥ 10 MHz, LSB below).
+ #[default]
+ Auto,
+ /// Always demodulate DIG as upper sideband.
+ Usb,
+ /// Always demodulate DIG as lower sideband.
+ Lsb,
+}
+
+impl DigSidebandPolicy {
+ /// Frequency boundary used by [`DigSidebandPolicy::Auto`]: at or above this
+ /// dial frequency DIG is upper sideband, below it is lower sideband. This is
+ /// the standard amateur SSB/data convention.
+ pub const AUTO_THRESHOLD_HZ: u64 = 10_000_000;
+
+ /// Resolve this policy to a concrete sideband mode ([`RigMode::USB`] or
+ /// [`RigMode::LSB`]) for the given dial frequency.
+ pub fn resolve(self, freq_hz: u64) -> RigMode {
+ match self {
+ DigSidebandPolicy::Usb => RigMode::USB,
+ DigSidebandPolicy::Lsb => RigMode::LSB,
+ DigSidebandPolicy::Auto => {
+ if freq_hz >= Self::AUTO_THRESHOLD_HZ {
+ RigMode::USB
+ } else {
+ RigMode::LSB
+ }
+ }
+ }
+ }
+
+ /// Compact encoding for storage in an atomic (e.g. shared runtime state).
+ pub fn to_u8(self) -> u8 {
+ match self {
+ DigSidebandPolicy::Auto => 0,
+ DigSidebandPolicy::Usb => 1,
+ DigSidebandPolicy::Lsb => 2,
+ }
+ }
+
+ /// Inverse of [`DigSidebandPolicy::to_u8`]; unknown values decode to `Auto`.
+ pub fn from_u8(v: u8) -> Self {
+ match v {
+ 1 => DigSidebandPolicy::Usb,
+ 2 => DigSidebandPolicy::Lsb,
+ _ => DigSidebandPolicy::Auto,
+ }
+ }
+
+ /// Parse a case-insensitive `auto`/`usb`/`lsb` string; `None` if unknown.
+ pub fn parse(s: &str) -> Option {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "auto" => Some(DigSidebandPolicy::Auto),
+ "usb" => Some(DigSidebandPolicy::Usb),
+ "lsb" => Some(DigSidebandPolicy::Lsb),
+ _ => None,
+ }
+ }
+}
+
+/// Resolve a channel's effective demodulation mode. For [`RigMode::DIG`] this
+/// applies the sideband `policy` at the given dial frequency; every other mode
+/// is returned unchanged. Callers hand the result to the DSP while keeping the
+/// logical `DIG` mode for display and decoder gating.
+pub fn effective_demod_mode(logical: &RigMode, policy: DigSidebandPolicy, freq_hz: u64) -> RigMode {
+ if *logical == RigMode::DIG {
+ policy.resolve(freq_hz)
+ } else {
+ logical.clone()
+ }
+}
+
fn default_wfm_deemphasis_us() -> u32 {
75
}
@@ -506,3 +593,61 @@ pub struct RigSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option>,
}
+
+#[cfg(test)]
+mod dig_sideband_tests {
+ use super::{effective_demod_mode, DigSidebandPolicy, RigMode};
+
+ #[test]
+ fn auto_follows_ssb_band_convention() {
+ // Below 10 MHz → LSB; at/above 10 MHz → USB.
+ assert_eq!(DigSidebandPolicy::Auto.resolve(7_074_000), RigMode::LSB);
+ assert_eq!(DigSidebandPolicy::Auto.resolve(3_580_000), RigMode::LSB);
+ assert_eq!(DigSidebandPolicy::Auto.resolve(9_999_999), RigMode::LSB);
+ assert_eq!(DigSidebandPolicy::Auto.resolve(10_000_000), RigMode::USB);
+ assert_eq!(DigSidebandPolicy::Auto.resolve(14_074_000), RigMode::USB);
+ }
+
+ #[test]
+ fn forced_policies_ignore_frequency() {
+ assert_eq!(DigSidebandPolicy::Usb.resolve(3_580_000), RigMode::USB);
+ assert_eq!(DigSidebandPolicy::Lsb.resolve(14_074_000), RigMode::LSB);
+ }
+
+ #[test]
+ fn effective_mode_only_rewrites_dig() {
+ // Non-DIG modes pass through untouched regardless of policy/frequency.
+ assert_eq!(
+ effective_demod_mode(&RigMode::USB, DigSidebandPolicy::Lsb, 3_580_000),
+ RigMode::USB
+ );
+ assert_eq!(
+ effective_demod_mode(&RigMode::FM, DigSidebandPolicy::Usb, 3_580_000),
+ RigMode::FM
+ );
+ // DIG resolves by policy + frequency.
+ assert_eq!(
+ effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 7_074_000),
+ RigMode::LSB
+ );
+ assert_eq!(
+ effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 14_074_000),
+ RigMode::USB
+ );
+ }
+
+ #[test]
+ fn u8_roundtrips_and_parses() {
+ for p in [
+ DigSidebandPolicy::Auto,
+ DigSidebandPolicy::Usb,
+ DigSidebandPolicy::Lsb,
+ ] {
+ assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p);
+ }
+ assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto);
+ assert_eq!(DigSidebandPolicy::parse("USB"), Some(DigSidebandPolicy::Usb));
+ assert_eq!(DigSidebandPolicy::parse(" lsb "), Some(DigSidebandPolicy::Lsb));
+ assert_eq!(DigSidebandPolicy::parse("nonsense"), None);
+ }
+}
diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs
index a79d2a0c..d029b7ba 100644
--- a/src/trx-protocol/src/codec.rs
+++ b/src/trx-protocol/src/codec.rs
@@ -343,6 +343,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
+ sdr_dig_sideband: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
wfm_stereo_detected: false,
@@ -391,6 +392,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
+ sdr_dig_sideband: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
wfm_stereo_detected: true,
diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs
index 1ee71ae9..02999489 100644
--- a/src/trx-protocol/src/mapping.rs
+++ b/src/trx-protocol/src/mapping.rs
@@ -146,6 +146,7 @@ define_command_mapping! {
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc,
+ SetSdrDigSideband { policy } <=> SetSdrDigSideband,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise,
diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs
index 4c696a2e..5465024a 100644
--- a/src/trx-protocol/src/types.rs
+++ b/src/trx-protocol/src/types.rs
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot;
-use trx_core::WfmDenoiseLevel;
+use trx_core::{DigSidebandPolicy, WfmDenoiseLevel};
/// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)]
@@ -106,6 +106,9 @@ pub enum ClientCommand {
enabled: bool,
threshold: f64,
},
+ SetSdrDigSideband {
+ policy: DigSidebandPolicy,
+ },
SetWfmDeemphasis {
deemphasis_us: u32,
},
diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs
index 045c43e9..77cc425e 100644
--- a/src/trx-server/src/main.rs
+++ b/src/trx-server/src/main.rs
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
+ dig_sideband: rig_cfg.sdr.dig_sideband,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs
index b51ebd46..5bfb8690 100644
--- a/src/trx-server/src/rig_task.rs
+++ b/src/trx-server/src/rig_task.rs
@@ -774,6 +774,18 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
+ RigCommand::SetSdrDigSideband(policy) => {
+ if let Some(sdr) = ctx.rig.as_sdr() {
+ if let Err(e) = sdr.set_sdr_dig_sideband(policy).await {
+ return Err(RigError::communication(format!("set_sdr_dig_sideband: {e}")));
+ }
+ } else {
+ return Err(RigError::not_supported("set_sdr_dig_sideband"));
+ }
+ ctx.state.filter = ctx.rig.as_sdr_ref().and_then(|s| s.filter_state());
+ let _ = ctx.state_tx.send(ctx.state.clone());
+ return snapshot_from(ctx.state);
+ }
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs
index f7ff9104..c3fe2774 100644
--- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs
+++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs
@@ -13,7 +13,10 @@ use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError;
-use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
+use trx_core::rig::state::{
+ effective_demod_mode, DigSidebandPolicy, RigFilterState, SpectrumData, VchanRdsEntry,
+ WfmDenoiseLevel,
+};
use trx_core::rig::{
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
};
@@ -73,6 +76,8 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
+ /// How DIG mode resolves to a sideband (auto/usb/lsb).
+ pub dig_sideband: DigSidebandPolicy,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
@@ -104,6 +109,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
+ dig_sideband: DigSidebandPolicy::Auto,
spectrum_fft_size: 1024,
}
}
@@ -157,7 +163,12 @@ pub struct SoapySdrRig {
/// Hidden AIS decoder channels (A and B) when available.
ais_channel_indices: Option<(usize, usize)>,
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
+ /// Also owns the shared DIG-sideband policy.
channel_manager: Arc,
+ /// Concrete demodulation mode last pushed to the primary channel DSP. For
+ /// DIG this is the resolved USB/LSB; used to avoid rebuilding filters on
+ /// every tune step when the resolved sideband hasn't actually changed.
+ applied_primary_mode: RigMode,
}
impl SoapySdrRig {
@@ -200,6 +211,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
+ let dig_sideband = config.dig_sideband;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
@@ -364,8 +376,15 @@ impl SoapySdrRig {
pipeline.clone(),
fixed_slot_count,
max_virtual_channels,
+ dig_sideband,
));
+ // Concrete demod mode the primary channel starts in. For DIG this
+ // resolves the configured sideband policy against the initial dial
+ // frequency so the very first image/audio uses the right sideband.
+ let initial_primary_mode = effective_demod_mode(&initial_mode, dig_sideband, initial_freq.hz);
+ let initial_is_dig = initial_mode == RigMode::DIG;
+
let rig = Self {
info,
freq: initial_freq,
@@ -392,7 +411,17 @@ impl SoapySdrRig {
nb_threshold,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
channel_manager,
+ applied_primary_mode: initial_primary_mode.clone(),
};
+ // If the primary channel starts in DIG, its DSP was created with the
+ // logical DIG demodulator (upper sideband); push the resolved sideband
+ // so an Auto/LSB start is honoured immediately.
+ if initial_is_dig {
+ let dsps = rig.pipeline.channel_dsps.read().unwrap();
+ if let Some(dsp_arc) = dsps.get(rig.primary_channel_idx) {
+ dsp_arc.lock().unwrap().set_mode(&initial_primary_mode);
+ }
+ }
rig.apply_ais_channel_activity();
Ok(rig)
}
@@ -448,6 +477,7 @@ impl SoapySdrRig {
max_virtual_channels,
nb_enabled,
nb_threshold,
+ dig_sideband: DigSidebandPolicy::default(),
})
}
@@ -619,6 +649,21 @@ impl RigCat for SoapySdrRig {
}
}
}
+ // In DIG/Auto, tuning across the 10 MHz boundary flips the
+ // sideband. Rebuild the demodulator only when it actually changes,
+ // so ordinary tuning stays glitch-free.
+ if self.mode == RigMode::DIG {
+ let effective = self.channel_manager.dig_policy().resolve(self.freq.hz);
+ if effective != self.applied_primary_mode {
+ let dsps = self.pipeline.channel_dsps.read().unwrap();
+ if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
+ let mut dsp = dsp_arc.lock().unwrap();
+ dsp.set_mode(&effective);
+ dsp.set_filter(self.bandwidth_hz);
+ }
+ self.applied_primary_mode = effective;
+ }
+ }
self.update_ais_channel_offsets();
Ok(())
})
@@ -632,12 +677,17 @@ impl RigCat for SoapySdrRig {
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
self.mode = mode.clone();
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
+ // DIG carries no inherent sideband: resolve it to a concrete
+ // USB/LSB demodulator from the policy + dial frequency. The logical
+ // DIG mode is kept in `self.mode` (and RigState) for display.
+ let effective = effective_demod_mode(&self.mode, self.channel_manager.dig_policy(), self.freq.hz);
+ self.applied_primary_mode = effective.clone();
// Update the primary channel's demodulator in the live pipeline.
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
- dsp.set_mode(&mode);
+ dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
}
@@ -956,6 +1006,30 @@ impl RigSdr for SoapySdrRig {
})
}
+ fn set_sdr_dig_sideband<'a>(
+ &'a mut self,
+ policy: DigSidebandPolicy,
+ ) -> Pin> + Send + 'a>> {
+ Box::pin(async move {
+ // Update the shared policy (also re-resolves DIG virtual channels).
+ self.channel_manager.set_dig_policy(policy);
+ // Re-apply to the primary channel when it is currently DIG.
+ if self.mode == RigMode::DIG {
+ let effective = policy.resolve(self.freq.hz);
+ if effective != self.applied_primary_mode {
+ let dsps = self.pipeline.channel_dsps.read().unwrap();
+ if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
+ let mut dsp = dsp_arc.lock().unwrap();
+ dsp.set_mode(&effective);
+ dsp.set_filter(self.bandwidth_hz);
+ }
+ self.applied_primary_mode = effective;
+ }
+ }
+ Ok(())
+ })
+ }
+
fn set_wfm_stereo<'a>(
&'a mut self,
enabled: bool,
@@ -1049,6 +1123,7 @@ impl RigSdr for SoapySdrRig {
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
sdr_nb_enabled: Some(self.nb_enabled),
sdr_nb_threshold: Some(self.nb_threshold),
+ sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo,
wfm_stereo_detected,
diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs
index c052978b..b716a0fe 100644
--- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs
+++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs
@@ -22,12 +22,12 @@
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead
//! of destroying them.
-use std::sync::atomic::{AtomicI64, Ordering};
+use std::sync::atomic::{AtomicI64, AtomicU8, Ordering};
use std::sync::{Arc, RwLock};
use num_complex::Complex;
use tokio::sync::broadcast;
-use trx_core::rig::state::{RigMode, VchanRdsEntry};
+use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry};
use uuid::Uuid;
use crate::dsp::SdrPipeline;
@@ -59,7 +59,12 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
struct ManagedChannel {
id: Uuid,
freq_hz: u64,
+ /// Logical mode requested by the user (e.g. `DIG`), shown in listings.
mode: RigMode,
+ /// Concrete demod mode last pushed to the DSP. For `DIG` this is the
+ /// resolved USB/LSB; tracked so a frequency change only rebuilds the
+ /// demodulator when the resolved sideband actually flips.
+ applied_mode: RigMode,
/// `broadcast::Sender` kept alive so new subscribers can join at any time.
pcm_tx: broadcast::Sender>,
/// IQ tap sender (kept alive; external consumers may subscribe).
@@ -91,6 +96,9 @@ pub struct SdrVirtualChannelManager {
channels: RwLock>,
/// Fires whenever a channel is explicitly destroyed.
destroyed_tx: broadcast::Sender,
+ /// Shared DIG-sideband policy ([`DigSidebandPolicy`] encoded via `to_u8`).
+ /// Read when resolving any DIG channel's demodulator.
+ dig_sideband: AtomicU8,
}
impl SdrVirtualChannelManager {
@@ -100,7 +108,12 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4).
- pub fn new(pipeline: Arc, fixed_slot_count: usize, max_total: usize) -> Self {
+ pub fn new(
+ pipeline: Arc,
+ fixed_slot_count: usize,
+ max_total: usize,
+ dig_sideband: DigSidebandPolicy,
+ ) -> Self {
// Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline
@@ -118,6 +131,7 @@ impl SdrVirtualChannelManager {
id: Uuid::new_v4(),
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
mode: RigMode::USB,
+ applied_mode: RigMode::USB,
pcm_tx: primary_pcm_tx,
iq_tx: primary_iq_tx,
pipeline_slot: 0,
@@ -134,6 +148,7 @@ impl SdrVirtualChannelManager {
max_total: max_total.max(1),
channels: RwLock::new(vec![primary]),
destroyed_tx,
+ dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
}
}
@@ -141,6 +156,36 @@ impl SdrVirtualChannelManager {
self.destroyed_tx.clone()
}
+ /// Current shared DIG-sideband policy.
+ pub fn dig_policy(&self) -> DigSidebandPolicy {
+ DigSidebandPolicy::from_u8(self.dig_sideband.load(Ordering::Relaxed))
+ }
+
+ /// Update the shared DIG-sideband policy and re-resolve every DIG virtual
+ /// channel's demodulator (the primary channel is handled by `SoapySdrRig`).
+ pub fn set_dig_policy(&self, policy: DigSidebandPolicy) {
+ self.dig_sideband.store(policy.to_u8(), Ordering::Relaxed);
+ let mut channels = self.channels.write().unwrap();
+ let dsps = self.pipeline.channel_dsps.read().unwrap();
+ for ch in channels
+ .iter_mut()
+ .filter(|c| !c.permanent && c.mode == RigMode::DIG)
+ {
+ let effective = policy.resolve(ch.freq_hz);
+ if effective != ch.applied_mode {
+ ch.applied_mode = effective.clone();
+ if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
+ dsp_arc.lock().unwrap().set_mode(&effective);
+ }
+ }
+ }
+ }
+
+ /// Resolve a channel's effective demod mode under the current policy.
+ fn resolve_mode(&self, logical: &RigMode, freq_hz: u64) -> RigMode {
+ effective_demod_mode(logical, self.dig_policy(), freq_hz)
+ }
+
fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2
}
@@ -172,10 +217,13 @@ impl SdrVirtualChannelManager {
});
}
+ // Bandwidth follows the logical mode; the DSP demodulator follows the
+ // resolved sideband (DIG → USB/LSB by policy + frequency).
let bandwidth_hz = default_bandwidth_hz(mode);
- let (pcm_tx, iq_tx) = self
- .pipeline
- .add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
+ let applied_mode = self.resolve_mode(mode, freq_hz);
+ let (pcm_tx, iq_tx) =
+ self.pipeline
+ .add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
let pipeline_slot = self
.pipeline
@@ -190,6 +238,7 @@ impl SdrVirtualChannelManager {
id,
freq_hz,
mode: mode.clone(),
+ applied_mode,
pcm_tx,
iq_tx,
pipeline_slot,
@@ -314,9 +363,18 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.freq_hz = freq_hz;
+ // A DIG/Auto channel flips sideband across the 10 MHz boundary; rebuild
+ // the demodulator only when the resolved sideband actually changes.
+ let effective = self.resolve_mode(&ch.mode, freq_hz);
+ let mode_changed = effective != ch.applied_mode;
+ ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
- dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
+ let mut dsp = dsp_arc.lock().unwrap();
+ dsp.set_channel_if_hz(if_hz as f64);
+ if mode_changed {
+ dsp.set_mode(&effective);
+ }
}
Ok(())
}
@@ -329,9 +387,11 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.mode = mode.clone();
+ let effective = self.resolve_mode(mode, ch.freq_hz);
+ ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
- dsp_arc.lock().unwrap().set_mode(mode);
+ dsp_arc.lock().unwrap().set_mode(&effective);
}
Ok(())
}
@@ -425,6 +485,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::demod::Demodulator;
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
fn make_pipeline() -> Arc {
@@ -446,7 +507,7 @@ mod tests {
#[test]
fn add_and_list() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 4);
+ let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
mgr.update_center_hz(14_100_000);
assert_eq!(mgr.channels().len(), 1); // primary only
@@ -462,7 +523,7 @@ mod tests {
#[test]
fn remove_virtual_channel() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 4);
+ let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
mgr.remove_channel(id).unwrap();
@@ -472,7 +533,7 @@ mod tests {
#[test]
fn cannot_remove_primary() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 4);
+ let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
let primary_id = mgr.channels()[0].id;
let err = mgr.remove_channel(primary_id).unwrap_err();
assert!(matches!(err, VChanError::Permanent));
@@ -481,7 +542,7 @@ mod tests {
#[test]
fn cap_enforced() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
+ let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
@@ -491,7 +552,7 @@ mod tests {
#[test]
fn out_of_bandwidth() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 4);
+ let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
@@ -500,7 +561,7 @@ mod tests {
#[test]
fn hidden_background_channels_are_outside_visible_cap() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
+ let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
@@ -513,10 +574,46 @@ mod tests {
assert!(visible.iter().all(|channel| channel.id != hidden_id));
}
+ #[test]
+ fn dig_channel_resolves_sideband_and_tracks_policy() {
+ let p = make_pipeline();
+ let mgr = SdrVirtualChannelManager::new(p.clone(), 1, 6, DigSidebandPolicy::Auto);
+ mgr.update_center_hz(7_100_000);
+
+ // The virtual channel occupies pipeline slot 1 (slot 0 is the primary).
+ let demod_at_slot1 = || {
+ p.channel_dsps.read().unwrap()[1]
+ .lock()
+ .unwrap()
+ .demodulator
+ .clone()
+ };
+
+ // DIG below 10 MHz under Auto → lower sideband.
+ let (_id, _rx) = mgr.add_channel(7_074_000, &RigMode::DIG).unwrap();
+ assert_eq!(demod_at_slot1(), Demodulator::Lsb);
+
+ // Forcing USB re-resolves the live channel.
+ mgr.set_dig_policy(DigSidebandPolicy::Usb);
+ assert_eq!(demod_at_slot1(), Demodulator::Usb);
+
+ // Back to Auto (still below 10 MHz) → lower sideband again.
+ mgr.set_dig_policy(DigSidebandPolicy::Auto);
+ assert_eq!(demod_at_slot1(), Demodulator::Lsb);
+
+ // The listing keeps the logical DIG mode for display.
+ let ch = mgr
+ .channels()
+ .into_iter()
+ .find(|c| !c.permanent)
+ .expect("virtual channel");
+ assert_eq!(ch.mode, "DIG");
+ }
+
#[test]
fn retune_keeps_virtual_channel_allocated() {
let p = make_pipeline();
- let mgr = SdrVirtualChannelManager::new(p, 1, 4);
+ let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let mut destroyed_rx = mgr.subscribe_destroyed();
diff --git a/trx-rs.toml.example b/trx-rs.toml.example
index f23481d8..821b709e 100644
--- a/trx-rs.toml.example
+++ b/trx-rs.toml.example
@@ -118,6 +118,7 @@ sample_rate = 1920000
bandwidth = 1500000
wfm_deemphasis_us = 50
center_offset_hz = 100000
+dig_sideband = "auto"
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024