Skip to content

feat: add StreamingResampler#25

Merged
wavekat-eason merged 3 commits into
mainfrom
feat/streaming-resampler
May 14, 2026
Merged

feat: add StreamingResampler#25
wavekat-eason merged 3 commits into
mainfrom
feat/streaming-resampler

Conversation

@wavekat-eason
Copy link
Copy Markdown
Contributor

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

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

  • cargo test --features resample — 53 unit tests + 5 doctests pass.
  • 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.
  • cargo clippy --workspace --all-features -- -D warnings clean.
  • 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

AudioFrame::resample builds a fresh stateless rubato per call. For
real-time consumers that feed it short frames in sequence (20 ms G.711
packets off an RTP socket are the motivating case), each call re-pays
the sinc reconstruction's edge cost: it sees an abrupt step from zero
to the chunk's first sample, smears the result, and produces audible
boundary artifacts at the frame rate (50 Hz for 20 ms packets). Real
playback of resampled telephony audio sounds like continuous noise/buzz
over the voice.

StreamingResampler builds rubato once with `new(source, target,
chunk_size)` and reuses its internal filter state for every `process`
call. State carries across calls, so the only edge cost is paid once at
the start of the stream.

The sinc parameters and rubato construction are factored into a
`build_sinc_resampler` helper shared with `AudioFrame::resample`, so
the two paths can't drift on quality settings.

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!

wavekat-eason and others added 2 commits May 14, 2026 18:21
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wavekat-eason wavekat-eason merged commit bf9b6cf into main May 14, 2026
3 checks passed
@wavekat-eason wavekat-eason deleted the feat/streaming-resampler branch May 14, 2026 06:35
@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.10 -> 0.0.11 (✓ API compatible changes)

<details><summary><i><b>Changelog</b></i></summary><p>

<blockquote>

##
[0.0.11](v0.0.10...v0.0.11)
- 2026-05-14

### Added

- add StreamingResampler
([#25](#25))
</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>
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