Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions src/conversions/sample_rate/rubato.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,19 +193,41 @@ impl<I: Source, R: rubato::Resampler<Sample>> RubatoResample<I, R> {
}

fn fill_input_buffer(&mut self, needed_by_resampler: InFrameCount) -> InFrameCount {
let current_span_length = self.input.current_span_len().map(InSamples);
let frames_to_take = needed_by_resampler
.samples(self.output.channels)
.min(current_span_length.unwrap_or(InSamples::MAX));

let wanted = needed_by_resampler.samples(self.output.channels);
self.input_buffer.clear();
for _ in 0..frames_to_take.raw() {
if let Some(sample) = self.input.next() {
self.input_buffer.push(sample);
} else {

// Keep pulling across span boundaries while the format is unchanged.
// A span boundary only matters to the resampler when the sample rate
// or channel count actually changes; ending a chunk at every boundary
// would flag it `partial_len`, which rubato zero-pads.
while self.input_buffer.len() < wanted {
let span = self.input.current_span_len().map(InSamples);
let take = (wanted - self.input_buffer.len()).min(span.unwrap_or(InSamples::MAX));
if take == InSamples::ZERO {
break;
}

let rate_before = self.input.sample_rate();
let channels_before = self.input.channels();

let mut taken = 0usize;
for _ in 0..take.raw() {
if let Some(sample) = self.input.next() {
self.input_buffer.push(sample);
taken += 1;
} else {
break;
}
}

if taken == 0
|| self.input.sample_rate() != rate_before
|| self.input.channels() != channels_before
{
break;
}
}

self.input_buffer.len().frames(self.output.channels)
}

Expand Down
64 changes: 64 additions & 0 deletions src/conversions/sample_rate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,67 @@ fn test_span_boundary_same_format() {
output.len()
);
}

/// Resampling must not depend on how the input is chunked into spans.
///
/// Decoders report one span per decoded packet (Vorbis packets are commonly
/// 128–2048 frames, switching to short blocks around transients), so a single
/// continuous stream arrives as many small spans. If a span boundary ends a
/// resampler chunk, the chunk is flagged `partial_len` and zero-padded,
/// injecting a discontinuity at every boundary.
#[test]
fn small_spans_do_not_degrade_resampled_signal() {
let source_rate = SampleRate::new(48000).unwrap();
let target_rate = SampleRate::new(44100).unwrap();
let channels = ChannelCount::new(2).unwrap();

// 0.5 s of a 440 Hz stereo sine at 48 kHz.
let signal: Vec<Sample> = (0..24_000)
.flat_map(|i| {
let t = i as Sample / source_rate.get() as Sample;
let s = (std::f64::consts::TAU as Sample * 440.0 * t).sin() * 0.5;
[s, s]
})
.collect();

// Fraction of output energy sitting at the 440 Hz fundamental, via
// Goertzel on the left channel. A clean resample keeps this ~1.0.
let tone_energy_fraction = |samples: &[Sample]| -> f64 {
// Accumulate in f64 regardless of `Sample`: the cast is a no-op
// under the `64bit` feature but needed without it.
#[allow(clippy::unnecessary_cast)]
let left: Vec<f64> = samples.iter().step_by(2).map(|&s| s as f64).collect();
let n = left.len();
let bin = 440.0 * n as f64 / target_rate.get() as f64;
let w = std::f64::consts::TAU * bin / n as f64;
let (mut prev, mut prev2) = (0.0, 0.0);
for &s in &left {
let current = s + 2.0 * w.cos() * prev - prev2;
prev2 = prev;
prev = current;
}
let tone = prev * prev + prev2 * prev2 - 2.0 * w.cos() * prev * prev2;
let total: f64 = left.iter().map(|s| s * s).sum();
(tone / (n as f64 / 2.0)) / total.max(f64::EPSILON)
};

for frames_per_span in [4096usize, 2048, 1024, 512, 256, 128] {
let span = frames_per_span * channels.get() as usize;
let mut chunks = signal.chunks(span);
let mut source = TestSource::new(chunks.next().unwrap().to_vec(), source_rate, channels);
for chunk in chunks {
source = source.chain(chunk.to_vec(), source_rate, channels);
}

let output: Vec<Sample> =
SampleRateConverter::new(source, target_rate, ResampleConfig::poly().build()).collect();
let fraction = tone_energy_fraction(&output);

assert!(
fraction > 0.99,
"{frames_per_span}-frame spans: only {:.1}% of output energy at the \
fundamental (expected >99%) — span boundaries are corrupting the signal",
fraction * 100.0
);
}
}
Loading