[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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user