[feat](trx-backend-soapysdr): improve AM coherent demodulation

Replace the raw AM envelope detector with a limiter-derived coherent detector and update backend tests for the current channel DSP constructor.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-03 01:48:22 +01:00
parent 081d2d062d
commit 298b7247f8
3 changed files with 57 additions and 6 deletions
@@ -135,7 +135,7 @@ pub enum Demodulator {
Usb,
/// Lower sideband SSB: negate imaginary part before taking real part.
Lsb,
/// AM envelope detector: magnitude of IQ, DC-removed.
/// AM coherent detector using a limiter-derived carrier reference.
Am,
/// Narrow-band FM: instantaneous frequency via quadrature.
Fm,
@@ -4,12 +4,42 @@
use num_complex::Complex;
/// AM envelope detector: magnitude of IQ.
/// AM demodulator using a limiter-derived coherent reference.
///
/// This is a practical DSP analogue of the patent's split/limit/mix approach:
/// derive a fixed-amplitude carrier reference from the incoming IQ, smooth that
/// reference to follow carrier phase/frequency, then mix the original branch by
/// the conjugate reference and take the in-phase projection.
pub(super) fn demod_am(samples: &[Complex<f32>]) -> Vec<f32> {
samples
.iter()
.map(|sample| (sample.re * sample.re + sample.im * sample.im).sqrt())
.collect()
const EPSILON: f32 = 1.0e-12;
const REF_BLEND: f32 = 0.08;
let mut out = Vec::with_capacity(samples.len());
let mut carrier_ref = Complex::new(1.0_f32, 0.0);
for &sample in samples {
let mag_sq = sample.re * sample.re + sample.im * sample.im;
if mag_sq <= EPSILON {
out.push(0.0);
continue;
}
let mag = mag_sq.sqrt();
let limited = sample / mag;
let blended = carrier_ref * (1.0 - REF_BLEND) + limited * REF_BLEND;
let blended_mag_sq = blended.re * blended.re + blended.im * blended.im;
if blended_mag_sq > EPSILON {
carrier_ref = blended / blended_mag_sq.sqrt();
} else {
carrier_ref = limited;
}
// Project the original signal onto the limiter-derived carrier phase.
let mixed = sample * carrier_ref.conj();
out.push(mixed.re.max(0.0));
}
out
}
#[cfg(test)]
@@ -53,4 +83,21 @@ mod tests {
assert_approx_eq(got, exp, 1e-6, &format!("AM sample {idx}"));
}
}
#[test]
fn test_am_tracks_rotating_carrier_reference() {
let input: Vec<Complex<f32>> = (0..16)
.map(|idx| {
let angle = idx as f32 * 0.05;
Complex::new(angle.cos(), angle.sin())
})
.collect();
let out = demod_am(&input);
assert_eq!(out.len(), input.len());
let avg = out.iter().skip(1).copied().sum::<f32>() / (out.len().saturating_sub(1) as f32);
assert!(
avg > 0.95,
"AM rotating carrier: expected strong average coherent output, got {avg}"
);
}
}
@@ -478,6 +478,7 @@ mod tests {
#[test]
fn channel_dsp_processes_silence() {
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(8);
let mut dsp = ChannelDsp::new(
0.0,
&RigMode::USB,
@@ -490,6 +491,7 @@ mod tests {
true,
31,
pcm_tx,
iq_tx,
);
let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096];
dsp.process_block(&block);
@@ -498,6 +500,7 @@ mod tests {
#[test]
fn channel_dsp_set_mode() {
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
let (iq_tx, _) = broadcast::channel::<Vec<Complex<f32>>>(8);
let mut dsp = ChannelDsp::new(
0.0,
&RigMode::USB,
@@ -510,6 +513,7 @@ mod tests {
true,
31,
pcm_tx,
iq_tx,
);
assert_eq!(dsp.demodulator, Demodulator::Usb);
dsp.set_mode(&RigMode::FM);