[fix](trx-backend-soapysdr): demodulate CW as USB (real part, not envelope)

CW signals in SDR are centred at an audio offset (e.g. 700 Hz) by the
upstream FIR filter, so demodulating as USB (taking the real part) produces
the correct side-tone.  The previous magnitude/envelope approach produced a
DC pulse per key press with no audible tone.

Re-enable the DC blocker for CW/CWR (r = 0.9999): the output is now audio
that can carry a DC offset from BFO frequency error, identical to USB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 16:25:53 +01:00
parent eed5673f95
commit 1f9c09668d
2 changed files with 17 additions and 22 deletions
@@ -577,17 +577,14 @@ fn demod_fm(samples: &[Complex<f32>]) -> Vec<f32> {
// CW
// ---------------------------------------------------------------------------
/// CW envelope detector: magnitude of IQ.
/// CW demodulator: take the real part of each baseband IQ sample.
///
/// Returns the raw envelope amplitude. Level normalisation is handled
/// downstream by the per-channel AGC. No DC blocker is applied to CW because
/// the envelope is always non-negative; high-pass filtering would create
/// negative-going artifacts on each key release.
/// The upstream FIR filter centres the CW carrier at the configured audio
/// offset (e.g. 700 Hz), so demodulating identically to USB produces the
/// characteristic CW side-tone. Level normalisation is handled downstream
/// by the per-channel AGC.
fn demod_cw(samples: &[Complex<f32>]) -> Vec<f32> {
samples
.iter()
.map(|s| (s.re * s.re + s.im * s.im).sqrt())
.collect()
samples.iter().map(|s| s.re).collect()
}
// ---------------------------------------------------------------------------
@@ -705,18 +702,17 @@ mod tests {
}
}
// Test 7: CW envelope detector returns raw magnitudes.
// Normalisation is handled downstream by the AGC.
// Test 7: CW demodulator returns the real part (same as USB).
#[test]
fn test_cw_raw_magnitude() {
fn test_cw_takes_real_part() {
let input = vec![
Complex::new(3.0_f32, 4.0), // magnitude 5.0
Complex::new(0.0, 0.0), // magnitude 0.0
Complex::new(1.0, 0.0), // magnitude 1.0
Complex::new(3.0_f32, 4.0),
Complex::new(0.0, 0.0),
Complex::new(1.0, 0.0),
];
let out = Demodulator::Cw.demodulate(&input);
assert_eq!(out.len(), 3);
assert_approx_eq(out[0], 5.0, 1e-6, "CW sample 0");
assert_approx_eq(out[0], 3.0, 1e-6, "CW sample 0");
assert_approx_eq(out[1], 0.0, 1e-6, "CW sample 1");
assert_approx_eq(out[2], 1.0, 1e-6, "CW sample 2");
}
@@ -269,14 +269,13 @@ fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
/// Build the DC blocker for a given mode, or `None` if not applicable.
///
/// CW and WFM are excluded: CW envelope is always non-negative (DC blocking
/// would create negative artifacts on key releases), and WFM has its own
/// internal DC blockers on each output channel.
/// AM uses a slightly faster blocker (r = 0.999, corner ≈ 7.6 Hz @ 48 kHz)
/// so it can track slow carrier-amplitude fading.
/// WFM is excluded because it has its own internal DC blockers on each output
/// channel. AM uses a slightly faster blocker (r = 0.999, corner ≈ 7.6 Hz
/// @ 48 kHz) so it can track slow carrier-amplitude fading. All other modes
/// (including CW, which now demodulates as USB) use r = 0.9999 (≈ 0.76 Hz).
fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> {
match mode {
RigMode::CW | RigMode::CWR | RigMode::WFM => None,
RigMode::WFM => None,
RigMode::AM => Some(DcBlocker::new(0.999)),
_ => Some(DcBlocker::new(0.9999)),
}