[fix](trx-frontend-http,trx-backend-soapysdr): fix audio rate and stereo copy

JS: fix stereo AudioData channel copy — frame.copyTo with planeIndex:0
only fills the left plane; reading it as interleaved data caused every
other sample to be zero, making WFM stereo play at half speed.  Now
calls copyTo per channel with the correct planeIndex.

Rust WFM: replace integer output_decim/output_counter in WfmStereoDecoder
with a fractional phase accumulator (output_phase_inc = audio_rate /
composite_rate).  Integer division caused the effective output rate to
drift from audio_sample_rate when the SDR rate is not an exact multiple
(e.g. 2 MHz SDR → 250 kHz composite → ~50 kHz output instead of 48 kHz,
making audio play 4% slow).

Rust non-WFM: add resample_phase/resample_phase_inc to ChannelDsp and
use a fractional-phase resampler in process_block for non-WFM paths,
ensuring exactly audio_sample_rate samples/sec regardless of SDR rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 07:57:42 +01:00
parent 7fa4f5d133
commit 6a47fb00ad
3 changed files with 69 additions and 29 deletions
@@ -2504,8 +2504,6 @@ function startRxAudio() {
const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000; const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
opusDecoder = new AudioDecoder({ opusDecoder = new AudioDecoder({
output: (frame) => { output: (frame) => {
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
frame.copyTo(buf, { planeIndex: 0 });
const forceMono = frame.numberOfChannels >= 2 const forceMono = frame.numberOfChannels >= 2
&& wfmAudioModeEl && wfmAudioModeEl
&& wfmAudioModeEl.value === "mono" && wfmAudioModeEl.value === "mono"
@@ -2514,21 +2512,21 @@ function startRxAudio() {
const outChannels = forceMono ? 1 : frame.numberOfChannels; const outChannels = forceMono ? 1 : frame.numberOfChannels;
const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate); const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate);
if (forceMono) { if (forceMono) {
// Mix all planes down to mono
const monoData = new Float32Array(frame.numberOfFrames); 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++) { for (let ch = 0; ch < frame.numberOfChannels; ch++) {
sum += buf[i * frame.numberOfChannels + ch]; const plane = new Float32Array(frame.numberOfFrames);
} frame.copyTo(plane, { planeIndex: ch });
monoData[i] = sum / frame.numberOfChannels; 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); ab.copyToChannel(monoData, 0);
} else { } else {
// Copy each plane directly — AudioData uses planar layout (f32-planar)
for (let ch = 0; ch < frame.numberOfChannels; ch++) { for (let ch = 0; ch < frame.numberOfChannels; ch++) {
const chData = new Float32Array(frame.numberOfFrames); const chData = new Float32Array(frame.numberOfFrames);
for (let i = 0; i < frame.numberOfFrames; i++) { frame.copyTo(chData, { planeIndex: ch });
chData[i] = buf[i * frame.numberOfChannels + ch];
}
ab.copyToChannel(chData, ch); ab.copyToChannel(chData, ch);
} }
} }
@@ -62,8 +62,13 @@ pub struct WfmStereoDecoder {
deemph_m: Deemphasis, deemph_m: Deemphasis,
deemph_l: Deemphasis, deemph_l: Deemphasis,
deemph_r: Deemphasis, deemph_r: Deemphasis,
output_decim: usize, /// Fractional phase increment per composite sample = audio_rate / composite_rate.
output_counter: usize, /// 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 { impl WfmStereoDecoder {
@@ -74,7 +79,7 @@ impl WfmStereoDecoder {
deemphasis_us: u32, deemphasis_us: u32,
) -> Self { ) -> Self {
let composite_rate_f = composite_rate.max(1) as f32; 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; let deemphasis_us = deemphasis_us as f32;
Self { Self {
output_channels: output_channels.max(1), 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_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
deemph_l: 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), deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
output_decim, output_phase_inc,
output_counter: 0, output_phase: 0.0,
} }
} }
@@ -102,7 +107,8 @@ impl WfmStereoDecoder {
let _ = self.rds_decoder.process_samples(&composite); let _ = self.rds_decoder.process_samples(&composite);
let mut output = Vec::with_capacity( 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 { for x in composite {
@@ -121,11 +127,11 @@ impl WfmStereoDecoder {
let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0; let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0;
let diff = self.diff_lp.process(x * stereo_carrier) * stereo_blend; let diff = self.diff_lp.process(x * stereo_carrier) * stereo_blend;
self.output_counter += 1; self.output_phase += self.output_phase_inc;
if self.output_counter < self.output_decim { if self.output_phase < 1.0 {
continue; continue;
} }
self.output_counter = 0; self.output_phase -= 1.0;
if self.output_channels >= 2 { if self.output_channels >= 2 {
let left = self.deemph_l.process((sum + diff) * 0.5).clamp(-1.0, 1.0); let left = self.deemph_l.process((sum + diff) * 0.5).clamp(-1.0, 1.0);
@@ -282,8 +282,15 @@ pub struct ChannelDsp {
pub mixer_phase: f64, pub mixer_phase: f64,
/// Phase increment per IQ sample. /// Phase increment per IQ sample.
pub mixer_phase_inc: f64, pub mixer_phase_inc: f64,
/// Decimation counter. /// Decimation counter (used only for the WFM path).
decim_counter: usize, 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. /// Dedicated WFM decoder that preserves the FM composite baseband.
wfm_decoder: Option<WfmStereoDecoder>, wfm_decoder: Option<WfmStereoDecoder>,
} }
@@ -335,6 +342,12 @@ impl ChannelDsp {
) )
.0; .0;
self.decim_counter = 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 { self.wfm_decoder = if self.mode == RigMode::WFM {
Some(WfmStereoDecoder::new( Some(WfmStereoDecoder::new(
channel_sample_rate, channel_sample_rate,
@@ -403,6 +416,12 @@ impl ChannelDsp {
mixer_phase: 0.0, mixer_phase: 0.0,
mixer_phase_inc, mixer_phase_inc,
decim_counter: 0, 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 { wfm_decoder: if *mode == RigMode::WFM {
Some(WfmStereoDecoder::new( Some(WfmStereoDecoder::new(
channel_sample_rate, channel_sample_rate,
@@ -496,9 +515,13 @@ impl ChannelDsp {
let filtered_i = self.lpf_i.filter_block(&mixed_i); let filtered_i = self.lpf_i.filter_block(&mixed_i);
let filtered_q = self.lpf_q.filter_block(&mixed_q); let filtered_q = self.lpf_q.filter_block(&mixed_q);
// --- 3. Decimate ---------------------------------------------------- // --- 3. Decimate / resample -----------------------------------------
let capacity = n / self.decim_factor + 1; let capacity = n / self.decim_factor + 1;
let mut decimated: Vec<Complex<f32>> = Vec::with_capacity(capacity); let mut decimated: Vec<Complex<f32>> = Vec::with_capacity(capacity);
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 { for i in 0..n {
self.decim_counter += 1; self.decim_counter += 1;
if self.decim_counter >= self.decim_factor { if self.decim_counter >= self.decim_factor {
@@ -508,6 +531,19 @@ impl ChannelDsp {
decimated.push(Complex::new(fi, fq)); 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));
}
}
}
if decimated.is_empty() { if decimated.is_empty() {
return; return;