diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 29a9a7b..3900002 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1252,6 +1252,12 @@ function render(update) { if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") { wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us); } + if (wfmDenoiseBtn && typeof update.filter.wfm_denoise === "boolean") { + const on = update.filter.wfm_denoise; + wfmDenoiseBtn.textContent = on ? "On" : "Off"; + wfmDenoiseBtn.style.borderColor = on ? "" : "var(--accent-warn, #f0a500)"; + wfmDenoiseBtn.style.color = on ? "" : "var(--accent-warn, #f0a500)"; + } } if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { applyLocalTunedFrequency(update.status.freq.hz, true); @@ -2546,6 +2552,7 @@ const audioRow = document.getElementById("audio-row"); const wfmControlsCol = document.getElementById("wfm-controls-col"); const wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); const wfmAudioModeEl = document.getElementById("wfm-audio-mode"); +const wfmDenoiseBtn = document.getElementById("wfm-denoise-btn"); // Hide audio row if audio is not configured on the server fetch("/audio", { method: "GET" }).then((r) => { @@ -2587,6 +2594,11 @@ if (wfmDeemphasisEl) { postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {}); }); } +if (wfmDenoiseBtn) { + wfmDenoiseBtn.addEventListener("click", () => { + postPath("/toggle_wfm_denoise").catch(() => {}); + }); +} function updateWfmControls() { if (!wfmControlsCol) return; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 8365a74..192c30b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -165,6 +165,9 @@ +
WFM
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 81ae91e..1cedd09 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -486,6 +486,21 @@ pub async fn set_wfm_deemphasis( send_command(&rig_tx, RigCommand::SetWfmDeemphasis(query.us)).await } +#[post("/toggle_wfm_denoise")] +pub async fn toggle_wfm_denoise( + state: web::Data>, + rig_tx: web::Data>, +) -> Result { + let enabled = state + .get_ref() + .borrow() + .filter + .as_ref() + .map(|f| f.wfm_denoise) + .unwrap_or(true); + send_command(&rig_tx, RigCommand::SetWfmDenoise(!enabled)).await +} + #[post("/toggle_aprs_decode")] pub async fn toggle_aprs_decode( state: web::Data>, @@ -698,6 +713,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(set_bandwidth) .service(set_fir_taps) .service(set_wfm_deemphasis) + .service(toggle_wfm_denoise) .service(toggle_aprs_decode) .service(toggle_cw_decode) .service(set_cw_auto) diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs index f9dde32..fad3b7f 100644 --- a/src/trx-core/src/rig/command.rs +++ b/src/trx-core/src/rig/command.rs @@ -34,5 +34,6 @@ pub enum RigCommand { SetBandwidth(u32), SetFirTaps(u32), SetWfmDeemphasis(u32), + SetWfmDenoise(bool), GetSpectrum, } diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs index 55b1eaa..69d519c 100644 --- a/src/trx-core/src/rig/controller/handlers.rs +++ b/src/trx-core/src/rig/controller/handlers.rs @@ -517,6 +517,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box { | RigCommand::SetBandwidth(_) | RigCommand::SetFirTaps(_) | RigCommand::SetWfmDeemphasis(_) + | RigCommand::SetWfmDenoise(_) | RigCommand::GetSpectrum => Box::new(GetSnapshotCommand), } } diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs index 1d89dc2..efb3686 100644 --- a/src/trx-core/src/rig/mod.rs +++ b/src/trx-core/src/rig/mod.rs @@ -165,6 +165,16 @@ pub trait RigCat: Rig + Send { ))) } + fn set_wfm_denoise<'a>( + &'a mut self, + _enabled: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(std::future::ready(Err( + Box::new(response::RigError::not_supported("set_wfm_denoise")) + as Box, + ))) + } + /// Return the current filter state if this backend supports filter controls. fn filter_state(&self) -> Option { None diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index 1082011..7dfbfbd 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -271,12 +271,18 @@ pub struct RigFilterState { pub cw_center_hz: u32, #[serde(default = "default_wfm_deemphasis_us")] pub wfm_deemphasis_us: u32, + #[serde(default = "default_wfm_denoise")] + pub wfm_denoise: bool, } fn default_wfm_deemphasis_us() -> u32 { 75 } +fn default_wfm_denoise() -> bool { + true +} + /// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpectrumData { diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index cb17699..9fd7daa 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -298,6 +298,7 @@ mod tests { fir_taps: 64, cw_center_hz: 700, wfm_deemphasis_us: 75, + wfm_denoise: true, }), ..minimal_snapshot() }) @@ -334,6 +335,7 @@ mod tests { fir_taps: 128, cw_center_hz: 700, wfm_deemphasis_us: 50, + wfm_denoise: true, }), ..minimal_snapshot() }; diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs index a5df583..357879c 100644 --- a/src/trx-protocol/src/mapping.rs +++ b/src/trx-protocol/src/mapping.rs @@ -51,6 +51,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand { ClientCommand::SetWfmDeemphasis { deemphasis_us } => { RigCommand::SetWfmDeemphasis(deemphasis_us) } + ClientCommand::SetWfmDenoise { enabled } => RigCommand::SetWfmDenoise(enabled), ClientCommand::GetSpectrum => RigCommand::GetSpectrum, } } @@ -95,6 +96,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand { RigCommand::SetWfmDeemphasis(deemphasis_us) => { ClientCommand::SetWfmDeemphasis { deemphasis_us } } + RigCommand::SetWfmDenoise(enabled) => ClientCommand::SetWfmDenoise { enabled }, RigCommand::GetSpectrum => ClientCommand::GetSpectrum, } } diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs index 7a4678c..eb6784e 100644 --- a/src/trx-protocol/src/types.rs +++ b/src/trx-protocol/src/types.rs @@ -39,6 +39,7 @@ pub enum ClientCommand { SetBandwidth { bandwidth_hz: u32 }, SetFirTaps { taps: u32 }, SetWfmDeemphasis { deemphasis_us: u32 }, + SetWfmDenoise { enabled: bool }, GetSpectrum, } diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index bd4bd6e..271cd60 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -462,6 +462,16 @@ async fn process_command( let _ = ctx.state_tx.send(ctx.state.clone()); return snapshot_from(ctx.state); } + RigCommand::SetWfmDenoise(enabled) => { + if let Err(e) = ctx.rig.set_wfm_denoise(enabled).await { + return Err(RigError::communication(format!("set_wfm_denoise: {e}"))); + } + if let Some(f) = ctx.state.filter.as_mut() { + f.wfm_denoise = enabled; + } + let _ = ctx.state_tx.send(ctx.state.clone()); + return snapshot_from(ctx.state); + } RigCommand::SetCenterFreq(freq) => { if let Err(e) = ctx.rig.set_center_freq(freq).await { return Err(RigError::communication(format!("set_center_freq: {e}"))); diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs index 45cf2ab..d2c04da 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs @@ -370,6 +370,8 @@ pub struct WfmStereoDecoder { deemph_r: Deemphasis, /// Multiband stereo blending applied at audio rate to the L-R diff channel. diff_denoise: MultibandStereoBlend, + /// Whether multiband stereo denoising is active. + denoise_enabled: bool, /// Previous filtered sum/diff composite samples used for linear interpolation. prev_sum: f32, /// Unblended L-R diff at the previous composite sample, for interpolation. @@ -391,6 +393,7 @@ impl WfmStereoDecoder { audio_rate: u32, output_channels: usize, deemphasis_us: u32, + denoise_enabled: bool, ) -> Self { let composite_rate_f = composite_rate.max(1) as f32; let output_phase_inc = audio_rate.max(1) as f64 / composite_rate.max(1) as f64; @@ -419,6 +422,7 @@ impl WfmStereoDecoder { deemph_l: 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), + denoise_enabled, prev_sum: 0.0, prev_diff: 0.0, prev_blend: 0.0, @@ -491,11 +495,15 @@ impl WfmStereoDecoder { // --- Deemphasis + DC block + output --- if self.output_channels >= 2 { - // Apply multiband stereo denoising at audio rate. - // Higher frequency bands of the diff are attenuated more aggressively - // when the pilot is weak, reducing stereo noise without collapsing - // the low-frequency stereo image. - let diff_denoised = self.diff_denoise.process(diff_i, blend_i); + // Apply multiband or single-band stereo blend at audio rate. + let diff_denoised = if self.denoise_enabled { + // Multiband: attenuates high-frequency diff more aggressively + // when the pilot is weak, preserving the low-frequency stereo image. + self.diff_denoise.process(diff_i, blend_i) + } else { + // Single-band: uniform blend across all frequencies. + diff_i * blend_i + }; let left = self.dc_l .process(self.deemph_l.process((sum_i + diff_denoised) * 0.5)) .clamp(-1.0, 1.0); @@ -516,6 +524,10 @@ impl WfmStereoDecoder { output } + pub fn set_denoise_enabled(&mut self, enabled: bool) { + self.denoise_enabled = enabled; + } + pub fn rds_data(&self) -> Option { self.rds_decoder.snapshot() } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index f71f116..0a05fb9 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -309,6 +309,8 @@ pub struct ChannelDsp { fir_taps: usize, /// WFM deemphasis time constant in microseconds. wfm_deemphasis_us: u32, + /// Whether multiband stereo denoising is enabled for WFM. + wfm_denoise: bool, /// Decimation factor: `sdr_sample_rate / audio_sample_rate`. pub decim_factor: usize, /// Number of PCM channels emitted in each frame. @@ -409,6 +411,7 @@ impl ChannelDsp { self.audio_sample_rate, self.output_channels, self.wfm_deemphasis_us, + self.wfm_denoise, )); } } else { @@ -429,6 +432,7 @@ impl ChannelDsp { frame_duration_ms: u16, audio_bandwidth_hz: u32, wfm_deemphasis_us: u32, + wfm_denoise: bool, fir_taps: usize, pcm_tx: broadcast::Sender>, ) -> Self { @@ -473,6 +477,7 @@ impl ChannelDsp { audio_bandwidth_hz, fir_taps: taps, wfm_deemphasis_us, + wfm_denoise, decim_factor, output_channels, frame_buf: Vec::with_capacity(frame_size + output_channels), @@ -493,6 +498,7 @@ impl ChannelDsp { audio_sample_rate, output_channels, wfm_deemphasis_us, + wfm_denoise, )) } else { None @@ -538,6 +544,13 @@ impl ChannelDsp { self.rebuild_filters(true); } + pub fn set_wfm_denoise(&mut self, enabled: bool) { + self.wfm_denoise = enabled; + if let Some(decoder) = &mut self.wfm_decoder { + decoder.set_denoise_enabled(enabled); + } + } + pub fn rds_data(&self) -> Option { self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data) } @@ -676,6 +689,7 @@ impl SdrPipeline { output_channels: usize, frame_duration_ms: u16, wfm_deemphasis_us: u32, + wfm_denoise: bool, channels: &[(f64, RigMode, u32, usize)], ) -> Self { const IQ_BROADCAST_CAPACITY: usize = 64; @@ -697,6 +711,7 @@ impl SdrPipeline { frame_duration_ms, audio_bandwidth_hz, wfm_deemphasis_us, + wfm_denoise, fir_taps, pcm_tx.clone(), ); @@ -945,7 +960,7 @@ mod tests { fn channel_dsp_processes_silence() { let (pcm_tx, _pcm_rx) = broadcast::channel::>(8); let mut dsp = - ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx); + ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, true, 31, pcm_tx); let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096]; dsp.process_block(&block); } @@ -954,7 +969,7 @@ mod tests { fn channel_dsp_set_mode() { let (pcm_tx, _) = broadcast::channel::>(8); let mut dsp = - ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx); + ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, true, 31, pcm_tx); assert_eq!(dsp.demodulator, Demodulator::Usb); dsp.set_mode(&RigMode::FM); assert_eq!(dsp.demodulator, Demodulator::Fm); @@ -969,6 +984,7 @@ mod tests { 1, 20, 75, + true, &[(200_000.0, RigMode::USB, 3000, 64)], ); assert_eq!(pipeline.pcm_senders.len(), 1); @@ -977,7 +993,7 @@ mod tests { #[test] fn pipeline_empty_channels() { - let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, 75, &[]); + let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, 75, true, &[]); assert_eq!(pipeline.pcm_senders.len(), 0); assert_eq!(pipeline.channel_dsps.len(), 0); } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 65ae832..edc849f 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -39,6 +39,8 @@ pub struct SoapySdrRig { retune_cmd: Arc>>, /// Current WFM deemphasis setting in microseconds. wfm_deemphasis_us: u32, + /// Whether multiband WFM stereo denoising is enabled. + wfm_denoise: bool, } impl SoapySdrRig { @@ -115,6 +117,7 @@ impl SoapySdrRig { audio_channels, frame_duration_ms, wfm_deemphasis_us, + true, // wfm_denoise: enabled by default channels, ); @@ -182,6 +185,7 @@ impl SoapySdrRig { center_hz: hardware_center_hz, retune_cmd, wfm_deemphasis_us, + wfm_denoise: true, }) } @@ -460,12 +464,26 @@ impl RigCat for SoapySdrRig { }) } + fn set_wfm_denoise<'a>( + &'a mut self, + enabled: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + self.wfm_denoise = enabled; + if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) { + dsp_arc.lock().unwrap().set_wfm_denoise(enabled); + } + Ok(()) + }) + } + fn filter_state(&self) -> Option { Some(RigFilterState { bandwidth_hz: self.bandwidth_hz, fir_taps: self.fir_taps, cw_center_hz: 700, wfm_deemphasis_us: self.wfm_deemphasis_us, + wfm_denoise: self.wfm_denoise, }) }