[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:
@@ -2955,6 +2955,13 @@ function render(update) {
|
|||||||
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
||||||
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !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 hasSdrSquelchEnabled = typeof update.filter.sdr_squelch_enabled === "boolean";
|
||||||
const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number";
|
const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number";
|
||||||
if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) {
|
if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) {
|
||||||
@@ -3915,7 +3922,7 @@ const MODE_BW_DEFAULTS = {
|
|||||||
LSB: [2_700, 300, 6_000, 100],
|
LSB: [2_700, 300, 6_000, 100],
|
||||||
USB: [2_700, 300, 6_000, 100],
|
USB: [2_700, 300, 6_000, 100],
|
||||||
AM: [9_000, 500, 20_000, 500],
|
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],
|
FM: [12_500, 2_500, 25_000, 500],
|
||||||
AIS: [25_000, 12_500, 50_000, 500],
|
AIS: [25_000, 12_500, 50_000, 500],
|
||||||
VDES: [100_000, 25_000, 200_000, 1_000],
|
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 sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set");
|
||||||
const sdrAgcEl = document.getElementById("sdr-agc-enabled");
|
const sdrAgcEl = document.getElementById("sdr-agc-enabled");
|
||||||
const wfmStFlagEl = document.getElementById("wfm-st-flag");
|
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 sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
|
||||||
const sdrSquelchEl = document.getElementById("sdr-squelch");
|
const sdrSquelchEl = document.getElementById("sdr-squelch");
|
||||||
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
|
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
|
||||||
@@ -7681,6 +7691,18 @@ if (wfmDeemphasisEl) {
|
|||||||
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
|
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() {
|
function submitSdrGain() {
|
||||||
if (!sdrGainEl) return;
|
if (!sdrGainEl) return;
|
||||||
const parsed = Number.parseFloat(sdrGainEl.value);
|
const parsed = Number.parseFloat(sdrGainEl.value);
|
||||||
@@ -7761,9 +7783,9 @@ if (sdrNbThresholdEl) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function updateWfmControls() {
|
function updateWfmControls() {
|
||||||
if (!wfmControlsCol) return;
|
|
||||||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
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
|
// Show compatibility warning for non-Chromium browsers
|
||||||
|
|||||||
@@ -253,6 +253,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="label"><span>WFM</span></div>
|
<div class="label"><span>WFM</span></div>
|
||||||
</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="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||||
<div class="label"><span>Transmit / Power</span></div>
|
<div class="label"><span>Transmit / Power</span></div>
|
||||||
<div class="btn-grid">
|
<div class="btn-grid">
|
||||||
@@ -411,7 +427,7 @@
|
|||||||
<option value="LSB">
|
<option value="LSB">
|
||||||
<option value="USB">
|
<option value="USB">
|
||||||
<option value="AM">
|
<option value="AM">
|
||||||
<option value="AMC-QUAM">
|
<option value="SAM">
|
||||||
<option value="FM">
|
<option value="FM">
|
||||||
<option value="DIG">
|
<option value="DIG">
|
||||||
<option value="CW">
|
<option value="CW">
|
||||||
|
|||||||
@@ -1122,6 +1122,36 @@ pub async fn set_wfm_denoise(
|
|||||||
send_command(&rig_tx, RigCommand::SetWfmDenoise(q.level), q.remote).await
|
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")]
|
#[post("/toggle_aprs_decode")]
|
||||||
pub async fn toggle_aprs_decode(
|
pub async fn toggle_aprs_decode(
|
||||||
query: web::Query<RemoteQuery>,
|
query: web::Query<RemoteQuery>,
|
||||||
@@ -1974,6 +2004,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(set_wfm_deemphasis)
|
.service(set_wfm_deemphasis)
|
||||||
.service(set_wfm_stereo)
|
.service(set_wfm_stereo)
|
||||||
.service(set_wfm_denoise)
|
.service(set_wfm_denoise)
|
||||||
|
.service(set_sam_stereo_width)
|
||||||
|
.service(set_sam_carrier_sync)
|
||||||
.service(toggle_aprs_decode)
|
.service(toggle_aprs_decode)
|
||||||
.service(toggle_hf_aprs_decode)
|
.service(toggle_hf_aprs_decode)
|
||||||
.service(toggle_cw_decode)
|
.service(toggle_cw_decode)
|
||||||
|
|||||||
@@ -47,5 +47,7 @@ pub enum RigCommand {
|
|||||||
SetWfmDeemphasis(u32),
|
SetWfmDeemphasis(u32),
|
||||||
SetWfmStereo(bool),
|
SetWfmStereo(bool),
|
||||||
SetWfmDenoise(WfmDenoiseLevel),
|
SetWfmDenoise(WfmDenoiseLevel),
|
||||||
|
SetSamStereoWidth(f32),
|
||||||
|
SetSamCarrierSync(bool),
|
||||||
GetSpectrum,
|
GetSpectrum,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,6 +529,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::SetWfmDeemphasis(_)
|
| RigCommand::SetWfmDeemphasis(_)
|
||||||
| RigCommand::SetWfmStereo(_)
|
| RigCommand::SetWfmStereo(_)
|
||||||
| RigCommand::SetWfmDenoise(_)
|
| RigCommand::SetWfmDenoise(_)
|
||||||
|
| RigCommand::SetSamStereoWidth(_)
|
||||||
|
| RigCommand::SetSamCarrierSync(_)
|
||||||
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
|
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Return the current filter state if this backend supports filter controls.
|
||||||
fn filter_state(&self) -> Option<state::RigFilterState> {
|
fn filter_state(&self) -> Option<state::RigFilterState> {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ pub enum RigMode {
|
|||||||
CW,
|
CW,
|
||||||
CWR,
|
CWR,
|
||||||
AM,
|
AM,
|
||||||
/// AM C-QUAM stereo (Compatible Quadrature Amplitude Modulation).
|
/// Synchronous AM (Stereo AM) — carrier-locked stereo demodulation.
|
||||||
AMC,
|
SAM,
|
||||||
WFM,
|
WFM,
|
||||||
FM,
|
FM,
|
||||||
AIS,
|
AIS,
|
||||||
@@ -338,6 +338,12 @@ pub struct RigFilterState {
|
|||||||
pub wfm_stereo_detected: bool,
|
pub wfm_stereo_detected: bool,
|
||||||
#[serde(default = "default_wfm_denoise_level")]
|
#[serde(default = "default_wfm_denoise_level")]
|
||||||
pub wfm_denoise: WfmDenoiseLevel,
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -358,6 +364,14 @@ fn default_wfm_stereo() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sam_stereo_width() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_sam_carrier_sync() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
||||||
WfmDenoiseLevel::Auto
|
WfmDenoiseLevel::Auto
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub fn parse_mode(s: &str) -> RigMode {
|
|||||||
"CW" => RigMode::CW,
|
"CW" => RigMode::CW,
|
||||||
"CWR" => RigMode::CWR,
|
"CWR" => RigMode::CWR,
|
||||||
"AM" => RigMode::AM,
|
"AM" => RigMode::AM,
|
||||||
"AMC-QUAM" | "AMC_QUAM" | "AMC" => RigMode::AMC,
|
"SAM" | "AMC-QUAM" | "AMC_QUAM" | "AMC" => RigMode::SAM,
|
||||||
"FM" => RigMode::FM,
|
"FM" => RigMode::FM,
|
||||||
"WFM" => RigMode::WFM,
|
"WFM" => RigMode::WFM,
|
||||||
"AIS" => RigMode::AIS,
|
"AIS" => RigMode::AIS,
|
||||||
@@ -45,7 +45,7 @@ pub fn mode_to_string(mode: &RigMode) -> Cow<'static, str> {
|
|||||||
RigMode::CW => Cow::Borrowed("CW"),
|
RigMode::CW => Cow::Borrowed("CW"),
|
||||||
RigMode::CWR => Cow::Borrowed("CWR"),
|
RigMode::CWR => Cow::Borrowed("CWR"),
|
||||||
RigMode::AM => Cow::Borrowed("AM"),
|
RigMode::AM => Cow::Borrowed("AM"),
|
||||||
RigMode::AMC => Cow::Borrowed("AMC-QUAM"),
|
RigMode::SAM => Cow::Borrowed("SAM"),
|
||||||
RigMode::FM => Cow::Borrowed("FM"),
|
RigMode::FM => Cow::Borrowed("FM"),
|
||||||
RigMode::WFM => Cow::Borrowed("WFM"),
|
RigMode::WFM => Cow::Borrowed("WFM"),
|
||||||
RigMode::AIS => Cow::Borrowed("AIS"),
|
RigMode::AIS => Cow::Borrowed("AIS"),
|
||||||
@@ -85,7 +85,8 @@ mod tests {
|
|||||||
assert_eq!(parse_mode("CW"), RigMode::CW);
|
assert_eq!(parse_mode("CW"), RigMode::CW);
|
||||||
assert_eq!(parse_mode("CWR"), RigMode::CWR);
|
assert_eq!(parse_mode("CWR"), RigMode::CWR);
|
||||||
assert_eq!(parse_mode("AM"), RigMode::AM);
|
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("FM"), RigMode::FM);
|
||||||
assert_eq!(parse_mode("WFM"), RigMode::WFM);
|
assert_eq!(parse_mode("WFM"), RigMode::WFM);
|
||||||
assert_eq!(parse_mode("AIS"), RigMode::AIS);
|
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::CW), "CW");
|
||||||
assert_eq!(mode_to_string(&RigMode::CWR), "CWR");
|
assert_eq!(mode_to_string(&RigMode::CWR), "CWR");
|
||||||
assert_eq!(mode_to_string(&RigMode::AM), "AM");
|
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::FM), "FM");
|
||||||
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
|
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
|
||||||
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
|
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
|
||||||
@@ -154,7 +155,7 @@ mod tests {
|
|||||||
RigMode::CW,
|
RigMode::CW,
|
||||||
RigMode::CWR,
|
RigMode::CWR,
|
||||||
RigMode::AM,
|
RigMode::AM,
|
||||||
RigMode::AMC,
|
RigMode::SAM,
|
||||||
RigMode::FM,
|
RigMode::FM,
|
||||||
RigMode::WFM,
|
RigMode::WFM,
|
||||||
RigMode::AIS,
|
RigMode::AIS,
|
||||||
@@ -325,6 +326,8 @@ mod tests {
|
|||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: false,
|
wfm_stereo_detected: false,
|
||||||
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
|
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
|
||||||
|
sam_stereo_width: 1.0,
|
||||||
|
sam_carrier_sync: true,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
})
|
})
|
||||||
@@ -369,6 +372,8 @@ mod tests {
|
|||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: true,
|
wfm_stereo_detected: true,
|
||||||
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
|
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
|
||||||
|
sam_stereo_width: 0.5,
|
||||||
|
sam_carrier_sync: false,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
};
|
};
|
||||||
@@ -379,6 +384,8 @@ mod tests {
|
|||||||
assert_eq!(f.sdr_gain_db, Some(18.0));
|
assert_eq!(f.sdr_gain_db, Some(18.0));
|
||||||
assert_eq!(f.wfm_deemphasis_us, 50);
|
assert_eq!(f.wfm_deemphasis_us, 50);
|
||||||
assert!(f.wfm_stereo_detected);
|
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 {
|
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
|
|||||||
}
|
}
|
||||||
ClientCommand::SetWfmStereo { enabled } => RigCommand::SetWfmStereo(enabled),
|
ClientCommand::SetWfmStereo { enabled } => RigCommand::SetWfmStereo(enabled),
|
||||||
ClientCommand::SetWfmDenoise { level } => RigCommand::SetWfmDenoise(level),
|
ClientCommand::SetWfmDenoise { level } => RigCommand::SetWfmDenoise(level),
|
||||||
|
ClientCommand::SetSamStereoWidth { width } => RigCommand::SetSamStereoWidth(width),
|
||||||
|
ClientCommand::SetSamCarrierSync { enabled } => RigCommand::SetSamCarrierSync(enabled),
|
||||||
ClientCommand::GetSpectrum => RigCommand::GetSpectrum,
|
ClientCommand::GetSpectrum => RigCommand::GetSpectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,8 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
|
|||||||
}
|
}
|
||||||
RigCommand::SetWfmStereo(enabled) => ClientCommand::SetWfmStereo { enabled },
|
RigCommand::SetWfmStereo(enabled) => ClientCommand::SetWfmStereo { enabled },
|
||||||
RigCommand::SetWfmDenoise(level) => ClientCommand::SetWfmDenoise { level },
|
RigCommand::SetWfmDenoise(level) => ClientCommand::SetWfmDenoise { level },
|
||||||
|
RigCommand::SetSamStereoWidth(width) => ClientCommand::SetSamStereoWidth { width },
|
||||||
|
RigCommand::SetSamCarrierSync(enabled) => ClientCommand::SetSamCarrierSync { enabled },
|
||||||
RigCommand::GetSpectrum => ClientCommand::GetSpectrum,
|
RigCommand::GetSpectrum => ClientCommand::GetSpectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ pub enum ClientCommand {
|
|||||||
SetWfmDeemphasis { deemphasis_us: u32 },
|
SetWfmDeemphasis { deemphasis_us: u32 },
|
||||||
SetWfmStereo { enabled: bool },
|
SetWfmStereo { enabled: bool },
|
||||||
SetWfmDenoise { level: WfmDenoiseLevel },
|
SetWfmDenoise { level: WfmDenoiseLevel },
|
||||||
|
SetSamStereoWidth { width: f32 },
|
||||||
|
SetSamCarrierSync { enabled: bool },
|
||||||
GetSpectrum,
|
GetSpectrum,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||||
RigMode::PKT => 25_000,
|
RigMode::PKT => 25_000,
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::AM | RigMode::AMC => 9_000,
|
RigMode::AM | RigMode::SAM => 9_000,
|
||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::AIS => 25_000,
|
RigMode::AIS => 25_000,
|
||||||
|
|||||||
@@ -666,6 +666,38 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
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) => {
|
RigCommand::SetCenterFreq(freq) => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||||
if let Err(e) = sdr.set_center_freq(freq).await {
|
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::CWR => Ok('7'),
|
||||||
RigMode::PKT => Ok('9'),
|
RigMode::PKT => Ok('9'),
|
||||||
RigMode::WFM => Ok('4'),
|
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()),
|
RigMode::Other(_) => Err("Unsupported mode for FT-450D".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,7 +603,7 @@ fn encode_mode(mode: &RigMode) -> Option<u8> {
|
|||||||
RigMode::FM => 0x08,
|
RigMode::FM => 0x08,
|
||||||
RigMode::DIG => 0x0A,
|
RigMode::DIG => 0x0A,
|
||||||
RigMode::PKT => 0x0C,
|
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
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
mod am;
|
mod am;
|
||||||
mod amcquam;
|
|
||||||
mod fm;
|
mod fm;
|
||||||
mod math;
|
mod math;
|
||||||
mod math_arm;
|
mod math_arm;
|
||||||
mod math_x86;
|
mod math_x86;
|
||||||
|
mod sam;
|
||||||
mod ssb;
|
mod ssb;
|
||||||
mod wfm;
|
mod wfm;
|
||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use trx_core::rig::state::RigMode;
|
use trx_core::rig::state::RigMode;
|
||||||
|
|
||||||
pub use self::amcquam::CquamDemod;
|
pub use self::sam::SamDemod;
|
||||||
pub use self::wfm::WfmStereoDecoder;
|
pub use self::wfm::WfmStereoDecoder;
|
||||||
|
|
||||||
/// Shared DC blocker used by narrowband and WFM audio paths.
|
/// Shared DC blocker used by narrowband and WFM audio paths.
|
||||||
@@ -147,8 +147,8 @@ pub enum Demodulator {
|
|||||||
Cw,
|
Cw,
|
||||||
/// Pass-through (DIG, PKT): same as USB.
|
/// Pass-through (DIG, PKT): same as USB.
|
||||||
Passthrough,
|
Passthrough,
|
||||||
/// AM C-QUAM stereo: synchronous IQ detection with carrier phase tracking.
|
/// Synchronous AM (Stereo AM): carrier-locked IQ detection with stereo decode.
|
||||||
AmCQuam,
|
Sam,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Demodulator {
|
impl Demodulator {
|
||||||
@@ -158,7 +158,7 @@ impl Demodulator {
|
|||||||
RigMode::USB => Self::Usb,
|
RigMode::USB => Self::Usb,
|
||||||
RigMode::LSB => Self::Lsb,
|
RigMode::LSB => Self::Lsb,
|
||||||
RigMode::AM => Self::Am,
|
RigMode::AM => Self::Am,
|
||||||
RigMode::AMC => Self::AmCQuam,
|
RigMode::SAM => Self::Sam,
|
||||||
RigMode::FM => Self::Fm,
|
RigMode::FM => Self::Fm,
|
||||||
RigMode::WFM => Self::Wfm,
|
RigMode::WFM => Self::Wfm,
|
||||||
RigMode::AIS | RigMode::VDES => Self::Fm,
|
RigMode::AIS | RigMode::VDES => Self::Fm,
|
||||||
@@ -175,7 +175,7 @@ impl Demodulator {
|
|||||||
match self {
|
match self {
|
||||||
Self::Usb | Self::Passthrough => ssb::demod_usb(samples),
|
Self::Usb | Self::Passthrough => ssb::demod_usb(samples),
|
||||||
Self::Lsb => ssb::demod_lsb(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::Fm | Self::Wfm => fm::demod_fm(samples),
|
||||||
Self::Cw => ssb::demod_cw(samples),
|
Self::Cw => ssb::demod_cw(samples),
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ mod tests {
|
|||||||
Demodulator::Passthrough
|
Demodulator::Passthrough
|
||||||
);
|
);
|
||||||
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm);
|
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]
|
#[test]
|
||||||
@@ -215,7 +215,7 @@ mod tests {
|
|||||||
Demodulator::Wfm,
|
Demodulator::Wfm,
|
||||||
Demodulator::Cw,
|
Demodulator::Cw,
|
||||||
Demodulator::Passthrough,
|
Demodulator::Passthrough,
|
||||||
Demodulator::AmCQuam,
|
Demodulator::Sam,
|
||||||
];
|
];
|
||||||
for demod in &demodulators {
|
for demod in &demodulators {
|
||||||
assert!(
|
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 ≈ (L−R)/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 ≈ (L−R)/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 tokio::sync::broadcast;
|
||||||
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
|
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};
|
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;
|
let sr = audio_sample_rate.max(1) as f32;
|
||||||
match mode {
|
match mode {
|
||||||
RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0),
|
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),
|
_ => 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
|
// DC blocker always sees the same steady-state bias (~0.7) regardless
|
||||||
// of RF signal strength. Fast attack (0.5 ms) catches sudden carrier
|
// of RF signal strength. Fast attack (0.5 ms) catches sudden carrier
|
||||||
// appearance; 50 ms release tracks slow fading without distorting audio.
|
// 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,
|
RigMode::WFM => None,
|
||||||
_ => 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> {
|
fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> {
|
||||||
match mode {
|
match mode {
|
||||||
RigMode::WFM => None,
|
RigMode::WFM => None,
|
||||||
// AMC: DC is handled inside CquamDemod per channel (L and R separately).
|
// SAM: DC is handled inside SamDemod per channel (L and R separately).
|
||||||
RigMode::AMC => None,
|
RigMode::SAM => None,
|
||||||
// AM: the envelope detector output has a large carrier-amplitude DC
|
// 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
|
// 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
|
// 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::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||||
RigMode::PKT => 25_000,
|
RigMode::PKT => 25_000,
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::AM | RigMode::AMC => 9_000,
|
RigMode::AM | RigMode::SAM => 9_000,
|
||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::AIS => 25_000,
|
RigMode::AIS => 25_000,
|
||||||
@@ -288,7 +288,7 @@ pub struct ChannelDsp {
|
|||||||
resample_phase: f64,
|
resample_phase: f64,
|
||||||
resample_phase_inc: f64,
|
resample_phase_inc: f64,
|
||||||
wfm_decoder: Option<WfmStereoDecoder>,
|
wfm_decoder: Option<WfmStereoDecoder>,
|
||||||
cquam_decoder: Option<CquamDemod>,
|
sam_decoder: Option<SamDemod>,
|
||||||
iq_agc: Option<SoftAgc>,
|
iq_agc: Option<SoftAgc>,
|
||||||
audio_agc: SoftAgc,
|
audio_agc: SoftAgc,
|
||||||
audio_dc: Option<DcBlocker>,
|
audio_dc: Option<DcBlocker>,
|
||||||
@@ -301,9 +301,9 @@ pub struct ChannelDsp {
|
|||||||
impl ChannelDsp {
|
impl ChannelDsp {
|
||||||
fn clamp_bandwidth_for_mode(mode: &RigMode, bandwidth_hz: u32) -> u32 {
|
fn clamp_bandwidth_for_mode(mode: &RigMode, bandwidth_hz: u32) -> u32 {
|
||||||
match mode {
|
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
|
||||||
// (L−R) sidebands; narrower bandwidths would discard stereo content.
|
// (L−R) sidebands; narrower bandwidths would discard stereo content.
|
||||||
RigMode::AMC => bandwidth_hz.max(9_000),
|
RigMode::SAM => bandwidth_hz.max(9_000),
|
||||||
_ => bandwidth_hz,
|
_ => bandwidth_hz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,10 +385,10 @@ impl ChannelDsp {
|
|||||||
} else {
|
} else {
|
||||||
self.wfm_decoder = None;
|
self.wfm_decoder = None;
|
||||||
}
|
}
|
||||||
if self.mode == RigMode::AMC {
|
if self.mode == RigMode::SAM {
|
||||||
self.cquam_decoder = Some(CquamDemod::new(self.audio_sample_rate));
|
self.sam_decoder = Some(SamDemod::new(self.audio_sample_rate));
|
||||||
} else {
|
} else {
|
||||||
self.cquam_decoder = None;
|
self.sam_decoder = None;
|
||||||
}
|
}
|
||||||
self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate);
|
self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate);
|
||||||
self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
|
self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
|
||||||
@@ -488,8 +488,8 @@ impl ChannelDsp {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
cquam_decoder: if *mode == RigMode::AMC {
|
sam_decoder: if *mode == RigMode::SAM {
|
||||||
Some(CquamDemod::new(audio_sample_rate))
|
Some(SamDemod::new(audio_sample_rate))
|
||||||
} else {
|
} else {
|
||||||
None
|
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) {
|
pub fn set_wfm_denoise(&mut self, level: WfmDenoiseLevel) {
|
||||||
self.wfm_denoise = level;
|
self.wfm_denoise = level;
|
||||||
if let Some(decoder) = &mut self.wfm_decoder {
|
if let Some(decoder) = &mut self.wfm_decoder {
|
||||||
@@ -709,7 +721,7 @@ impl ChannelDsp {
|
|||||||
*sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0);
|
*sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0);
|
||||||
}
|
}
|
||||||
out
|
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);
|
let stereo = decoder.demodulate_stereo(decimated);
|
||||||
// Apply stereo-aware AGC (shared gain preserves L/R balance).
|
// Apply stereo-aware AGC (shared gain preserves L/R balance).
|
||||||
let mut out = Vec::with_capacity(stereo.len());
|
let mut out = Vec::with_capacity(stereo.len());
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ pub struct SoapySdrRig {
|
|||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
/// Whether WFM stereo denoise is enabled.
|
/// Whether WFM stereo denoise is enabled.
|
||||||
wfm_denoise: WfmDenoiseLevel,
|
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.
|
/// Requested hardware gain setting in dB.
|
||||||
gain_db: f64,
|
gain_db: f64,
|
||||||
/// Optional hard ceiling for the applied hardware gain in dB.
|
/// Optional hard ceiling for the applied hardware gain in dB.
|
||||||
@@ -77,7 +81,7 @@ impl SoapySdrRig {
|
|||||||
RigMode::PKT | RigMode::AIS => 25_000,
|
RigMode::PKT | RigMode::AIS => 25_000,
|
||||||
RigMode::VDES => 100_000,
|
RigMode::VDES => 100_000,
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::AM | RigMode::AMC => 9_000,
|
RigMode::AM | RigMode::SAM => 9_000,
|
||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::Other(_) => 3_000,
|
RigMode::Other(_) => 3_000,
|
||||||
@@ -251,7 +255,7 @@ impl SoapySdrRig {
|
|||||||
RigMode::CW,
|
RigMode::CW,
|
||||||
RigMode::CWR,
|
RigMode::CWR,
|
||||||
RigMode::AM,
|
RigMode::AM,
|
||||||
RigMode::AMC,
|
RigMode::SAM,
|
||||||
RigMode::WFM,
|
RigMode::WFM,
|
||||||
RigMode::FM,
|
RigMode::FM,
|
||||||
RigMode::AIS,
|
RigMode::AIS,
|
||||||
@@ -311,6 +315,8 @@ impl SoapySdrRig {
|
|||||||
wfm_deemphasis_us,
|
wfm_deemphasis_us,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_denoise: WfmDenoiseLevel::Auto,
|
wfm_denoise: WfmDenoiseLevel::Auto,
|
||||||
|
sam_stereo_width: 1.0,
|
||||||
|
sam_carrier_sync: true,
|
||||||
gain_db,
|
gain_db,
|
||||||
max_gain_db,
|
max_gain_db,
|
||||||
lna_gain_db: initial_lna_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> {
|
fn filter_state(&self) -> Option<RigFilterState> {
|
||||||
let wfm_stereo_detected = self
|
let wfm_stereo_detected = self
|
||||||
.pipeline
|
.pipeline
|
||||||
@@ -883,6 +921,8 @@ impl RigSdr for SoapySdrRig {
|
|||||||
wfm_stereo: self.wfm_stereo,
|
wfm_stereo: self.wfm_stereo,
|
||||||
wfm_stereo_detected,
|
wfm_stereo_detected,
|
||||||
wfm_denoise: self.wfm_denoise,
|
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 {
|
match mode {
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
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::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::PKT | RigMode::AIS => 25_000,
|
RigMode::PKT | RigMode::AIS => 25_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user