fix: resample short inputs when upsampling#23
Merged
Conversation
AudioFrame::resample failed for any input shorter than rubato's chunk size when upsampling (e.g. a 160-sample G.711 frame to 44.1 kHz) because the output buffer was sized by input-times-ratio while rubato sizes its output by chunk-times-ratio. Switch to rubato's process_all_needed_output_len helper and match the chunk size to short inputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Move resampled.len() into a local before the assert! so the value is evaluated on the happy path, not only inside the panic-message format args. Lets codecov see the new test lines as covered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
wavekat-eason
pushed a commit
that referenced
this pull request
May 14, 2026
## 🤖 New release * `wavekat-core`: 0.0.9 -> 0.0.10 (✓ API compatible changes) <details><summary><i><b>Changelog</b></i></summary><p> <blockquote> ## [0.0.10](v0.0.9...v0.0.10) - 2026-05-14 ### Fixed - resample short inputs when upsampling ([#23](#23)) </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4 tasks
wavekat-eason
added a commit
that referenced
this pull request
May 14, 2026
## Summary Adds `StreamingResampler` — a stateful resampler for real-time pipelines that feed short frames in sequence. `AudioFrame::resample` is fine for one-shot conversions, but it constructs a fresh rubato per call. A consumer that calls it on every 20 ms G.711 packet pays the sinc reconstruction's edge cost on every chunk — the resampler sees an abrupt step from zero to the chunk's first sample and produces audible boundary artifacts at the frame rate (50 Hz for 20 ms packets). On a real playback path the user hears continuous noise/buzz over the voice. This bit `wavekat-voice`'s M1 media work after the short-input fix from #23 made `resample` itself succeed. `StreamingResampler::new(source_rate, target_rate, chunk_size)` builds rubato once. `process` accumulates input until it has `chunk_size`, runs one rubato step, and emits output. Internal filter state is preserved across calls. Also factors the sinc parameters + builder out into a private `build_sinc_resampler` helper shared with `AudioFrame::resample`, so the two paths can't drift on quality settings. ## API ```rust use wavekat_core::StreamingResampler; let mut r = StreamingResampler::new(8000, 44100, 160)?; // chunk = 1 G.711 frame let mut out = Vec::new(); for packet in incoming_rtp_packets { let samples: Vec<f32> = /* G.711 decode */; r.process(&samples, &mut out)?; // `out` now has device-rate samples ready for the audio sink } ``` Pass-through fast path when `source_rate == target_rate`: no resampler is built, `process` is a copy. ## Test plan - [x] `cargo test --features resample` — 53 unit tests + 5 doctests pass. - [x] 6 new `StreamingResampler` tests: - `same_rate_is_passthrough` — proves the no-op fast path doesn't go through rubato. - `short_input_chunked_calls` — 10× 160-sample inputs at 8 kHz → 44.1 kHz produce roughly 10 × 882 output samples (the exact RTP receive shape). - `buffers_across_partial_calls` — splitting an input across two `process` calls produces sample-for-sample-identical output to one big call. - `avoids_per_frame_edge_artifacts` — the regression guard: a smooth 600 Hz sine, resampled chunk-by-chunk with `StreamingResampler` vs `AudioFrame::resample`. The streaming output's max consecutive-sample delta stays inside the expected smooth bound (under 10 spikes in steady state); the stateless per-chunk output has 5×+ more spikes because of the chunk-boundary transients. - `rejects_zero_rate` / `rejects_zero_chunk_size` — input validation. - [x] `cargo clippy --workspace --all-features -- -D warnings` clean. - [x] `cargo fmt --all --check` clean. ## Downstream `wavekat-voice`'s `LocalDeviceSink` will switch from per-frame `AudioFrame::resample` to `StreamingResampler` once this lands and bumps to 0.0.11. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AudioFrame::resamplereturnedInsufficientOutputBufferSizefor any input shorter than rubato's internal chunk size when upsampling — e.g. a 160-sample G.711 frame to 44.1 kHz, the exact casewavekat-voice's RTP receive path hits.process_all_needed_output_len()helper (input-times-ratio underestimates by a full chunk's worth of output) and matched the chunk size to short inputs so we don't pad a 160-sample frame up to 1024.docs/04-resample-short-input-bug.md(8 kHz → 44.1 kHz, 8 kHz → 16 kHz, 8 kHz → 48 kHz) plus the design doc itself.Test plan
cargo test --features resample— 47 passing, including the three new short-input cases and the pre-existing long-input regression guards.cargo fmt --all --checkcargo clippy --workspace --all-features -- -D warnings🤖 Generated with Claude Code