[feat](trx-rs): rename AMC (AM C-QUAM) to SAM (Stereo AM) with stereo width and carrier sync controls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-26 21:50:53 +01:00
parent 20a22622e7
commit 27489c3745
20 changed files with 427 additions and 166 deletions
@@ -2955,6 +2955,13 @@ function render(update) {
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
}
if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
}
if (samCarrierSyncEl && typeof update.filter.sam_carrier_sync === "boolean") {
const nextVal = update.filter.sam_carrier_sync ? "on" : "off";
if (samCarrierSyncEl.value !== nextVal) samCarrierSyncEl.value = nextVal;
}
const hasSdrSquelchEnabled = typeof update.filter.sdr_squelch_enabled === "boolean";
const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number";
if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) {
@@ -3915,7 +3922,7 @@ const MODE_BW_DEFAULTS = {
LSB: [2_700, 300, 6_000, 100],
USB: [2_700, 300, 6_000, 100],
AM: [9_000, 500, 20_000, 500],
"AMC-QUAM": [9_000, 500, 20_000, 500],
SAM: [9_000, 500, 20_000, 500],
FM: [12_500, 2_500, 25_000, 500],
AIS: [25_000, 12_500, 50_000, 500],
VDES: [100_000, 25_000, 200_000, 1_000],
@@ -7487,6 +7494,9 @@ 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 samControlsCol = document.getElementById("sam-controls-col");
const samStereoWidthEl = document.getElementById("sam-stereo-width");
const samCarrierSyncEl = document.getElementById("sam-carrier-sync");
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
const sdrSquelchEl = document.getElementById("sdr-squelch");
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
@@ -7681,6 +7691,18 @@ if (wfmDeemphasisEl) {
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
});
}
if (samStereoWidthEl) {
samStereoWidthEl.addEventListener("input", () => {
const width = Number(samStereoWidthEl.value) / 100;
postPath(`/set_sam_stereo_width?width=${width}`).catch(() => {});
});
}
if (samCarrierSyncEl) {
samCarrierSyncEl.addEventListener("change", () => {
const enabled = samCarrierSyncEl.value === "on";
postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => {});
});
}
function submitSdrGain() {
if (!sdrGainEl) return;
const parsed = Number.parseFloat(sdrGainEl.value);
@@ -7761,9 +7783,9 @@ if (sdrNbThresholdEl) {
});
}
function updateWfmControls() {
if (!wfmControlsCol) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
}
// Show compatibility warning for non-Chromium browsers
@@ -253,6 +253,22 @@
</div>
<div class="label"><span>WFM</span></div>
</div>
<div class="controls-col controls-col-sam label-below-col" id="sam-controls-col" style="display:none;">
<div class="inline sam-controls-inline">
<label class="wfm-control">
<span class="wfm-control-label">Stereo Width</span>
<input type="range" id="sam-stereo-width" min="0" max="100" value="100" class="status-input" />
</label>
<label class="wfm-control">
<span class="wfm-control-label">Carrier Sync</span>
<select id="sam-carrier-sync" class="status-input">
<option value="on">On</option>
<option value="off">Off</option>
</select>
</label>
</div>
<div class="label"><span>SAM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
@@ -411,7 +427,7 @@
<option value="LSB">
<option value="USB">
<option value="AM">
<option value="AMC-QUAM">
<option value="SAM">
<option value="FM">
<option value="DIG">
<option value="CW">
@@ -1122,6 +1122,36 @@ pub async fn set_wfm_denoise(
send_command(&rig_tx, RigCommand::SetWfmDenoise(q.level), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SamStereoWidthQuery {
pub width: f32,
pub remote: Option<String>,
}
#[post("/set_sam_stereo_width")]
pub async fn set_sam_stereo_width(
query: web::Query<SamStereoWidthQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSamStereoWidth(q.width), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SamCarrierSyncQuery {
pub enabled: bool,
pub remote: Option<String>,
}
#[post("/set_sam_carrier_sync")]
pub async fn set_sam_carrier_sync(
query: web::Query<SamCarrierSyncQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSamCarrierSync(q.enabled), q.remote).await
}
#[post("/toggle_aprs_decode")]
pub async fn toggle_aprs_decode(
query: web::Query<RemoteQuery>,
@@ -1974,6 +2004,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_wfm_deemphasis)
.service(set_wfm_stereo)
.service(set_wfm_denoise)
.service(set_sam_stereo_width)
.service(set_sam_carrier_sync)
.service(toggle_aprs_decode)
.service(toggle_hf_aprs_decode)
.service(toggle_cw_decode)
+2
View File
@@ -47,5 +47,7 @@ pub enum RigCommand {
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
SetSamStereoWidth(f32),
SetSamCarrierSync(bool),
GetSpectrum,
}
@@ -529,6 +529,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
| RigCommand::SetSamStereoWidth(_)
| RigCommand::SetSamCarrierSync(_)
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
}
}
+20
View File
@@ -268,6 +268,26 @@ pub trait RigSdr: Send {
)))
}
fn set_sam_stereo_width<'a>(
&'a mut self,
_width: f32,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sam_stereo_width"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sam_carrier_sync<'a>(
&'a mut self,
_enabled: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sam_carrier_sync"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
/// Return the current filter state if this backend supports filter controls.
fn filter_state(&self) -> Option<state::RigFilterState> {
None
+16 -2
View File
@@ -87,8 +87,8 @@ pub enum RigMode {
CW,
CWR,
AM,
/// AM C-QUAM stereo (Compatible Quadrature Amplitude Modulation).
AMC,
/// Synchronous AM (Stereo AM) — carrier-locked stereo demodulation.
SAM,
WFM,
FM,
AIS,
@@ -338,6 +338,12 @@ pub struct RigFilterState {
pub wfm_stereo_detected: bool,
#[serde(default = "default_wfm_denoise_level")]
pub wfm_denoise: WfmDenoiseLevel,
/// SAM stereo width (0.0 = mono, 1.0 = full stereo).
#[serde(default = "default_sam_stereo_width")]
pub sam_stereo_width: f32,
/// SAM carrier synchronization enabled.
#[serde(default = "default_sam_carrier_sync")]
pub sam_carrier_sync: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -358,6 +364,14 @@ fn default_wfm_stereo() -> bool {
true
}
fn default_sam_stereo_width() -> f32 {
1.0
}
fn default_sam_carrier_sync() -> bool {
true
}
fn default_wfm_denoise_level() -> WfmDenoiseLevel {
WfmDenoiseLevel::Auto
}
+12 -5
View File
@@ -22,7 +22,7 @@ pub fn parse_mode(s: &str) -> RigMode {
"CW" => RigMode::CW,
"CWR" => RigMode::CWR,
"AM" => RigMode::AM,
"AMC-QUAM" | "AMC_QUAM" | "AMC" => RigMode::AMC,
"SAM" | "AMC-QUAM" | "AMC_QUAM" | "AMC" => RigMode::SAM,
"FM" => RigMode::FM,
"WFM" => RigMode::WFM,
"AIS" => RigMode::AIS,
@@ -45,7 +45,7 @@ pub fn mode_to_string(mode: &RigMode) -> Cow<'static, str> {
RigMode::CW => Cow::Borrowed("CW"),
RigMode::CWR => Cow::Borrowed("CWR"),
RigMode::AM => Cow::Borrowed("AM"),
RigMode::AMC => Cow::Borrowed("AMC-QUAM"),
RigMode::SAM => Cow::Borrowed("SAM"),
RigMode::FM => Cow::Borrowed("FM"),
RigMode::WFM => Cow::Borrowed("WFM"),
RigMode::AIS => Cow::Borrowed("AIS"),
@@ -85,7 +85,8 @@ mod tests {
assert_eq!(parse_mode("CW"), RigMode::CW);
assert_eq!(parse_mode("CWR"), RigMode::CWR);
assert_eq!(parse_mode("AM"), RigMode::AM);
assert_eq!(parse_mode("AMC-QUAM"), RigMode::AMC);
assert_eq!(parse_mode("SAM"), RigMode::SAM);
assert_eq!(parse_mode("AMC-QUAM"), RigMode::SAM);
assert_eq!(parse_mode("FM"), RigMode::FM);
assert_eq!(parse_mode("WFM"), RigMode::WFM);
assert_eq!(parse_mode("AIS"), RigMode::AIS);
@@ -132,7 +133,7 @@ mod tests {
assert_eq!(mode_to_string(&RigMode::CW), "CW");
assert_eq!(mode_to_string(&RigMode::CWR), "CWR");
assert_eq!(mode_to_string(&RigMode::AM), "AM");
assert_eq!(mode_to_string(&RigMode::AMC), "AMC-QUAM");
assert_eq!(mode_to_string(&RigMode::SAM), "SAM");
assert_eq!(mode_to_string(&RigMode::FM), "FM");
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
@@ -154,7 +155,7 @@ mod tests {
RigMode::CW,
RigMode::CWR,
RigMode::AM,
RigMode::AMC,
RigMode::SAM,
RigMode::FM,
RigMode::WFM,
RigMode::AIS,
@@ -325,6 +326,8 @@ mod tests {
wfm_stereo: true,
wfm_stereo_detected: false,
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
sam_stereo_width: 1.0,
sam_carrier_sync: true,
}),
..minimal_snapshot()
})
@@ -369,6 +372,8 @@ mod tests {
wfm_stereo: true,
wfm_stereo_detected: true,
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
sam_stereo_width: 0.5,
sam_carrier_sync: false,
}),
..minimal_snapshot()
};
@@ -379,6 +384,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.sam_stereo_width, 0.5);
assert!(!f.sam_carrier_sync);
}
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
+4
View File
@@ -73,6 +73,8 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
}
ClientCommand::SetWfmStereo { enabled } => RigCommand::SetWfmStereo(enabled),
ClientCommand::SetWfmDenoise { level } => RigCommand::SetWfmDenoise(level),
ClientCommand::SetSamStereoWidth { width } => RigCommand::SetSamStereoWidth(width),
ClientCommand::SetSamCarrierSync { enabled } => RigCommand::SetSamCarrierSync(enabled),
ClientCommand::GetSpectrum => RigCommand::GetSpectrum,
}
}
@@ -139,6 +141,8 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
}
RigCommand::SetWfmStereo(enabled) => ClientCommand::SetWfmStereo { enabled },
RigCommand::SetWfmDenoise(level) => ClientCommand::SetWfmDenoise { level },
RigCommand::SetSamStereoWidth(width) => ClientCommand::SetSamStereoWidth { width },
RigCommand::SetSamCarrierSync(enabled) => ClientCommand::SetSamCarrierSync { enabled },
RigCommand::GetSpectrum => ClientCommand::GetSpectrum,
}
}
+2
View File
@@ -52,6 +52,8 @@ pub enum ClientCommand {
SetWfmDeemphasis { deemphasis_us: u32 },
SetWfmStereo { enabled: bool },
SetWfmDenoise { level: WfmDenoiseLevel },
SetSamStereoWidth { width: f32 },
SetSamCarrierSync { enabled: bool },
GetSpectrum,
}
+1 -1
View File
@@ -239,7 +239,7 @@ fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::PKT => 25_000,
RigMode::CW | RigMode::CWR => 500,
RigMode::AM | RigMode::AMC => 9_000,
RigMode::AM | RigMode::SAM => 9_000,
RigMode::FM => 12_500,
RigMode::WFM => 180_000,
RigMode::AIS => 25_000,
+32
View File
@@ -666,6 +666,38 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSamStereoWidth(width) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sam_stereo_width(width).await {
return Err(RigError::communication(format!(
"set_sam_stereo_width: {e}"
)));
}
} else {
return Err(RigError::not_supported("set_sam_stereo_width"));
}
if let Some(f) = ctx.state.filter.as_mut() {
f.sam_stereo_width = width;
}
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSamCarrierSync(enabled) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sam_carrier_sync(enabled).await {
return Err(RigError::communication(format!(
"set_sam_carrier_sync: {e}"
)));
}
} else {
return Err(RigError::not_supported("set_sam_carrier_sync"));
}
if let Some(f) = ctx.state.filter.as_mut() {
f.sam_carrier_sync = enabled;
}
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetCenterFreq(freq) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_center_freq(freq).await {
@@ -519,7 +519,7 @@ fn encode_mode(mode: &RigMode) -> DynResult<char> {
RigMode::CWR => Ok('7'),
RigMode::PKT => Ok('9'),
RigMode::WFM => Ok('4'),
RigMode::AMC => Err("Unsupported mode for FT-450D".into()),
RigMode::SAM => Err("Unsupported mode for FT-450D".into()),
RigMode::Other(_) => Err("Unsupported mode for FT-450D".into()),
}
}
@@ -603,7 +603,7 @@ fn encode_mode(mode: &RigMode) -> Option<u8> {
RigMode::FM => 0x08,
RigMode::DIG => 0x0A,
RigMode::PKT => 0x0C,
RigMode::AIS | RigMode::VDES | RigMode::AMC | RigMode::Other(_) => return None,
RigMode::AIS | RigMode::VDES | RigMode::SAM | RigMode::Other(_) => return None,
})
}
@@ -3,18 +3,18 @@
// SPDX-License-Identifier: BSD-2-Clause
mod am;
mod amcquam;
mod fm;
mod math;
mod math_arm;
mod math_x86;
mod sam;
mod ssb;
mod wfm;
use num_complex::Complex;
use trx_core::rig::state::RigMode;
pub use self::amcquam::CquamDemod;
pub use self::sam::SamDemod;
pub use self::wfm::WfmStereoDecoder;
/// Shared DC blocker used by narrowband and WFM audio paths.
@@ -147,8 +147,8 @@ pub enum Demodulator {
Cw,
/// Pass-through (DIG, PKT): same as USB.
Passthrough,
/// AM C-QUAM stereo: synchronous IQ detection with carrier phase tracking.
AmCQuam,
/// Synchronous AM (Stereo AM): carrier-locked IQ detection with stereo decode.
Sam,
}
impl Demodulator {
@@ -158,7 +158,7 @@ impl Demodulator {
RigMode::USB => Self::Usb,
RigMode::LSB => Self::Lsb,
RigMode::AM => Self::Am,
RigMode::AMC => Self::AmCQuam,
RigMode::SAM => Self::Sam,
RigMode::FM => Self::Fm,
RigMode::WFM => Self::Wfm,
RigMode::AIS | RigMode::VDES => Self::Fm,
@@ -175,7 +175,7 @@ impl Demodulator {
match self {
Self::Usb | Self::Passthrough => ssb::demod_usb(samples),
Self::Lsb => ssb::demod_lsb(samples),
Self::Am | Self::AmCQuam => am::demod_am(samples),
Self::Am | Self::Sam => am::demod_am(samples),
Self::Fm | Self::Wfm => fm::demod_fm(samples),
Self::Cw => ssb::demod_cw(samples),
}
@@ -201,7 +201,7 @@ mod tests {
Demodulator::Passthrough
);
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm);
assert_eq!(Demodulator::for_mode(&RigMode::AMC), Demodulator::AmCQuam);
assert_eq!(Demodulator::for_mode(&RigMode::SAM), Demodulator::Sam);
}
#[test]
@@ -215,7 +215,7 @@ mod tests {
Demodulator::Wfm,
Demodulator::Cw,
Demodulator::Passthrough,
Demodulator::AmCQuam,
Demodulator::Sam,
];
for demod in &demodulators {
assert!(
@@ -1,126 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
use super::DcBlocker;
use num_complex::Complex;
/// C-QUAM (Compatible Quadrature AM) stereo demodulator.
///
/// Tracks the AM carrier phase using a first-order IIR filter on the baseband
/// DC component (τ ≈ 50 ms), rotates each sample to align I with the sum
/// audio and Q with the difference audio, then reconstructs L/R stereo.
///
/// Input: AGC-normalised baseband IQ samples at audio sample rate.
/// Output: interleaved stereo PCM [L0, R0, L1, R1, …]
pub struct CquamDemod {
/// IIR-tracked in-phase carrier estimate.
carrier_re: f32,
/// IIR-tracked quadrature carrier estimate.
carrier_im: f32,
/// IIR smoothing coefficient (close to 1 → slow tracking).
alpha: f32,
/// DC blocker for left channel output (removes the carrier-level DC).
dc_l: DcBlocker,
/// DC blocker for right channel output.
dc_r: DcBlocker,
}
impl CquamDemod {
/// Create a new C-QUAM demodulator for the given audio sample rate.
pub fn new(audio_sample_rate: u32) -> Self {
let sr = audio_sample_rate.max(1) as f32;
// 50 ms tracking time constant — slow enough not to follow audio
// modulation (lowest speech fundamental ~100 Hz → period 10 ms),
// fast enough to follow SDR frequency offset drift.
let alpha = (-1.0f32 / (0.05 * sr)).exp();
Self {
carrier_re: 1.0,
carrier_im: 0.0,
alpha,
dc_l: DcBlocker::new(0.999),
dc_r: DcBlocker::new(0.999),
}
}
/// Demodulate a block of AGC-normalised baseband IQ samples into
/// interleaved stereo audio.
pub fn demodulate_stereo(&mut self, samples: &[Complex<f32>]) -> Vec<f32> {
let mut out = Vec::with_capacity(samples.len() * 2);
let alpha = self.alpha;
let one_minus_alpha = 1.0 - alpha;
for &s in samples {
// Advance the carrier IIR tracker. In steady state the DC
// component of s is the carrier phasor e^{jφ}.
self.carrier_re = alpha * self.carrier_re + one_minus_alpha * s.re;
self.carrier_im = alpha * self.carrier_im + one_minus_alpha * s.im;
// Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d.
let mag_sq = self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
let (i_corr, q_corr) = if mag_sq > 1e-8 {
let inv = mag_sq.sqrt().recip();
let cos_phi = self.carrier_re * inv;
let sin_phi = self.carrier_im * inv;
// s · e^{-jφ}
(
s.re * cos_phi + s.im * sin_phi,
-s.re * sin_phi + s.im * cos_phi,
)
} else {
(s.re, s.im)
};
// Stereo decode.
// I ≈ 1 + (L+R)/2, Q ≈ (LR)/2
// L_raw = I + Q = 1 + L → DC-block → L audio
// R_raw = I Q = 1 + R → DC-block → R audio
out.push(self.dc_l.process(i_corr + q_corr));
out.push(self.dc_r.process(i_corr - q_corr));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cquam_silence_is_silent() {
let mut demod = CquamDemod::new(8_000);
let samples = vec![Complex::new(0.0f32, 0.0); 256];
let out = demod.demodulate_stereo(&samples);
assert_eq!(out.len(), 512);
for &s in &out {
assert!(
s.abs() < 1e-5,
"silence should produce near-zero output, got {s}"
);
}
}
#[test]
fn test_cquam_pure_am_mono() {
// A pure AM carrier (no Q modulation) should produce equal L and R.
let mut demod = CquamDemod::new(8_000);
// Let the carrier tracker settle for 1 s worth of samples.
let settle: Vec<Complex<f32>> = (0..8_000)
.map(|i| {
let t = i as f32 / 8_000.0;
let audio = 0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin();
Complex::new(1.0 + audio, 0.0)
})
.collect();
let out = demod.demodulate_stereo(&settle);
// After settling, L and R should be roughly equal (within 0.02 amplitude).
for chunk in out.chunks_exact(2).skip(4_000) {
let l = chunk[0];
let r = chunk[1];
assert!(
(l - r).abs() < 0.02,
"pure AM mono should have L ≈ R, got L={l:.4} R={r:.4}"
);
}
}
}
@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
use super::DcBlocker;
use num_complex::Complex;
/// Synchronous AM (Stereo AM) demodulator with carrier synchronization.
///
/// Tracks the AM carrier phase using a first-order IIR filter on the baseband
/// DC component (τ ≈ 50 ms), rotates each sample to align I with the sum
/// audio and Q with the difference audio, then reconstructs L/R stereo.
///
/// `stereo_width` controls the L/R separation (0.0 = mono, 1.0 = full stereo).
/// `carrier_sync` enables/disables the carrier phase tracking PLL.
///
/// Input: AGC-normalised baseband IQ samples at audio sample rate.
/// Output: interleaved stereo PCM [L0, R0, L1, R1, …]
pub struct SamDemod {
/// IIR-tracked in-phase carrier estimate.
carrier_re: f32,
/// IIR-tracked quadrature carrier estimate.
carrier_im: f32,
/// IIR smoothing coefficient (close to 1 → slow tracking).
alpha: f32,
/// DC blocker for left channel output (removes the carrier-level DC).
dc_l: DcBlocker,
/// DC blocker for right channel output.
dc_r: DcBlocker,
/// Stereo width: 0.0 = mono, 1.0 = full stereo separation.
stereo_width: f32,
/// Whether carrier phase synchronization is active.
carrier_sync: bool,
}
impl SamDemod {
/// Create a new SAM demodulator for the given audio sample rate.
pub fn new(audio_sample_rate: u32) -> Self {
let sr = audio_sample_rate.max(1) as f32;
// 50 ms tracking time constant — slow enough not to follow audio
// modulation (lowest speech fundamental ~100 Hz → period 10 ms),
// fast enough to follow SDR frequency offset drift.
let alpha = (-1.0f32 / (0.05 * sr)).exp();
Self {
carrier_re: 1.0,
carrier_im: 0.0,
alpha,
dc_l: DcBlocker::new(0.999),
dc_r: DcBlocker::new(0.999),
stereo_width: 1.0,
carrier_sync: true,
}
}
/// Set stereo width (0.0 = mono, 1.0 = full stereo).
pub fn set_stereo_width(&mut self, width: f32) {
self.stereo_width = width.clamp(0.0, 1.0);
}
/// Enable or disable carrier phase synchronization.
pub fn set_carrier_sync(&mut self, enabled: bool) {
self.carrier_sync = enabled;
if !enabled {
// Reset carrier tracker to default (real axis aligned).
self.carrier_re = 1.0;
self.carrier_im = 0.0;
}
}
/// Demodulate a block of AGC-normalised baseband IQ samples into
/// interleaved stereo audio.
pub fn demodulate_stereo(&mut self, samples: &[Complex<f32>]) -> Vec<f32> {
let mut out = Vec::with_capacity(samples.len() * 2);
let alpha = self.alpha;
let one_minus_alpha = 1.0 - alpha;
let w = self.stereo_width;
for &s in samples {
let (i_corr, q_corr) = if self.carrier_sync {
// Advance the carrier IIR tracker. In steady state the DC
// component of s is the carrier phasor e^{jφ}.
self.carrier_re = alpha * self.carrier_re + one_minus_alpha * s.re;
self.carrier_im = alpha * self.carrier_im + one_minus_alpha * s.im;
// Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d.
let mag_sq = self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
if mag_sq > 1e-8 {
let inv = mag_sq.sqrt().recip();
let cos_phi = self.carrier_re * inv;
let sin_phi = self.carrier_im * inv;
// s · e^{-jφ}
(
s.re * cos_phi + s.im * sin_phi,
-s.re * sin_phi + s.im * cos_phi,
)
} else {
(s.re, s.im)
}
} else {
// No carrier sync — treat I/Q as-is (envelope detection).
(s.re, s.im)
};
// Stereo decode.
// I ≈ 1 + (L+R)/2, Q ≈ (LR)/2
// L_raw = I + w*Q = 1 + L → DC-block → L audio
// R_raw = I w*Q = 1 + R → DC-block → R audio
let l = self.dc_l.process(i_corr + w * q_corr);
let r = self.dc_r.process(i_corr - w * q_corr);
out.push(l);
out.push(r);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sam_silence_is_silent() {
let mut demod = SamDemod::new(8_000);
let samples = vec![Complex::new(0.0f32, 0.0); 256];
let out = demod.demodulate_stereo(&samples);
assert_eq!(out.len(), 512);
for &s in &out {
assert!(
s.abs() < 1e-5,
"silence should produce near-zero output, got {s}"
);
}
}
#[test]
fn test_sam_pure_am_mono() {
// A pure AM carrier (no Q modulation) should produce equal L and R.
let mut demod = SamDemod::new(8_000);
// Let the carrier tracker settle for 1 s worth of samples.
let settle: Vec<Complex<f32>> = (0..8_000)
.map(|i| {
let t = i as f32 / 8_000.0;
let audio = 0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin();
Complex::new(1.0 + audio, 0.0)
})
.collect();
let out = demod.demodulate_stereo(&settle);
// After settling, L and R should be roughly equal (within 0.02 amplitude).
for chunk in out.chunks_exact(2).skip(4_000) {
let l = chunk[0];
let r = chunk[1];
assert!(
(l - r).abs() < 0.02,
"pure AM mono should have L ≈ R, got L={l:.4} R={r:.4}"
);
}
}
#[test]
fn test_sam_stereo_width_zero_is_mono() {
let mut demod = SamDemod::new(8_000);
demod.set_stereo_width(0.0);
// Feed carrier with Q modulation — should still be mono.
let samples: Vec<Complex<f32>> = (0..8_000)
.map(|i| {
let t = i as f32 / 8_000.0;
let audio = 0.3 * (2.0 * std::f32::consts::PI * 1000.0 * t).sin();
Complex::new(1.0, audio)
})
.collect();
let out = demod.demodulate_stereo(&samples);
// With width=0, L and R should be identical.
for chunk in out.chunks_exact(2).skip(4_000) {
let l = chunk[0];
let r = chunk[1];
assert!(
(l - r).abs() < 1e-5,
"width=0 should produce identical L/R, got L={l:.4} R={r:.4}"
);
}
}
}
@@ -6,7 +6,7 @@ use num_complex::Complex;
use tokio::sync::broadcast;
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
use crate::demod::{CquamDemod, DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder};
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
@@ -196,7 +196,7 @@ fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
let sr = audio_sample_rate.max(1) as f32;
match mode {
RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0),
RigMode::AM | RigMode::AMC => SoftAgc::new(sr, 5.0, 200.0, 0.5, 36.0),
RigMode::AM | RigMode::SAM => SoftAgc::new(sr, 5.0, 200.0, 0.5, 36.0),
_ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0),
}
}
@@ -210,7 +210,7 @@ fn iq_agc_for_mode(mode: &RigMode, sample_rate: u32) -> Option<SoftAgc> {
// DC blocker always sees the same steady-state bias (~0.7) regardless
// of RF signal strength. Fast attack (0.5 ms) catches sudden carrier
// appearance; 50 ms release tracks slow fading without distorting audio.
RigMode::AM | RigMode::AMC => Some(SoftAgc::new(sr, 0.5, 50.0, 0.7, 30.0)),
RigMode::AM | RigMode::SAM => Some(SoftAgc::new(sr, 0.5, 50.0, 0.7, 30.0)),
RigMode::WFM => None,
_ => None,
}
@@ -219,8 +219,8 @@ fn iq_agc_for_mode(mode: &RigMode, sample_rate: u32) -> Option<SoftAgc> {
fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> {
match mode {
RigMode::WFM => None,
// AMC: DC is handled inside CquamDemod per channel (L and R separately).
RigMode::AMC => None,
// SAM: DC is handled inside SamDemod per channel (L and R separately).
RigMode::SAM => None,
// AM: the envelope detector output has a large carrier-amplitude DC
// bias (A_c). r=0.999 gives τ≈125 ms at 8 kHz, tracking carrier
// level ~10× faster than r=0.9999 while still passing all audio
@@ -235,7 +235,7 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::PKT => 25_000,
RigMode::CW | RigMode::CWR => 500,
RigMode::AM | RigMode::AMC => 9_000,
RigMode::AM | RigMode::SAM => 9_000,
RigMode::FM => 12_500,
RigMode::WFM => 180_000,
RigMode::AIS => 25_000,
@@ -288,7 +288,7 @@ pub struct ChannelDsp {
resample_phase: f64,
resample_phase_inc: f64,
wfm_decoder: Option<WfmStereoDecoder>,
cquam_decoder: Option<CquamDemod>,
sam_decoder: Option<SamDemod>,
iq_agc: Option<SoftAgc>,
audio_agc: SoftAgc,
audio_dc: Option<DcBlocker>,
@@ -301,9 +301,9 @@ pub struct ChannelDsp {
impl ChannelDsp {
fn clamp_bandwidth_for_mode(mode: &RigMode, bandwidth_hz: u32) -> u32 {
match mode {
// C-QUAM requires ≥ 9 kHz to capture both sum (L+R) and difference
// SAM stereo requires ≥ 9 kHz to capture both sum (L+R) and difference
// (LR) sidebands; narrower bandwidths would discard stereo content.
RigMode::AMC => bandwidth_hz.max(9_000),
RigMode::SAM => bandwidth_hz.max(9_000),
_ => bandwidth_hz,
}
}
@@ -385,10 +385,10 @@ impl ChannelDsp {
} else {
self.wfm_decoder = None;
}
if self.mode == RigMode::AMC {
self.cquam_decoder = Some(CquamDemod::new(self.audio_sample_rate));
if self.mode == RigMode::SAM {
self.sam_decoder = Some(SamDemod::new(self.audio_sample_rate));
} else {
self.cquam_decoder = None;
self.sam_decoder = None;
}
self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate);
self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
@@ -488,8 +488,8 @@ impl ChannelDsp {
} else {
None
},
cquam_decoder: if *mode == RigMode::AMC {
Some(CquamDemod::new(audio_sample_rate))
sam_decoder: if *mode == RigMode::SAM {
Some(SamDemod::new(audio_sample_rate))
} else {
None
},
@@ -548,6 +548,18 @@ impl ChannelDsp {
}
}
pub fn set_sam_stereo_width(&mut self, width: f32) {
if let Some(decoder) = &mut self.sam_decoder {
decoder.set_stereo_width(width);
}
}
pub fn set_sam_carrier_sync(&mut self, enabled: bool) {
if let Some(decoder) = &mut self.sam_decoder {
decoder.set_carrier_sync(enabled);
}
}
pub fn set_wfm_denoise(&mut self, level: WfmDenoiseLevel) {
self.wfm_denoise = level;
if let Some(decoder) = &mut self.wfm_decoder {
@@ -709,7 +721,7 @@ impl ChannelDsp {
*sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0);
}
out
} else if let Some(decoder) = self.cquam_decoder.as_mut() {
} else if let Some(decoder) = self.sam_decoder.as_mut() {
let stereo = decoder.demodulate_stereo(decimated);
// Apply stereo-aware AGC (shared gain preserves L/R balance).
let mut out = Vec::with_capacity(stereo.len());
@@ -48,6 +48,10 @@ pub struct SoapySdrRig {
wfm_stereo: bool,
/// Whether WFM stereo denoise is enabled.
wfm_denoise: WfmDenoiseLevel,
/// SAM stereo width (0.0 = mono, 1.0 = full stereo).
sam_stereo_width: f32,
/// SAM carrier synchronization enabled.
sam_carrier_sync: bool,
/// Requested hardware gain setting in dB.
gain_db: f64,
/// Optional hard ceiling for the applied hardware gain in dB.
@@ -77,7 +81,7 @@ impl SoapySdrRig {
RigMode::PKT | RigMode::AIS => 25_000,
RigMode::VDES => 100_000,
RigMode::CW | RigMode::CWR => 500,
RigMode::AM | RigMode::AMC => 9_000,
RigMode::AM | RigMode::SAM => 9_000,
RigMode::FM => 12_500,
RigMode::WFM => 180_000,
RigMode::Other(_) => 3_000,
@@ -251,7 +255,7 @@ impl SoapySdrRig {
RigMode::CW,
RigMode::CWR,
RigMode::AM,
RigMode::AMC,
RigMode::SAM,
RigMode::WFM,
RigMode::FM,
RigMode::AIS,
@@ -311,6 +315,8 @@ impl SoapySdrRig {
wfm_deemphasis_us,
wfm_stereo: true,
wfm_denoise: WfmDenoiseLevel::Auto,
sam_stereo_width: 1.0,
sam_carrier_sync: true,
gain_db,
max_gain_db,
lna_gain_db: initial_lna_gain_db,
@@ -856,6 +862,38 @@ impl RigSdr for SoapySdrRig {
})
}
fn set_sam_stereo_width<'a>(
&'a mut self,
width: f32,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
self.sam_stereo_width = width.clamp(0.0, 1.0);
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
dsp_arc.lock().unwrap().set_sam_stereo_width(width);
}
}
Ok(())
})
}
fn set_sam_carrier_sync<'a>(
&'a mut self,
enabled: bool,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
self.sam_carrier_sync = enabled;
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
dsp_arc.lock().unwrap().set_sam_carrier_sync(enabled);
}
}
Ok(())
})
}
fn filter_state(&self) -> Option<RigFilterState> {
let wfm_stereo_detected = self
.pipeline
@@ -883,6 +921,8 @@ impl RigSdr for SoapySdrRig {
wfm_stereo: self.wfm_stereo,
wfm_stereo_detected,
wfm_denoise: self.wfm_denoise,
sam_stereo_width: self.sam_stereo_width,
sam_carrier_sync: self.sam_carrier_sync,
})
}
@@ -43,7 +43,7 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
match mode {
RigMode::CW | RigMode::CWR => 500,
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::AM | RigMode::AMC => 9_000,
RigMode::AM | RigMode::SAM => 9_000,
RigMode::FM => 12_500,
RigMode::WFM => 180_000,
RigMode::PKT | RigMode::AIS => 25_000,