//! 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 = (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()); } }