The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
314 lines
12 KiB
Rust
314 lines
12 KiB
Rust
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
|
|
//!
|
|
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
|
|
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
|
|
//! channel layout. These convert at the device boundary so such a device plays and
|
|
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
|
|
//!
|
|
//! ## Where each is used
|
|
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
|
|
//! to 48 kHz on the capture drain thread — off the RT callback.
|
|
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
|
|
//! bus to the device rate inside the output RT callback, pulling internal frames
|
|
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
|
|
//!
|
|
//! ## Quality
|
|
//! This is plain linear interpolation with no anti-aliasing filter: correct,
|
|
//! allocation-free, and adequate for speech, but it adds some aliasing when
|
|
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
|
|
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
|
|
//! replace the internals without touching the cpal backend. The matching-rate /
|
|
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
|
|
|
|
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
|
|
#[inline]
|
|
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
|
|
a + (b - a) * frac
|
|
}
|
|
|
|
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
|
|
/// receive output samples at `out_rate` through an `emit` callback. It carries the
|
|
/// fractional read position and the previous input sample across calls, so feeding
|
|
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
|
|
/// [`process`](Self::process) allocates.
|
|
pub struct PushResampler {
|
|
/// Input samples consumed per output sample (`in_rate / out_rate`).
|
|
step: f64,
|
|
/// Position of the next output sample, in input-sample units, measured from the
|
|
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
|
|
/// after each input is consumed.
|
|
next: f64,
|
|
/// The previous input sample (left edge of the current interpolation segment).
|
|
prev: f32,
|
|
/// Whether any input has been seen yet (anchors the first output at input[0]).
|
|
started: bool,
|
|
}
|
|
|
|
impl PushResampler {
|
|
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
|
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
|
|
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
|
|
/// cpal backend's `resolve()` also rejects such rates up front, so this is
|
|
/// belt-and-suspenders against a future caller (review W7).
|
|
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
|
Self {
|
|
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
|
next: 0.0,
|
|
prev: 0.0,
|
|
started: false,
|
|
}
|
|
}
|
|
|
|
/// Feed one input sample; `emit` is called for each output sample produced
|
|
/// (zero or more, depending on the rate ratio).
|
|
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
|
|
if !self.started {
|
|
// First sample: just establish the left edge. Linear interpolation
|
|
// needs the next input as the right edge, so the first output is
|
|
// produced on the next push. This gives exact alignment
|
|
// (`output[k] == input[k]` at equal rates) with one input-sample of
|
|
// latency — negligible (~20 µs at 48 kHz).
|
|
self.started = true;
|
|
self.prev = cur;
|
|
self.next = 0.0;
|
|
return;
|
|
}
|
|
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
|
|
// every output whose position falls in [0, 1).
|
|
while self.next < 1.0 {
|
|
emit(lerp(self.prev, cur, self.next as f32));
|
|
self.next += self.step;
|
|
}
|
|
self.next -= 1.0;
|
|
self.prev = cur;
|
|
}
|
|
|
|
/// Convenience for tests / batch callers: push a whole slice.
|
|
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
|
|
for &s in input {
|
|
self.push(s, &mut emit);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
|
|
/// pulling input frames at `in_rate` from a closure on demand. Call
|
|
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
|
|
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
|
|
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
|
|
/// callback.
|
|
pub struct StereoPullResampler {
|
|
/// Input frames consumed per output frame (`in_rate / out_rate`).
|
|
step: f64,
|
|
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
|
|
frac: f64,
|
|
/// Left edge of the current interpolation segment.
|
|
prev: (f32, f32),
|
|
/// Right edge of the current interpolation segment.
|
|
cur: (f32, f32),
|
|
/// Whether `prev`/`cur` have been primed from the puller yet.
|
|
primed: bool,
|
|
}
|
|
|
|
impl StereoPullResampler {
|
|
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
|
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
|
|
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
|
|
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
|
Self {
|
|
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
|
frac: 0.0,
|
|
prev: (0.0, 0.0),
|
|
cur: (0.0, 0.0),
|
|
primed: false,
|
|
}
|
|
}
|
|
|
|
/// Produce the next output frame, pulling input frames via `pull` as needed.
|
|
/// Returns `None` if `pull` returns `None` before the frame can be formed
|
|
/// (underrun); the caller should substitute silence for that frame.
|
|
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
|
|
if !self.primed {
|
|
// Prime both edges from two pulls so the first output frame aligns
|
|
// exactly with the first input frame (`out[0] == in[0]` at equal
|
|
// rates). Needs two frames available to start, which the prefilled
|
|
// playback ring always has.
|
|
self.prev = pull()?;
|
|
self.cur = pull()?;
|
|
self.primed = true;
|
|
self.frac = 0.0;
|
|
}
|
|
// Advance the segment until the read position lands inside [prev, cur).
|
|
while self.frac >= 1.0 {
|
|
self.prev = self.cur;
|
|
self.cur = pull()?;
|
|
self.frac -= 1.0;
|
|
}
|
|
let f = self.frac as f32;
|
|
let out = (
|
|
lerp(self.prev.0, self.cur.0, f),
|
|
lerp(self.prev.1, self.cur.1, f),
|
|
);
|
|
self.frac += self.step;
|
|
Some(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
|
|
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
|
|
#[test]
|
|
fn push_identity_when_rates_match() {
|
|
let mut r = PushResampler::new(48_000, 48_000);
|
|
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
|
|
let mut out = Vec::new();
|
|
r.process(&input, |s| out.push(s));
|
|
assert_eq!(out.len(), input.len() - 1);
|
|
for (a, b) in out.iter().zip(input.iter()) {
|
|
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
|
|
}
|
|
}
|
|
|
|
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
|
|
#[test]
|
|
fn push_upsample_2x_interpolates_midpoints() {
|
|
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
|
|
let input = [0.0, 1.0, 2.0, 3.0];
|
|
let mut out = Vec::new();
|
|
r.process(&input, |s| out.push(s));
|
|
// (n - 1) segments at 2 outputs each = 6.
|
|
assert_eq!(out.len(), 6, "out {out:?}");
|
|
// A half-step between 1.0 and 2.0 must appear near 1.5.
|
|
assert!(
|
|
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
|
|
"expected a ~1.5 midpoint in {out:?}"
|
|
);
|
|
}
|
|
|
|
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
|
|
#[test]
|
|
fn push_downsample_reduces_count() {
|
|
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
|
|
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
|
|
let mut out = Vec::new();
|
|
r.process(&input, |s| out.push(s));
|
|
// 441 in @ 48k -> ~405 out @ 44.1k.
|
|
assert!(
|
|
(390..=410).contains(&out.len()),
|
|
"expected ~405 outputs, got {}",
|
|
out.len()
|
|
);
|
|
// Output stays within the input's value range and is non-decreasing.
|
|
for w in out.windows(2) {
|
|
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
|
|
}
|
|
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
|
|
}
|
|
|
|
/// Pull resampler at equal rates returns each input frame in order, aligned.
|
|
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
|
|
/// outputs (the last frame emits once a successor arrives).
|
|
#[test]
|
|
fn pull_identity_when_rates_match() {
|
|
let mut r = StereoPullResampler::new(48_000, 48_000);
|
|
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
|
|
let mut idx = 0;
|
|
let mut out = Vec::new();
|
|
while let Some(f) = r.next(|| {
|
|
let v = frames.get(idx).copied();
|
|
idx += 1;
|
|
v
|
|
}) {
|
|
out.push(f);
|
|
}
|
|
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
|
|
for (got, want) in out.iter().zip(frames.iter()) {
|
|
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
|
|
}
|
|
}
|
|
|
|
/// Pull resampler reports underrun (`None`) once the source is exhausted.
|
|
#[test]
|
|
fn pull_returns_none_on_underrun() {
|
|
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
|
|
let frames = [(0.0, 0.0), (1.0, -1.0)];
|
|
let mut idx = 0;
|
|
let mut pull = || {
|
|
let v = frames.get(idx).copied();
|
|
idx += 1;
|
|
v
|
|
};
|
|
// First frame primes + emits; subsequent calls eventually exhaust the source.
|
|
let mut produced = 0;
|
|
let mut hit_none = false;
|
|
for _ in 0..10 {
|
|
if r.next(&mut pull).is_some() {
|
|
produced += 1;
|
|
} else {
|
|
hit_none = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(produced >= 1, "should produce at least the primed frame");
|
|
assert!(hit_none, "should report underrun once the puller is dry");
|
|
}
|
|
|
|
/// Downsampling via pull consumes more input frames than it emits output frames.
|
|
#[test]
|
|
fn pull_downsample_consumes_more_than_it_emits() {
|
|
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
|
|
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
|
|
let mut idx = 0;
|
|
let mut emitted = 0;
|
|
for _ in 0..40 {
|
|
let f = r.next(|| {
|
|
let v = input.get(idx).copied();
|
|
idx += 1;
|
|
v
|
|
});
|
|
if f.is_some() {
|
|
emitted += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// At step 2.0 we consume ~2 input frames per output frame.
|
|
assert!(
|
|
idx > emitted,
|
|
"consumed {idx} input, emitted {emitted} output"
|
|
);
|
|
}
|
|
|
|
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
|
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
|
|
#[test]
|
|
fn push_zero_rate_does_not_spin() {
|
|
let mut r = PushResampler::new(0, 48_000);
|
|
let mut count = 0usize;
|
|
// Feed two samples; with a clamped non-zero step this returns promptly.
|
|
r.push(0.0, |_| count += 1);
|
|
r.push(1.0, |_| count += 1);
|
|
// Reaching here at all is the assertion (no hang); some output is produced.
|
|
assert!(count >= 1);
|
|
}
|
|
|
|
/// A zero output rate must not make the pull resampler's segment-advance loop
|
|
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
|
|
#[test]
|
|
fn pull_zero_out_rate_does_not_spin() {
|
|
let mut r = StereoPullResampler::new(48_000, 0);
|
|
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
|
|
let mut idx = 0;
|
|
let got = r.next(|| {
|
|
let v = frames.get(idx).copied();
|
|
idx += 1;
|
|
v
|
|
});
|
|
// Terminates and yields the primed frame instead of hanging.
|
|
assert!(got.is_some());
|
|
}
|
|
}
|