[feat](trx-rs): show live wfm stereo detect state
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -1265,6 +1265,12 @@ function render(update) {
|
|||||||
wfmDenoiseBtn.style.borderColor = on ? "" : "var(--accent-warn, #f0a500)";
|
wfmDenoiseBtn.style.borderColor = on ? "" : "var(--accent-warn, #f0a500)";
|
||||||
wfmDenoiseBtn.style.color = on ? "" : "var(--accent-warn, #f0a500)";
|
wfmDenoiseBtn.style.color = on ? "" : "var(--accent-warn, #f0a500)";
|
||||||
}
|
}
|
||||||
|
if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") {
|
||||||
|
const detected = update.filter.wfm_stereo_detected;
|
||||||
|
wfmStFlagEl.textContent = detected ? "ST" : "MO";
|
||||||
|
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
||||||
|
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
||||||
applyLocalTunedFrequency(update.status.freq.hz, true);
|
applyLocalTunedFrequency(update.status.freq.hz, true);
|
||||||
@@ -2559,6 +2565,7 @@ const audioRow = document.getElementById("audio-row");
|
|||||||
const wfmControlsCol = document.getElementById("wfm-controls-col");
|
const wfmControlsCol = document.getElementById("wfm-controls-col");
|
||||||
const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
|
const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
|
||||||
const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
|
const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
|
||||||
|
const wfmStFlagEl = document.getElementById("wfm-st-flag");
|
||||||
const wfmDenoiseBtn = document.getElementById("wfm-denoise-btn");
|
const wfmDenoiseBtn = document.getElementById("wfm-denoise-btn");
|
||||||
|
|
||||||
// Hide audio row if audio is not configured on the server
|
// Hide audio row if audio is not configured on the server
|
||||||
|
|||||||
@@ -165,6 +165,9 @@
|
|||||||
<option value="mono">Mono</option>
|
<option value="mono">Mono</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="wfm-control wfm-st-flag-wrap" aria-label="Stereo pilot status">
|
||||||
|
<span id="wfm-st-flag" class="wfm-st-flag wfm-st-flag-mono">MO</span>
|
||||||
|
</label>
|
||||||
<label class="wfm-control">Denoise
|
<label class="wfm-control">Denoise
|
||||||
<button id="wfm-denoise-btn" type="button" class="status-input toggle-btn toggle-on">On</button>
|
<button id="wfm-denoise-btn" type="button" class="status-input toggle-btn toggle-on">On</button>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -192,6 +192,34 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.wfm-st-flag-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wfm-st-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.35rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--input-bg);
|
||||||
|
}
|
||||||
|
.wfm-st-flag-stereo {
|
||||||
|
color: #ff5c5c;
|
||||||
|
border-color: color-mix(in srgb, #ff5c5c 65%, var(--border-light));
|
||||||
|
background: color-mix(in srgb, #ff5c5c 14%, var(--input-bg));
|
||||||
|
}
|
||||||
|
.wfm-st-flag-mono {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
background: color-mix(in srgb, var(--input-bg) 92%, var(--panel-2));
|
||||||
|
}
|
||||||
.controls-col-center::after {
|
.controls-col-center::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ pub struct RigFilterState {
|
|||||||
pub wfm_stereo: bool,
|
pub wfm_stereo: bool,
|
||||||
#[serde(default = "default_wfm_denoise")]
|
#[serde(default = "default_wfm_denoise")]
|
||||||
pub wfm_denoise: bool,
|
pub wfm_denoise: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wfm_stereo_detected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_wfm_deemphasis_us() -> u32 {
|
fn default_wfm_deemphasis_us() -> u32 {
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ mod tests {
|
|||||||
wfm_deemphasis_us: 75,
|
wfm_deemphasis_us: 75,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_denoise: true,
|
wfm_denoise: true,
|
||||||
|
wfm_stereo_detected: false,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
})
|
})
|
||||||
@@ -338,6 +339,7 @@ mod tests {
|
|||||||
wfm_deemphasis_us: 50,
|
wfm_deemphasis_us: 50,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_denoise: true,
|
wfm_denoise: true,
|
||||||
|
wfm_stereo_detected: true,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
};
|
};
|
||||||
@@ -347,6 +349,7 @@ mod tests {
|
|||||||
assert_eq!(f.bandwidth_hz, 12000);
|
assert_eq!(f.bandwidth_hz, 12000);
|
||||||
assert_eq!(f.fir_taps, 128);
|
assert_eq!(f.fir_taps, 128);
|
||||||
assert_eq!(f.wfm_deemphasis_us, 50);
|
assert_eq!(f.wfm_deemphasis_us, 50);
|
||||||
|
assert!(f.wfm_stereo_detected);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
|
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
|
||||||
|
|||||||
@@ -663,6 +663,7 @@ async fn refresh_state_with_retry(
|
|||||||
/// Read current state from the rig via CAT.
|
/// Read current state from the rig via CAT.
|
||||||
async fn refresh_state_from_cat(rig: &mut Box<dyn RigCat>, state: &mut RigState) -> DynResult<()> {
|
async fn refresh_state_from_cat(rig: &mut Box<dyn RigCat>, state: &mut RigState) -> DynResult<()> {
|
||||||
let (freq, mode, vfo) = rig.get_status().await?;
|
let (freq, mode, vfo) = rig.get_status().await?;
|
||||||
|
state.filter = rig.filter_state();
|
||||||
state.control.enabled = Some(true);
|
state.control.enabled = Some(true);
|
||||||
state.apply_freq(freq);
|
state.apply_freq(freq);
|
||||||
state.apply_mode(mode);
|
state.apply_mode(mode);
|
||||||
|
|||||||
@@ -405,6 +405,10 @@ pub struct WfmStereoDecoder {
|
|||||||
diff_denoise: MultibandStereoBlend,
|
diff_denoise: MultibandStereoBlend,
|
||||||
/// Whether multiband stereo denoising is active.
|
/// Whether multiband stereo denoising is active.
|
||||||
denoise_enabled: bool,
|
denoise_enabled: bool,
|
||||||
|
/// Smoothed pilot-derived stereo detection strength in [0, 1].
|
||||||
|
stereo_detect_level: f32,
|
||||||
|
/// Hysteretic pilot-lock result used by the UI.
|
||||||
|
stereo_detected: bool,
|
||||||
/// FM discriminator gain normalization.
|
/// FM discriminator gain normalization.
|
||||||
///
|
///
|
||||||
/// `demod_fm` outputs `atan2(…)/π ≈ 2·Δf/fs` for small deviations.
|
/// `demod_fm` outputs `atan2(…)/π ≈ 2·Δf/fs` for small deviations.
|
||||||
@@ -471,6 +475,8 @@ impl WfmStereoDecoder {
|
|||||||
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
diff_denoise: MultibandStereoBlend::new(audio_rate.max(1) as f32),
|
diff_denoise: MultibandStereoBlend::new(audio_rate.max(1) as f32),
|
||||||
denoise_enabled,
|
denoise_enabled,
|
||||||
|
stereo_detect_level: 0.0,
|
||||||
|
stereo_detected: false,
|
||||||
fm_gain: composite_rate_f / (2.0 * 75_000.0),
|
fm_gain: composite_rate_f / (2.0 * 75_000.0),
|
||||||
sum_hist: [0.0; 4],
|
sum_hist: [0.0; 4],
|
||||||
diff_hist: [0.0; 4],
|
diff_hist: [0.0; 4],
|
||||||
@@ -509,6 +515,19 @@ impl WfmStereoDecoder {
|
|||||||
|
|
||||||
let pilot_mag = (i * i + q * q).sqrt().max(pilot_tone.abs());
|
let pilot_mag = (i * i + q * q).sqrt().max(pilot_tone.abs());
|
||||||
let stereo_blend = (pilot_mag * 40.0).clamp(0.0, 1.0);
|
let stereo_blend = (pilot_mag * 40.0).clamp(0.0, 1.0);
|
||||||
|
let detect_coeff = if stereo_blend > self.stereo_detect_level {
|
||||||
|
0.0008
|
||||||
|
} else {
|
||||||
|
0.0002
|
||||||
|
};
|
||||||
|
self.stereo_detect_level += detect_coeff * (stereo_blend - self.stereo_detect_level);
|
||||||
|
if self.stereo_detected {
|
||||||
|
if self.stereo_detect_level < 0.35 {
|
||||||
|
self.stereo_detected = false;
|
||||||
|
}
|
||||||
|
} else if self.stereo_detect_level > 0.6 {
|
||||||
|
self.stereo_detected = true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- RDS ---
|
// --- RDS ---
|
||||||
let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0);
|
let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0);
|
||||||
@@ -619,6 +638,10 @@ impl WfmStereoDecoder {
|
|||||||
pub fn reset_rds(&mut self) {
|
pub fn reset_rds(&mut self) {
|
||||||
self.rds_decoder.reset();
|
self.rds_decoder.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stereo_detected(&self) -> bool {
|
||||||
|
self.stereo_detected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects the demodulation algorithm for a channel.
|
/// Selects the demodulation algorithm for a channel.
|
||||||
|
|||||||
@@ -568,6 +568,13 @@ impl ChannelDsp {
|
|||||||
self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data)
|
self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn wfm_stereo_detected(&self) -> bool {
|
||||||
|
self.wfm_decoder
|
||||||
|
.as_ref()
|
||||||
|
.map(WfmStereoDecoder::stereo_detected)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reset_rds(&mut self) {
|
pub fn reset_rds(&mut self) {
|
||||||
if let Some(decoder) = &mut self.wfm_decoder {
|
if let Some(decoder) = &mut self.wfm_decoder {
|
||||||
decoder.reset_rds();
|
decoder.reset_rds();
|
||||||
|
|||||||
@@ -495,6 +495,12 @@ impl RigCat for SoapySdrRig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn filter_state(&self) -> Option<RigFilterState> {
|
fn filter_state(&self) -> Option<RigFilterState> {
|
||||||
|
let wfm_stereo_detected = self
|
||||||
|
.pipeline
|
||||||
|
.channel_dsps
|
||||||
|
.get(self.primary_channel_idx)
|
||||||
|
.and_then(|dsp| dsp.lock().ok().map(|d| d.wfm_stereo_detected()))
|
||||||
|
.unwrap_or(false);
|
||||||
Some(RigFilterState {
|
Some(RigFilterState {
|
||||||
bandwidth_hz: self.bandwidth_hz,
|
bandwidth_hz: self.bandwidth_hz,
|
||||||
fir_taps: self.fir_taps,
|
fir_taps: self.fir_taps,
|
||||||
@@ -502,6 +508,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
||||||
wfm_stereo: self.wfm_stereo,
|
wfm_stereo: self.wfm_stereo,
|
||||||
wfm_denoise: self.wfm_denoise,
|
wfm_denoise: self.wfm_denoise,
|
||||||
|
wfm_stereo_detected,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user