From df7483fe30260f6b0a15ade2dce94ef687313e7a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 16 Aug 2026 00:26:45 +0200 Subject: [PATCH] [feat](trx-rs): configurable DIG sideband for SDR (auto/USB/LSB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIG has no inherent sideband; on the SDR backend it was always demodulated as USB. Make it resolve to USB or LSB via a policy that defaults to the amateur SSB/data convention (USB at/above 10 MHz, LSB below) and can be overridden globally from the advanced radio controls or per-bookmark. Design: the logical DIG mode is kept in RigState (display, decoder gating) while the SDR pipeline is handed a concrete USB/LSB demodulator resolved from (policy, dial frequency). Resolution happens at the boundary — the rig for the primary channel and the virtual-channel manager for vchans — so the hot DSP/demod path is untouched. The resolved sideband is only re-pushed when it actually changes (e.g. tuning DIG/Auto across 10 MHz), keeping ordinary tuning glitch-free. Core/protocol: - New `DigSidebandPolicy { Auto, Usb, Lsb }` with `resolve(freq)` and an `effective_demod_mode()` helper (trx-core), re-exported at the crate root. - `RigCommand::SetSdrDigSideband`, `RigSdr::set_sdr_dig_sideband`, and a `RigFilterState.sdr_dig_sideband` field for state sync; wired through the ClientCommand mapping. Config: `[rig.sdr] dig_sideband = "auto"` (regenerated trx-rs.toml.example). SDR backend: the vchan manager owns the shared policy (atomic); the rig applies it to the primary channel and, on `set_sdr_dig_sideband`, re-resolves all DIG virtual channels. Frontend: a mode-gated "DIG sideband" selector in the SDR advanced controls (POST /set_sdr_dig_sideband), reflecting server state; bookmarks gain an optional `dig_sideband` field (form selector shown only for DIG) that, on apply, sets the global policy before switching to DIG. The scheduler honours it for automated bookmark activation too. Tests: policy resolution / effective-mode / u8+parse round-trips (trx-core); a vchan integration test asserting a DIG channel resolves to LSB below 10 MHz, flips with the policy, and still lists as DIG. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 25 +++ .../assets/web/generated/bookmarks.js | 19 ++- .../trx-frontend-http/assets/web/index.html | 16 ++ .../examples/generate_typescript.rs | 5 +- .../frontend/src/api/generated.ts | 9 +- .../trx-frontend-http/frontend/src/app.ts | 26 ++++ .../frontend/src/plugins/bookmarks.ts | 29 ++++ .../trx-frontend-http/src/api/bookmarks.rs | 14 ++ .../trx-frontend-http/src/api/mod.rs | 1 + .../trx-frontend-http/src/api/rig.rs | 19 ++- .../trx-frontend-http/src/bookmarks.rs | 4 + .../trx-frontend-http/src/scheduler.rs | 15 ++ src/trx-config/src/server.rs | 7 +- src/trx-core/src/lib.rs | 2 +- src/trx-core/src/rig/command.rs | 4 +- src/trx-core/src/rig/controller/handlers.rs | 1 + src/trx-core/src/rig/mod.rs | 11 ++ src/trx-core/src/rig/state.rs | 145 ++++++++++++++++++ src/trx-protocol/src/codec.rs | 2 + src/trx-protocol/src/mapping.rs | 1 + src/trx-protocol/src/types.rs | 5 +- src/trx-server/src/main.rs | 1 + src/trx-server/src/rig_task.rs | 12 ++ .../trx-backend-soapysdr/src/lib.rs | 79 +++++++++- .../trx-backend-soapysdr/src/vchan_impl.rs | 127 +++++++++++++-- trx-rs.toml.example | 1 + 26 files changed, 555 insertions(+), 25 deletions(-) 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