[fix](trx-backend-soapysdr): fix ACI/CCI always reading 0% in WFM
ACI: the hard limiter in channel.rs normalised IQ samples to unit magnitude *before* the CMA equalizer, making the signal perfectly constant-modulus so the CMA never adapted and tap deviation stayed at zero. Fix by moving the hard limiter inside process_iq (after the CMA) and replacing the CMA-based metric with IQ envelope coefficient of variation, computed on the raw samples. CCI: the pilot coherence has a theoretical maximum of π/4 ≈ 0.785 (not 1.0), so coherence_penalty was always ~0.215 even for a clean signal. The Q/I ratio also depended on the arbitrary NCO-pilot phase offset rather than actual interference. Fix by normalising coherence by its theoretical max and dropping the phase-dependent Q/I ratio. Gate CCI on pilot detection so mono signals read 0%. https://claude.ai/code/session_01PUXWNMRGfrWYH56k2DLmen Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -688,31 +688,40 @@ impl WfmStereoDecoder {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACI estimation: measure IQ envelope variance before hard-limiting.
|
||||||
|
// A clean FM signal has constant envelope (zero variance); ACI causes
|
||||||
|
// amplitude modulation that raises the coefficient of variation.
|
||||||
|
{
|
||||||
|
let n = samples.len() as f32;
|
||||||
|
let mut sum_mag = 0.0_f32;
|
||||||
|
let mut sum_mag_sq = 0.0_f32;
|
||||||
|
for s in samples.iter() {
|
||||||
|
let mag = s.norm();
|
||||||
|
sum_mag += mag;
|
||||||
|
sum_mag_sq += mag * mag;
|
||||||
|
}
|
||||||
|
let mean_mag = sum_mag / n;
|
||||||
|
let var = (sum_mag_sq / n - mean_mag * mean_mag).max(0.0);
|
||||||
|
let cv = if mean_mag > 1e-8 { var.sqrt() / mean_mag } else { 0.0 };
|
||||||
|
// Map CV to 0–100. Empirically, CV > 0.35 is heavy ACI.
|
||||||
|
let raw_aci = (cv * 100.0 / 0.35).clamp(0.0, 100.0);
|
||||||
|
let alpha = 0.08_f32;
|
||||||
|
self.aci_level += alpha * (raw_aci - self.aci_level);
|
||||||
|
}
|
||||||
|
|
||||||
// Tech 9: apply CMA blind equalizer to IQ samples before FM demodulation.
|
// Tech 9: apply CMA blind equalizer to IQ samples before FM demodulation.
|
||||||
// The constant-modulus property of FM drives tap adaptation without a
|
// The constant-modulus property of FM drives tap adaptation without a
|
||||||
// training sequence, suppressing adjacent-channel interference.
|
// training sequence, suppressing adjacent-channel interference.
|
||||||
let equalized: Vec<Complex<f32>> = samples.iter().map(|&s| self.cma.process(s)).collect();
|
let mut equalized: Vec<Complex<f32>> = samples.iter().map(|&s| self.cma.process(s)).collect();
|
||||||
|
|
||||||
// ACI estimation: measure CMA tap deviation from identity.
|
// Hard-limit to unit magnitude after CMA (preserves phase for FM demod
|
||||||
// When adjacent-channel interference is present the equalizer drives its
|
// while preventing clipping artefacts).
|
||||||
// taps away from the centre-tap-only identity configuration.
|
for s in equalized.iter_mut() {
|
||||||
{
|
let mag = s.norm();
|
||||||
let mut tap_dev = 0.0_f32;
|
if mag > 1.0 {
|
||||||
for (k, &tap) in self.cma.taps.iter().enumerate() {
|
*s /= mag;
|
||||||
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 disc = demod_fm_with_prev(&equalized, &mut self.prev_iq);
|
||||||
let mut output = Vec::with_capacity(
|
let mut output = Vec::with_capacity(
|
||||||
@@ -773,20 +782,20 @@ impl WfmStereoDecoder {
|
|||||||
} else if self.stereo_detect_level > 0.6 {
|
} else if self.stereo_detect_level > 0.6 {
|
||||||
self.stereo_detected = true;
|
self.stereo_detected = true;
|
||||||
}
|
}
|
||||||
// CCI estimation: pilot quadrature leakage indicates co-channel
|
// CCI estimation: a clean 19 kHz pilot has coherence ≈ π/4
|
||||||
// interference. A clean pilot has all energy in I; CCI adds
|
// (ratio of coherent magnitude to rectified absolute value).
|
||||||
// incoherent 19 kHz energy that leaks into Q.
|
// Co-channel interference degrades this coherence by adding
|
||||||
let q_abs = (self.pilot_q_lp.y).abs();
|
// incoherent energy at 19 kHz. Normalise by the theoretical
|
||||||
let i_abs = (self.pilot_i_lp.y).abs();
|
// maximum so a clean pilot reads 0 %. Only report CCI when
|
||||||
let cci_ratio = if i_abs > 1e-8 {
|
// the pilot is actually detected; mono signals have no pilot
|
||||||
(q_abs / i_abs).clamp(0.0, 1.0)
|
// and CCI is not meaningful.
|
||||||
|
let raw_cci = if self.pilot_lock_level > 0.1 {
|
||||||
|
const MAX_COHERENCE: f32 = std::f32::consts::FRAC_PI_4;
|
||||||
|
let norm = (pilot_coherence / MAX_COHERENCE).clamp(0.0, 1.0);
|
||||||
|
((1.0 - norm) * 100.0).clamp(0.0, 100.0)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
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;
|
let cci_alpha = 0.08_f32;
|
||||||
self.cci_level += cci_alpha * (raw_cci - self.cci_level);
|
self.cci_level += cci_alpha * (raw_cci - self.cci_level);
|
||||||
|
|
||||||
@@ -1404,4 +1413,165 @@ mod tests {
|
|||||||
assert!(l_rms > 0.01);
|
assert!(l_rms > 0.01);
|
||||||
assert!(separation_db > 15.0);
|
assert!(separation_db > 15.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: generate stereo FM IQ samples from a composite signal.
|
||||||
|
fn fm_modulate(composite: &[f32], peak: f32, deviation_hz: f32, fs: f32) -> Vec<Complex<f32>> {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
let mod_index = TAU * deviation_hz / (peak * fs);
|
||||||
|
let mut phase = 0.0_f32;
|
||||||
|
composite
|
||||||
|
.iter()
|
||||||
|
.map(|&c| {
|
||||||
|
phase += mod_index * c;
|
||||||
|
Complex::from_polar(1.0, phase)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: build a stereo FM composite signal (1 kHz audio in L only).
|
||||||
|
fn stereo_composite(fs: f32, n: usize) -> Vec<f32> {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
let audio_freq = 1000.0_f32;
|
||||||
|
let pilot_freq = 19_000.0_f32;
|
||||||
|
let carrier_freq = 38_000.0_f32;
|
||||||
|
let mut composite = vec![0.0_f32; n];
|
||||||
|
for (i, sample) in composite.iter_mut().enumerate() {
|
||||||
|
let t = i as f32 / fs;
|
||||||
|
let audio = (TAU * audio_freq * t).sin();
|
||||||
|
let pilot = 0.1 * (TAU * pilot_freq * t).cos();
|
||||||
|
let carrier = (TAU * carrier_freq * t).cos();
|
||||||
|
*sample = audio + pilot + audio * carrier;
|
||||||
|
}
|
||||||
|
composite
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_signal_aci_near_zero() {
|
||||||
|
let composite_rate: u32 = 240_000;
|
||||||
|
let audio_rate: u32 = 48_000;
|
||||||
|
let fs = composite_rate as f32;
|
||||||
|
let n = (fs * 0.5) as usize;
|
||||||
|
|
||||||
|
let composite = stereo_composite(fs, n);
|
||||||
|
let iq = fm_modulate(&composite, 2.1, 75_000.0, fs);
|
||||||
|
|
||||||
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
composite_rate, audio_rate, 2, true, 50, WfmDenoiseLevel::Auto,
|
||||||
|
);
|
||||||
|
let _ = decoder.process_iq(&iq);
|
||||||
|
|
||||||
|
// Clean constant-envelope FM should show near-zero ACI.
|
||||||
|
assert!(
|
||||||
|
decoder.aci_level() < 5,
|
||||||
|
"clean signal ACI should be near 0, got {}",
|
||||||
|
decoder.aci_level()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aci_nonzero_with_adjacent_channel() {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
let composite_rate: u32 = 240_000;
|
||||||
|
let audio_rate: u32 = 48_000;
|
||||||
|
let fs = composite_rate as f32;
|
||||||
|
let n = (fs * 1.0) as usize;
|
||||||
|
|
||||||
|
// Main stereo FM signal
|
||||||
|
let composite = stereo_composite(fs, n);
|
||||||
|
let mut iq = fm_modulate(&composite, 2.1, 75_000.0, fs);
|
||||||
|
|
||||||
|
// Add a strong adjacent-channel signal offset by 150 kHz.
|
||||||
|
// This creates amplitude modulation on the combined IQ envelope.
|
||||||
|
let adj_freq_offset = 150_000.0_f32;
|
||||||
|
let adj_composite: Vec<f32> = (0..n)
|
||||||
|
.map(|i| (TAU * 3_000.0 * i as f32 / fs).sin())
|
||||||
|
.collect();
|
||||||
|
let adj_mod_index = TAU * 75_000.0 / (1.1 * fs);
|
||||||
|
let mut adj_phase = 0.0_f32;
|
||||||
|
for (i, s) in iq.iter_mut().enumerate() {
|
||||||
|
let t = i as f32 / fs;
|
||||||
|
adj_phase += adj_mod_index * adj_composite[i];
|
||||||
|
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
|
||||||
|
*s = *s + adj;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
composite_rate, audio_rate, 2, true, 50, WfmDenoiseLevel::Auto,
|
||||||
|
);
|
||||||
|
let _ = decoder.process_iq(&iq);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
decoder.aci_level() > 5,
|
||||||
|
"adjacent-channel signal should raise ACI above 5 %, got {}",
|
||||||
|
decoder.aci_level()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_stereo_cci_near_zero() {
|
||||||
|
let composite_rate: u32 = 240_000;
|
||||||
|
let audio_rate: u32 = 48_000;
|
||||||
|
let fs = composite_rate as f32;
|
||||||
|
let n = (fs * 0.5) as usize;
|
||||||
|
|
||||||
|
let composite = stereo_composite(fs, n);
|
||||||
|
let iq = fm_modulate(&composite, 2.1, 75_000.0, fs);
|
||||||
|
|
||||||
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
composite_rate, audio_rate, 2, true, 50, WfmDenoiseLevel::Auto,
|
||||||
|
);
|
||||||
|
let _ = decoder.process_iq(&iq);
|
||||||
|
|
||||||
|
// A clean stereo signal should show CCI near zero.
|
||||||
|
assert!(
|
||||||
|
decoder.cci_level() < 10,
|
||||||
|
"clean stereo CCI should be near 0, got {}",
|
||||||
|
decoder.cci_level()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cci_nonzero_with_cochannel() {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
let composite_rate: u32 = 240_000;
|
||||||
|
let audio_rate: u32 = 48_000;
|
||||||
|
let fs = composite_rate as f32;
|
||||||
|
let n = (fs * 1.0) as usize;
|
||||||
|
|
||||||
|
// Main stereo FM signal.
|
||||||
|
let composite = stereo_composite(fs, n);
|
||||||
|
let mut iq = fm_modulate(&composite, 2.1, 75_000.0, fs);
|
||||||
|
|
||||||
|
// Add a co-channel interferer: another FM station at the SAME
|
||||||
|
// frequency but with a different pilot phase and different audio.
|
||||||
|
let mut intf_composite = vec![0.0_f32; n];
|
||||||
|
for (i, sample) in intf_composite.iter_mut().enumerate() {
|
||||||
|
let t = i as f32 / fs;
|
||||||
|
let audio = (TAU * 5_000.0 * t).sin();
|
||||||
|
// Pilot with a different starting phase.
|
||||||
|
let pilot = 0.1 * (TAU * 19_000.0 * t + 1.5).cos();
|
||||||
|
let carrier = (TAU * 38_000.0 * t + 3.0).cos();
|
||||||
|
*sample = audio + pilot + audio * carrier;
|
||||||
|
}
|
||||||
|
let intf_iq = fm_modulate(&intf_composite, 2.1, 75_000.0, fs);
|
||||||
|
|
||||||
|
// Mix at 70 % of the main signal's amplitude — strong enough to
|
||||||
|
// overcome the FM capture effect and visibly degrade pilot coherence.
|
||||||
|
for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) {
|
||||||
|
*s = *s + intf * 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
composite_rate, audio_rate, 2, true, 50, WfmDenoiseLevel::Auto,
|
||||||
|
);
|
||||||
|
let _ = decoder.process_iq(&iq);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
decoder.cci_level() > 2,
|
||||||
|
"co-channel interference should raise CCI above 2 %, got {}",
|
||||||
|
decoder.cci_level()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -740,15 +740,6 @@ impl ChannelDsp {
|
|||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
/ decimated.len() as f32;
|
/ decimated.len() as f32;
|
||||||
let signal_db = 10.0 * signal_power.max(1e-12).log10();
|
let signal_db = 10.0 * signal_power.max(1e-12).log10();
|
||||||
if self.wfm_decoder.is_some() {
|
|
||||||
for sample in decimated.iter_mut() {
|
|
||||||
let mag = (sample.re * sample.re + sample.im * sample.im).sqrt();
|
|
||||||
if mag > 1.0 {
|
|
||||||
*sample /= mag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const WFM_OUTPUT_GAIN: f32 = 0.50;
|
const WFM_OUTPUT_GAIN: f32 = 0.50;
|
||||||
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
|
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
|
||||||
let mut out = decoder.process_iq(decimated);
|
let mut out = decoder.process_iq(decimated);
|
||||||
|
|||||||
Reference in New Issue
Block a user