diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 615a535..bbc8b3c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1265,6 +1265,12 @@ function render(update) { wfmDenoiseBtn.style.borderColor = on ? "" : "var(--accent-warn, #f0a500)"; wfmDenoiseBtn.style.color = on ? "" : "var(--accent-warn, #f0a500)"; } + if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") { + const detected = update.filter.wfm_stereo_detected; + wfmStFlagEl.textContent = detected ? "ST" : "MO"; + wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); + wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); + } } if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { applyLocalTunedFrequency(update.status.freq.hz, true); @@ -2559,6 +2565,7 @@ const audioRow = document.getElementById("audio-row"); const wfmControlsCol = document.getElementById("wfm-controls-col"); const wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); const wfmAudioModeEl = document.getElementById("wfm-audio-mode"); +const wfmStFlagEl = document.getElementById("wfm-st-flag"); const wfmDenoiseBtn = document.getElementById("wfm-denoise-btn"); // Hide audio row if audio is not configured on the server 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 192c30b..0e872e8 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 @@ -165,6 +165,9 @@ + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 7163955..1e93ba0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -192,6 +192,34 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; line-height: 1.2; box-sizing: border-box; } +.wfm-st-flag-wrap { + min-width: 0; +} +.wfm-st-flag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.35rem; + padding: 0.45rem 0.5rem; + box-sizing: border-box; + border: 1px solid var(--border-light); + border-radius: 6px; + font-size: 0.9rem; + font-weight: 800; + line-height: 1.2; + letter-spacing: 0.04em; + background: var(--input-bg); +} +.wfm-st-flag-stereo { + color: #ff5c5c; + border-color: color-mix(in srgb, #ff5c5c 65%, var(--border-light)); + background: color-mix(in srgb, #ff5c5c 14%, var(--input-bg)); +} +.wfm-st-flag-mono { + color: var(--text-muted); + border-color: var(--border-light); + background: color-mix(in srgb, var(--input-bg) 92%, var(--panel-2)); +} .controls-col-center::after { content: ""; display: block; diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index d2d4e6e..c392957 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -275,6 +275,8 @@ pub struct RigFilterState { pub wfm_stereo: bool, #[serde(default = "default_wfm_denoise")] pub wfm_denoise: bool, + #[serde(default)] + pub wfm_stereo_detected: bool, } fn default_wfm_deemphasis_us() -> u32 { diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index 4f79866..0eaeb83 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -300,6 +300,7 @@ mod tests { wfm_deemphasis_us: 75, wfm_stereo: true, wfm_denoise: true, + wfm_stereo_detected: false, }), ..minimal_snapshot() }) @@ -338,6 +339,7 @@ mod tests { wfm_deemphasis_us: 50, wfm_stereo: true, wfm_denoise: true, + wfm_stereo_detected: true, }), ..minimal_snapshot() }; @@ -347,6 +349,7 @@ mod tests { assert_eq!(f.bandwidth_hz, 12000); assert_eq!(f.fir_taps, 128); assert_eq!(f.wfm_deemphasis_us, 50); + assert!(f.wfm_stereo_detected); } fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot { diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index 2bfa366..aae5e15 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -663,6 +663,7 @@ async fn refresh_state_with_retry( /// Read current state from the rig via CAT. async fn refresh_state_from_cat(rig: &mut Box, state: &mut RigState) -> DynResult<()> { let (freq, mode, vfo) = rig.get_status().await?; + state.filter = rig.filter_state(); state.control.enabled = Some(true); state.apply_freq(freq); state.apply_mode(mode); diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs index fdb3c9f..a70a494 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs @@ -405,6 +405,10 @@ pub struct WfmStereoDecoder { diff_denoise: MultibandStereoBlend, /// Whether multiband stereo denoising is active. denoise_enabled: bool, + /// Smoothed pilot-derived stereo detection strength in [0, 1]. + stereo_detect_level: f32, + /// Hysteretic pilot-lock result used by the UI. + stereo_detected: bool, /// FM discriminator gain normalization. /// /// `demod_fm` outputs `atan2(…)/π ≈ 2·Δf/fs` for small deviations. @@ -471,6 +475,8 @@ impl WfmStereoDecoder { deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us), diff_denoise: MultibandStereoBlend::new(audio_rate.max(1) as f32), denoise_enabled, + stereo_detect_level: 0.0, + stereo_detected: false, fm_gain: composite_rate_f / (2.0 * 75_000.0), sum_hist: [0.0; 4], diff_hist: [0.0; 4], @@ -509,6 +515,19 @@ impl WfmStereoDecoder { let pilot_mag = (i * i + q * q).sqrt().max(pilot_tone.abs()); let stereo_blend = (pilot_mag * 40.0).clamp(0.0, 1.0); + let detect_coeff = if stereo_blend > self.stereo_detect_level { + 0.0008 + } else { + 0.0002 + }; + self.stereo_detect_level += detect_coeff * (stereo_blend - self.stereo_detect_level); + if self.stereo_detected { + if self.stereo_detect_level < 0.35 { + self.stereo_detected = false; + } + } else if self.stereo_detect_level > 0.6 { + self.stereo_detected = true; + } // --- RDS --- let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0); @@ -619,6 +638,10 @@ impl WfmStereoDecoder { pub fn reset_rds(&mut self) { self.rds_decoder.reset(); } + + pub fn stereo_detected(&self) -> bool { + self.stereo_detected + } } /// Selects the demodulation algorithm for a channel. diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index f5f96e1..a4e8a47 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -568,6 +568,13 @@ impl ChannelDsp { self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data) } + pub fn wfm_stereo_detected(&self) -> bool { + self.wfm_decoder + .as_ref() + .map(WfmStereoDecoder::stereo_detected) + .unwrap_or(false) + } + pub fn reset_rds(&mut self) { if let Some(decoder) = &mut self.wfm_decoder { decoder.reset_rds(); 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 37b239c..d6b786e 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 @@ -495,6 +495,12 @@ impl RigCat for SoapySdrRig { } fn filter_state(&self) -> Option { + let wfm_stereo_detected = self + .pipeline + .channel_dsps + .get(self.primary_channel_idx) + .and_then(|dsp| dsp.lock().ok().map(|d| d.wfm_stereo_detected())) + .unwrap_or(false); Some(RigFilterState { bandwidth_hz: self.bandwidth_hz, fir_taps: self.fir_taps, @@ -502,6 +508,7 @@ impl RigCat for SoapySdrRig { wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_stereo: self.wfm_stereo, wfm_denoise: self.wfm_denoise, + wfm_stereo_detected, }) }