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,
})
}