From 4da4d8ec664c6cb69bd85e8bf49477ef7637bb5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 08:17:34 +0000 Subject: [PATCH] [feat](trx-rs): add CCI/ACI bars to WFM panel with RDS mitigation Estimate Co-Channel Interference (CCI) from pilot tone quadrature leakage and coherence degradation. Estimate Adjacent Channel Interference (ACI) from CMA equalizer tap deviation from identity. Both metrics (0-100 scale) are surfaced through RigFilterState and displayed as colour-coded bars in the WFM control panel. The RDS decoder quality parameter is now adaptively penalised when CCI/ACI levels are elevated, reducing block-error rate under interference conditions. https://claude.ai/code/session_016EKzep42RCvE4GxvvRaCwu Signed-off-by: Claude --- .../trx-frontend-http/assets/web/app.js | 25 +++++++ .../trx-frontend-http/assets/web/index.html | 8 +++ .../trx-frontend-http/assets/web/style.css | 41 ++++++++++++ src/trx-core/src/rig/state.rs | 6 ++ src/trx-protocol/src/codec.rs | 6 ++ .../trx-backend-soapysdr/src/demod/wfm.rs | 65 ++++++++++++++++++- .../trx-backend-soapysdr/src/dsp/channel.rs | 14 ++++ .../trx-backend-soapysdr/src/lib.rs | 24 ++++--- 8 files changed, 180 insertions(+), 9 deletions(-) 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 7b8e0f6..cb1561f 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 @@ -1701,6 +1701,7 @@ function resetDecoderStateOnRigSwitch() { vchanRdsById = new Map(); resetRdsDisplay(); resetWfmStereoIndicator(); + resetIntfBars(); // Spectrum — clear stale data from previous rig's SDR lastSpectrumData = null; @@ -1728,6 +1729,23 @@ function resetWfmStereoIndicator() { wfmStFlagEl.classList.add("wfm-st-flag-mono"); } +function updateIntfBar(fillEl, valEl, level) { + if (!fillEl || !valEl) return; + const v = Math.round(Math.min(Math.max(level, 0), 100)); + valEl.textContent = String(v); + fillEl.style.width = v + "%"; + fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65); + fillEl.classList.toggle("wfm-intf-high", v >= 65); + if (v < 35) { + fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high"); + } +} + +function resetIntfBars() { + updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); + updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); +} + // ── Fast CSS-based frequency/BW marker positioning ────────────────────────── // These lightweight DOM elements reposition via `transform: translateX()` // which is GPU-composited — zero layout/paint cost. The full WebGL overlay @@ -1800,6 +1818,7 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) { primaryRds = null; resetRdsDisplay(); resetWfmStereoIndicator(); + resetIntfBars(); } lastFreqHz = hz; window.lastFreqHz = lastFreqHz; @@ -2955,6 +2974,8 @@ function render(update) { wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); } + if (typeof update.filter.wfm_cci === "number") updateIntfBar(wfmCciFillEl, wfmCciValEl, update.filter.wfm_cci); + if (typeof update.filter.wfm_aci === "number") updateIntfBar(wfmAciFillEl, wfmAciValEl, update.filter.wfm_aci); if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") { samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100)); } @@ -7494,6 +7515,10 @@ const sdrLnaGainEl = document.getElementById("sdr-lna-gain-db"); const sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set"); const sdrAgcEl = document.getElementById("sdr-agc-enabled"); const wfmStFlagEl = document.getElementById("wfm-st-flag"); +const wfmCciFillEl = document.getElementById("wfm-cci-fill"); +const wfmCciValEl = document.getElementById("wfm-cci-val"); +const wfmAciFillEl = document.getElementById("wfm-aci-fill"); +const wfmAciValEl = document.getElementById("wfm-aci-val"); const samControlsCol = document.getElementById("sam-controls-col"); const samStereoWidthEl = document.getElementById("sam-stereo-width"); const samCarrierSyncEl = document.getElementById("sam-carrier-sync"); 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 e5bad22..e610bff 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 @@ -250,6 +250,14 @@ Pilot MO + +
WFM
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 2c4e90e..74b51e6 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 @@ -265,6 +265,47 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; border-color: var(--border-light); background: color-mix(in srgb, var(--input-bg) 92%, var(--panel-2)); } +.wfm-intf-bar-wrap { + min-width: 3.6rem; +} +.wfm-intf-bar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-width: 3.6rem; + min-height: 2.1rem; + padding: 0; + box-sizing: border-box; + border: 1px solid var(--border-light); + border-radius: 6px; + overflow: hidden; + background: var(--input-bg); +} +.wfm-intf-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 0%; + border-radius: 5px 0 0 5px; + background: color-mix(in srgb, #4fc3f7 45%, transparent); + transition: width 0.25s ease, background 0.35s ease; +} +.wfm-intf-fill.wfm-intf-warn { + background: color-mix(in srgb, #ffa726 55%, transparent); +} +.wfm-intf-fill.wfm-intf-high { + background: color-mix(in srgb, #ef5350 55%, transparent); +} +.wfm-intf-val { + position: relative; + z-index: 1; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--text-primary); +} .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 63a2caa..ff08906 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -338,6 +338,12 @@ pub struct RigFilterState { pub wfm_stereo_detected: bool, #[serde(default = "default_wfm_denoise_level")] pub wfm_denoise: WfmDenoiseLevel, + /// Co-Channel Interference level (0–100 scale). + #[serde(default)] + pub wfm_cci: u8, + /// Adjacent Channel Interference level (0–100 scale). + #[serde(default)] + pub wfm_aci: u8, /// SAM stereo width (0.0 = mono, 1.0 = full stereo). #[serde(default = "default_sam_stereo_width")] pub sam_stereo_width: f32, diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index ac614e8..76d5a65 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -326,6 +326,8 @@ mod tests { wfm_stereo: true, wfm_stereo_detected: false, wfm_denoise: trx_core::WfmDenoiseLevel::Auto, + wfm_cci: 0, + wfm_aci: 0, sam_stereo_width: 1.0, sam_carrier_sync: true, }), @@ -372,6 +374,8 @@ mod tests { wfm_stereo: true, wfm_stereo_detected: true, wfm_denoise: trx_core::WfmDenoiseLevel::Auto, + wfm_cci: 12, + wfm_aci: 45, sam_stereo_width: 0.5, sam_carrier_sync: false, }), @@ -384,6 +388,8 @@ mod tests { assert_eq!(f.sdr_gain_db, Some(18.0)); assert_eq!(f.wfm_deemphasis_us, 50); assert!(f.wfm_stereo_detected); + assert_eq!(f.wfm_cci, 12); + assert_eq!(f.wfm_aci, 45); assert_eq!(f.sam_stereo_width, 0.5); assert!(!f.sam_carrier_sync); } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs index fd822d9..6873059 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs @@ -606,6 +606,10 @@ pub struct WfmStereoDecoder { prev_blend: f32, output_phase_inc: f64, output_phase: f64, + /// Smoothed CCI (Co-Channel Interference) estimate, 0–100 scale. + cci_level: f32, + /// Smoothed ACI (Adjacent Channel Interference) estimate, 0–100 scale. + aci_level: f32, } impl WfmStereoDecoder { @@ -674,6 +678,8 @@ impl WfmStereoDecoder { prev_blend: 0.0, output_phase_inc, output_phase: 0.0, + cci_level: 0.0, + aci_level: 0.0, } } @@ -686,6 +692,28 @@ impl WfmStereoDecoder { // The constant-modulus property of FM drives tap adaptation without a // training sequence, suppressing adjacent-channel interference. let equalized: Vec> = samples.iter().map(|&s| self.cma.process(s)).collect(); + + // ACI estimation: measure CMA tap deviation from identity. + // When adjacent-channel interference is present the equalizer drives its + // taps away from the centre-tap-only identity configuration. + { + let mut tap_dev = 0.0_f32; + for (k, &tap) in self.cma.taps.iter().enumerate() { + if k == CMA_N_TAPS / 2 { + // Centre tap: deviation from (1+0j). + tap_dev += (tap - Complex::new(1.0, 0.0)).norm_sqr(); + } else { + tap_dev += tap.norm_sqr(); + } + } + // Map deviation to 0–100 scale. Empirically, deviation > 0.5 is + // heavy ACI; scale linearly with a sqrt compressor for readability. + let raw_aci = (tap_dev.sqrt() * 100.0 / 0.7).clamp(0.0, 100.0); + // Smooth with ~200 ms time constant at block rate. + let alpha = 0.08_f32; + self.aci_level += alpha * (raw_aci - self.aci_level); + } + let disc = demod_fm_with_prev(&equalized, &mut self.prev_iq); let mut output = Vec::with_capacity( ((samples.len() as f64 * self.output_phase_inc).ceil() as usize + 1) @@ -745,6 +773,23 @@ impl WfmStereoDecoder { } else if self.stereo_detect_level > 0.6 { self.stereo_detected = true; } + // CCI estimation: pilot quadrature leakage indicates co-channel + // interference. A clean pilot has all energy in I; CCI adds + // incoherent 19 kHz energy that leaks into Q. + let q_abs = (self.pilot_q_lp.y).abs(); + let i_abs = (self.pilot_i_lp.y).abs(); + let cci_ratio = if i_abs > 1e-8 { + (q_abs / i_abs).clamp(0.0, 1.0) + } else { + 0.0 + }; + // Also factor in coherence drop: low coherence at moderate + // pilot amplitude implies multipath / co-channel. + let coherence_penalty = (1.0 - pilot_coherence).clamp(0.0, 1.0); + let raw_cci = ((cci_ratio * 0.6 + coherence_penalty * 0.4) * 100.0).clamp(0.0, 100.0); + let cci_alpha = 0.08_f32; + self.cci_level += cci_alpha * (raw_cci - self.cci_level); + self.detect_counter = 0; self.detect_pilot_mag_acc = 0.0; self.detect_pilot_abs_acc = 0.0; @@ -770,7 +815,13 @@ impl WfmStereoDecoder { self.rds_decoder.clear_pilot_ref(); } - let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0); + // Adaptive RDS quality: base metric from pilot strength, then + // penalise for CCI and ACI so the decoder weights bits lower when + // interference is present (reduces block-error rate). + let rds_base = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0); + let cci_penalty = 1.0 - (self.cci_level * 0.006).clamp(0.0, 0.45); + let aci_penalty = 1.0 - (self.aci_level * 0.004).clamp(0.0, 0.30); + let rds_quality = (rds_base * cci_penalty * aci_penalty).clamp(0.10, 1.0); let rds_clean = self.rds_dc.process(self.rds_bpf.process(x)); let _ = self.rds_decoder.process_sample(rds_clean, rds_quality); @@ -916,6 +967,8 @@ impl WfmStereoDecoder { self.denoise.reset(); self.prev_blend = 0.0; self.output_phase = 0.0; + self.cci_level = 0.0; + self.aci_level = 0.0; } pub fn set_denoise_level(&mut self, level: WfmDenoiseLevel) { @@ -925,6 +978,16 @@ impl WfmStereoDecoder { pub fn stereo_detected(&self) -> bool { self.stereo_detected } + + /// Current CCI (Co-Channel Interference) level, 0–100 scale. + pub fn cci_level(&self) -> u8 { + self.cci_level.round().clamp(0.0, 100.0) as u8 + } + + /// Current ACI (Adjacent Channel Interference) level, 0–100 scale. + pub fn aci_level(&self) -> u8 { + self.aci_level.round().clamp(0.0, 100.0) as u8 + } } #[cfg(test)] diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs index 1de40b1..3ae2a73 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs @@ -601,6 +601,20 @@ impl ChannelDsp { .unwrap_or(false) } + pub fn wfm_cci(&self) -> u8 { + self.wfm_decoder + .as_ref() + .map(WfmStereoDecoder::cci_level) + .unwrap_or(0) + } + + pub fn wfm_aci(&self) -> u8 { + self.wfm_decoder + .as_ref() + .map(WfmStereoDecoder::aci_level) + .unwrap_or(0) + } + 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 33d3368..0a184b3 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 @@ -895,14 +895,20 @@ impl RigSdr for SoapySdrRig { } fn filter_state(&self) -> Option { - let wfm_stereo_detected = self - .pipeline - .channel_dsps - .read() - .unwrap() - .get(self.primary_channel_idx) - .and_then(|dsp| dsp.lock().ok().map(|d| d.wfm_stereo_detected())) - .unwrap_or(false); + let (wfm_stereo_detected, wfm_cci, wfm_aci) = { + let dsps = self.pipeline.channel_dsps.read().unwrap(); + let dsp = dsps.get(self.primary_channel_idx); + let stereo = dsp + .and_then(|d| d.lock().ok().map(|d| d.wfm_stereo_detected())) + .unwrap_or(false); + let cci = dsp + .and_then(|d| d.lock().ok().map(|d| d.wfm_cci())) + .unwrap_or(0); + let aci = dsp + .and_then(|d| d.lock().ok().map(|d| d.wfm_aci())) + .unwrap_or(0); + (stereo, cci, aci) + }; Some(RigFilterState { bandwidth_hz: self.bandwidth_hz, cw_center_hz: 700, @@ -921,6 +927,8 @@ impl RigSdr for SoapySdrRig { wfm_stereo: self.wfm_stereo, wfm_stereo_detected, wfm_denoise: self.wfm_denoise, + wfm_cci, + wfm_aci, sam_stereo_width: self.sam_stereo_width, sam_carrier_sync: self.sam_carrier_sync, })