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 159a737..c22c467 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 @@ -2504,8 +2504,6 @@ function startRxAudio() { const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000; opusDecoder = new AudioDecoder({ output: (frame) => { - const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels); - frame.copyTo(buf, { planeIndex: 0 }); const forceMono = frame.numberOfChannels >= 2 && wfmAudioModeEl && wfmAudioModeEl.value === "mono" @@ -2514,21 +2512,21 @@ function startRxAudio() { const outChannels = forceMono ? 1 : frame.numberOfChannels; const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate); if (forceMono) { + // Mix all planes down to mono const monoData = new Float32Array(frame.numberOfFrames); - for (let i = 0; i < frame.numberOfFrames; i++) { - let sum = 0; - for (let ch = 0; ch < frame.numberOfChannels; ch++) { - sum += buf[i * frame.numberOfChannels + ch]; - } - monoData[i] = sum / frame.numberOfChannels; + for (let ch = 0; ch < frame.numberOfChannels; ch++) { + const plane = new Float32Array(frame.numberOfFrames); + frame.copyTo(plane, { planeIndex: ch }); + for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] += plane[i]; } + const inv = 1 / frame.numberOfChannels; + for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] *= inv; ab.copyToChannel(monoData, 0); } else { + // Copy each plane directly — AudioData uses planar layout (f32-planar) for (let ch = 0; ch < frame.numberOfChannels; ch++) { const chData = new Float32Array(frame.numberOfFrames); - for (let i = 0; i < frame.numberOfFrames; i++) { - chData[i] = buf[i * frame.numberOfChannels + ch]; - } + frame.copyTo(chData, { planeIndex: ch }); ab.copyToChannel(chData, ch); } } 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 7aa22a5..8f1a51c 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 @@ -62,8 +62,13 @@ pub struct WfmStereoDecoder { deemph_m: Deemphasis, deemph_l: Deemphasis, deemph_r: Deemphasis, - output_decim: usize, - output_counter: usize, + /// Fractional phase increment per composite sample = audio_rate / composite_rate. + /// Avoids integer-division rate error when composite_rate is not an exact + /// multiple of audio_rate (e.g. 250 kHz composite → 48 kHz audio). + output_phase_inc: f64, + /// Fractional phase accumulator (0 .. 1). Emits an output sample whenever + /// it crosses 1.0, ensuring the long-term rate is exactly audio_rate. + output_phase: f64, } impl WfmStereoDecoder { @@ -74,7 +79,7 @@ impl WfmStereoDecoder { deemphasis_us: u32, ) -> Self { let composite_rate_f = composite_rate.max(1) as f32; - let output_decim = (composite_rate / audio_rate.max(1)).max(1) as usize; + let output_phase_inc = audio_rate.max(1) as f64 / composite_rate.max(1) as f64; let deemphasis_us = deemphasis_us as f32; Self { output_channels: output_channels.max(1), @@ -89,8 +94,8 @@ impl WfmStereoDecoder { deemph_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us), deemph_l: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us), deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us), - output_decim, - output_counter: 0, + output_phase_inc, + output_phase: 0.0, } } @@ -102,7 +107,8 @@ impl WfmStereoDecoder { let _ = self.rds_decoder.process_samples(&composite); let mut output = Vec::with_capacity( - (composite.len() / self.output_decim.max(1)) * self.output_channels.max(1), + ((composite.len() as f64 * self.output_phase_inc).ceil() as usize + 1) + * self.output_channels.max(1), ); for x in composite { @@ -121,11 +127,11 @@ impl WfmStereoDecoder { let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0; let diff = self.diff_lp.process(x * stereo_carrier) * stereo_blend; - self.output_counter += 1; - if self.output_counter < self.output_decim { + self.output_phase += self.output_phase_inc; + if self.output_phase < 1.0 { continue; } - self.output_counter = 0; + self.output_phase -= 1.0; if self.output_channels >= 2 { let left = self.deemph_l.process((sum + diff) * 0.5).clamp(-1.0, 1.0); 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 1ea9a1e..9709a9a 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 @@ -282,8 +282,15 @@ pub struct ChannelDsp { pub mixer_phase: f64, /// Phase increment per IQ sample. pub mixer_phase_inc: f64, - /// Decimation counter. + /// Decimation counter (used only for the WFM path). decim_counter: usize, + /// Fractional-phase resampler for non-WFM paths. + /// Advances by `resample_phase_inc` per IQ sample and emits an output + /// sample whenever it crosses 1.0, giving a long-term output rate of + /// exactly `audio_sample_rate` regardless of SDR rate rounding. + resample_phase: f64, + /// `audio_sample_rate / sdr_sample_rate` (updated in `rebuild_filters`). + resample_phase_inc: f64, /// Dedicated WFM decoder that preserves the FM composite baseband. wfm_decoder: Option, } @@ -335,6 +342,12 @@ impl ChannelDsp { ) .0; self.decim_counter = 0; + self.resample_phase = 0.0; + self.resample_phase_inc = if self.sdr_sample_rate == 0 { + 1.0 + } else { + self.audio_sample_rate as f64 / self.sdr_sample_rate as f64 + }; self.wfm_decoder = if self.mode == RigMode::WFM { Some(WfmStereoDecoder::new( channel_sample_rate, @@ -403,6 +416,12 @@ impl ChannelDsp { mixer_phase: 0.0, mixer_phase_inc, decim_counter: 0, + resample_phase: 0.0, + resample_phase_inc: if sdr_sample_rate == 0 { + 1.0 + } else { + audio_sample_rate as f64 / sdr_sample_rate as f64 + }, wfm_decoder: if *mode == RigMode::WFM { Some(WfmStereoDecoder::new( channel_sample_rate, @@ -496,16 +515,33 @@ impl ChannelDsp { let filtered_i = self.lpf_i.filter_block(&mixed_i); let filtered_q = self.lpf_q.filter_block(&mixed_q); - // --- 3. Decimate ---------------------------------------------------- + // --- 3. Decimate / resample ----------------------------------------- let capacity = n / self.decim_factor + 1; let mut decimated: Vec> = Vec::with_capacity(capacity); - for i in 0..n { - self.decim_counter += 1; - if self.decim_counter >= self.decim_factor { - self.decim_counter = 0; - let fi = filtered_i.get(i).copied().unwrap_or(0.0); - let fq = filtered_q.get(i).copied().unwrap_or(0.0); - decimated.push(Complex::new(fi, fq)); + if self.wfm_decoder.is_some() { + // WFM: integer decimation preserves the FM composite signal at the + // rate expected by WfmStereoDecoder. Final rate correction is done + // inside WfmStereoDecoder via its own fractional-phase accumulator. + for i in 0..n { + self.decim_counter += 1; + if self.decim_counter >= self.decim_factor { + self.decim_counter = 0; + let fi = filtered_i.get(i).copied().unwrap_or(0.0); + let fq = filtered_q.get(i).copied().unwrap_or(0.0); + decimated.push(Complex::new(fi, fq)); + } + } + } else { + // Non-WFM: fractional-phase resampler so the long-term output rate + // equals exactly `audio_sample_rate` regardless of SDR rate rounding. + for i in 0..n { + self.resample_phase += self.resample_phase_inc; + if self.resample_phase >= 1.0 { + self.resample_phase -= 1.0; + let fi = filtered_i.get(i).copied().unwrap_or(0.0); + let fq = filtered_q.get(i).copied().unwrap_or(0.0); + decimated.push(Complex::new(fi, fq)); + } } }