Skip to content

fix: resample short inputs when upsampling#23

Merged
wavekat-eason merged 2 commits into
mainfrom
fix/resample-short-input
May 14, 2026
Merged

fix: resample short inputs when upsampling#23
wavekat-eason merged 2 commits into
mainfrom
fix/resample-short-input

Conversation

@wavekat-eason
Copy link
Copy Markdown
Contributor

Summary

  • AudioFrame::resample returned InsufficientOutputBufferSize for 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 case wavekat-voice's RTP receive path hits.
  • Switched the output-buffer sizing to rubato's own 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.
  • Added regression tests for the three failure shapes called out in 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 --check
  • cargo clippy --workspace --all-features -- -D warnings

🤖 Generated with Claude Code

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>
@sentry
Copy link
Copy Markdown

sentry Bot commented May 14, 2026

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>
@wavekat-eason wavekat-eason merged commit bd545f8 into main May 14, 2026
3 checks passed
@wavekat-eason wavekat-eason deleted the fix/resample-short-input branch May 14, 2026 05:16
@github-actions github-actions Bot mentioned this pull request May 14, 2026
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>
@wavekat-eason wavekat-eason mentioned this pull request May 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant