|
| 1 | +//! The single streaming resampler for audio rate conversion |
| 2 | +//! (PIPECAT_FIX_PLAN C-G5). |
| 3 | +//! |
| 4 | +//! One implementation for every path that converts sample rates — ingress |
| 5 | +//! decode (wire rate → the 16 kHz the VAD/smart-turn models require) and TTS |
| 6 | +//! egress (provider rate → client playback rate) — so rate conversion can |
| 7 | +//! never diverge per call site again. |
| 8 | +//! |
| 9 | +//! Properties (soxr-stream parity via rubato's `SincFixedIn`): |
| 10 | +//! - **Filter history across chunks**: the sinc delay line persists between |
| 11 | +//! calls, so chunk boundaries produce no clicks (the canonical stateless- |
| 12 | +//! per-chunk failure). |
| 13 | +//! - **Lazy init**: the resampler is constructed on the first call that |
| 14 | +//! actually needs work; identity calls never allocate it. |
| 15 | +//! - **Stale-state clear**: a gap > [`CLEAR_AFTER`] since the last call resets |
| 16 | +//! the delay line, so a new utterance doesn't inherit the previous one's |
| 17 | +//! filter tail (another click source). |
| 18 | +//! - **Identity fast path**: `in_rate == out_rate` returns `None` — the |
| 19 | +//! caller uses its input as-is, zero copies, zero state. |
| 20 | +//! |
| 21 | +//! NOT `Sync`: one instance per stream/direction (rubato's `process` needs |
| 22 | +//! `&mut`); never share across concurrent streams. |
| 23 | +
|
| 24 | +use std::time::Instant; |
| 25 | + |
| 26 | +use rubato::{Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction}; |
| 27 | +use tracing::{debug, warn}; |
| 28 | + |
| 29 | +/// Drop stale filter state when this much wall time passed since the last |
| 30 | +/// chunk (Pipecat `CLEAR_STREAM_AFTER_SECS` parity). |
| 31 | +const CLEAR_AFTER: std::time::Duration = std::time::Duration::from_millis(200); |
| 32 | + |
| 33 | +/// Fixed input chunk the sinc resampler consumes per process call. |
| 34 | +const CHUNK_FRAMES: usize = 1024; |
| 35 | + |
| 36 | +/// Streaming mono f32 resampler. See the module docs for the contract. |
| 37 | +pub struct StreamResampler { |
| 38 | + inner: Option<SincFixedIn<f32>>, |
| 39 | + in_rate: u32, |
| 40 | + out_rate: u32, |
| 41 | + last_call: Option<Instant>, |
| 42 | + /// Tail (< one chunk) carried between calls — continuous filter state. |
| 43 | + pending_in: Vec<f32>, |
| 44 | +} |
| 45 | + |
| 46 | +impl std::fmt::Debug for StreamResampler { |
| 47 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 48 | + f.debug_struct("StreamResampler") |
| 49 | + .field("in_rate", &self.in_rate) |
| 50 | + .field("out_rate", &self.out_rate) |
| 51 | + .field("pending", &self.pending_in.len()) |
| 52 | + .finish() |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +impl Default for StreamResampler { |
| 57 | + fn default() -> Self { |
| 58 | + Self::new() |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +impl StreamResampler { |
| 63 | + pub fn new() -> Self { |
| 64 | + Self { inner: None, in_rate: 0, out_rate: 0, last_call: None, pending_in: Vec::new() } |
| 65 | + } |
| 66 | + |
| 67 | + /// Resample mono f32 samples. Returns `None` when `in_rate == out_rate` — |
| 68 | + /// the caller uses its input unchanged (zero-copy identity, the common |
| 69 | + /// case for 16 kHz clients). Otherwise returns the resampled samples; |
| 70 | + /// input shorter than the internal chunk is buffered and may yield an |
| 71 | + /// empty Vec until enough accumulates (continuous-stream semantics). |
| 72 | + pub fn resample(&mut self, input: &[f32], in_rate: u32, out_rate: u32) -> Option<Vec<f32>> { |
| 73 | + if in_rate == out_rate || in_rate == 0 || out_rate == 0 { |
| 74 | + return None; |
| 75 | + } |
| 76 | + self.ensure(in_rate, out_rate); |
| 77 | + self.maybe_clear_stale(); |
| 78 | + self.last_call = Some(Instant::now()); |
| 79 | + |
| 80 | + let resampler = self.inner.as_mut().expect("ensured above"); |
| 81 | + self.pending_in.extend_from_slice(input); |
| 82 | + |
| 83 | + let mut out: Vec<f32> = Vec::new(); |
| 84 | + let chunk = resampler.input_frames_next().max(1); |
| 85 | + while self.pending_in.len() >= chunk { |
| 86 | + let take: Vec<f32> = self.pending_in.drain(..chunk).collect(); |
| 87 | + match resampler.process(&[take], None) { |
| 88 | + Ok(mut resampled) => { |
| 89 | + if let Some(channel) = resampled.pop() { |
| 90 | + out.extend_from_slice(&channel); |
| 91 | + } |
| 92 | + } |
| 93 | + Err(e) => { |
| 94 | + warn!(error = %e, "stream resample failed; passing chunk through unresampled"); |
| 95 | + // Conservative degradation: never drop audio silently. |
| 96 | + return Some(input.to_vec()); |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | + Some(out) |
| 101 | + } |
| 102 | + |
| 103 | + /// Explicit utterance boundary (barge-in / context end): drop the filter |
| 104 | + /// state now instead of waiting out the stale-clear window. |
| 105 | + pub fn reset(&mut self) { |
| 106 | + if let Some(r) = self.inner.as_mut() { |
| 107 | + r.reset(); |
| 108 | + } |
| 109 | + self.pending_in.clear(); |
| 110 | + self.last_call = None; |
| 111 | + } |
| 112 | + |
| 113 | + fn ensure(&mut self, in_rate: u32, out_rate: u32) { |
| 114 | + if self.inner.is_some() && self.in_rate == in_rate && self.out_rate == out_rate { |
| 115 | + return; |
| 116 | + } |
| 117 | + if self.inner.is_some() { |
| 118 | + // Mid-stream rate change (e.g. a provider reconnect renegotiated): |
| 119 | + // rebuild — legitimate but worth a log line. |
| 120 | + debug!( |
| 121 | + from = format!("{}→{}", self.in_rate, self.out_rate), |
| 122 | + to = format!("{in_rate}→{out_rate}"), |
| 123 | + "stream resampler rate change; rebuilding" |
| 124 | + ); |
| 125 | + } |
| 126 | + let params = SincInterpolationParameters { |
| 127 | + sinc_len: 128, |
| 128 | + f_cutoff: 0.95, |
| 129 | + oversampling_factor: 128, |
| 130 | + interpolation: SincInterpolationType::Linear, |
| 131 | + window: WindowFunction::BlackmanHarris2, |
| 132 | + }; |
| 133 | + match SincFixedIn::new(out_rate as f64 / in_rate as f64, 1.0, params, CHUNK_FRAMES, 1) { |
| 134 | + Ok(r) => { |
| 135 | + self.inner = Some(r); |
| 136 | + self.in_rate = in_rate; |
| 137 | + self.out_rate = out_rate; |
| 138 | + self.pending_in.clear(); |
| 139 | + } |
| 140 | + Err(e) => { |
| 141 | + warn!(error = %e, in_rate, out_rate, "failed to build sinc resampler"); |
| 142 | + self.inner = None; |
| 143 | + } |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + fn maybe_clear_stale(&mut self) { |
| 148 | + if let (Some(last), Some(r)) = (self.last_call, self.inner.as_mut()) |
| 149 | + && last.elapsed() > CLEAR_AFTER |
| 150 | + { |
| 151 | + r.reset(); |
| 152 | + self.pending_in.clear(); |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +/// PCM16-LE convenience for the TTS egress path (`AudioData.data` is bytes). |
| 158 | +/// Returns `None` when no conversion is needed (use the original bytes). |
| 159 | +pub fn resample_pcm16( |
| 160 | + r: &mut StreamResampler, |
| 161 | + pcm: &[u8], |
| 162 | + in_rate: u32, |
| 163 | + out_rate: u32, |
| 164 | +) -> Option<Vec<u8>> { |
| 165 | + if in_rate == out_rate || in_rate == 0 || out_rate == 0 { |
| 166 | + return None; |
| 167 | + } |
| 168 | + let samples: Vec<f32> = pcm |
| 169 | + .chunks_exact(2) |
| 170 | + .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0) |
| 171 | + .collect(); |
| 172 | + let out = r.resample(&samples, in_rate, out_rate)?; |
| 173 | + let mut bytes = Vec::with_capacity(out.len() * 2); |
| 174 | + for &s in &out { |
| 175 | + let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16; |
| 176 | + bytes.extend_from_slice(&v.to_le_bytes()); |
| 177 | + } |
| 178 | + Some(bytes) |
| 179 | +} |
| 180 | + |
| 181 | +#[cfg(test)] |
| 182 | +mod tests { |
| 183 | + use super::*; |
| 184 | + |
| 185 | + fn sine(n: usize, freq_norm: f32) -> Vec<f32> { |
| 186 | + (0..n).map(|i| (i as f32 * freq_norm).sin() * 0.8).collect() |
| 187 | + } |
| 188 | + |
| 189 | + #[test] |
| 190 | + fn identity_returns_none_zero_work() { |
| 191 | + let mut r = StreamResampler::new(); |
| 192 | + assert!(r.resample(&[0.1, -0.2, 0.3], 16000, 16000).is_none()); |
| 193 | + assert!(r.inner.is_none(), "identity must not even build the resampler"); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn downsample_48k_to_16k_length_ratio() { |
| 198 | + let mut r = StreamResampler::new(); |
| 199 | + let input = sine(48_000, 0.05); // 1s at 48k |
| 200 | + let out = r.resample(&input, 48000, 16000).unwrap(); |
| 201 | + // ~16000 samples, minus the < one-chunk tail still buffered. |
| 202 | + let expected = 16_000.0; |
| 203 | + assert!( |
| 204 | + (out.len() as f32 - expected).abs() < 1500.0, |
| 205 | + "length {} far from expected ~{expected}", |
| 206 | + out.len() |
| 207 | + ); |
| 208 | + } |
| 209 | + |
| 210 | + #[test] |
| 211 | + fn chunked_stream_has_no_boundary_click() { |
| 212 | + // Resample one continuous sine in two calls: the seam must be smooth |
| 213 | + // (filter history carries across the boundary). |
| 214 | + let input = sine(9600, 0.05); |
| 215 | + let mut r = StreamResampler::new(); |
| 216 | + let mut joined = r.resample(&input[..4800], 48000, 16000).unwrap(); |
| 217 | + joined.extend(r.resample(&input[4800..], 48000, 16000).unwrap()); |
| 218 | + let max_step = joined.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max); |
| 219 | + assert!(max_step < 0.3, "seam discontinuity (click): step {max_step}"); |
| 220 | + } |
| 221 | + |
| 222 | + #[test] |
| 223 | + fn stale_state_cleared_after_gap() { |
| 224 | + let mut r = StreamResampler::new(); |
| 225 | + let loud = sine(4800, 0.3); |
| 226 | + let _ = r.resample(&loud, 48000, 16000); |
| 227 | + // Simulate the inter-utterance gap. |
| 228 | + r.last_call = Some(Instant::now() - std::time::Duration::from_millis(400)); |
| 229 | + let silence = vec![0.0f32; 4800]; |
| 230 | + let out = r.resample(&silence, 48000, 16000).unwrap(); |
| 231 | + let max_abs = out.iter().fold(0.0f32, |m, s| m.max(s.abs())); |
| 232 | + assert!(max_abs < 1e-3, "filter tail leaked across the gap: {max_abs}"); |
| 233 | + } |
| 234 | + |
| 235 | + #[test] |
| 236 | + fn explicit_reset_drops_pending() { |
| 237 | + let mut r = StreamResampler::new(); |
| 238 | + let _ = r.resample(&sine(100, 0.05), 48000, 16000); // < chunk → all pending |
| 239 | + assert!(!r.pending_in.is_empty()); |
| 240 | + r.reset(); |
| 241 | + assert!(r.pending_in.is_empty()); |
| 242 | + } |
| 243 | + |
| 244 | + #[test] |
| 245 | + fn rate_change_rebuilds() { |
| 246 | + let mut r = StreamResampler::new(); |
| 247 | + let _ = r.resample(&sine(2048, 0.05), 48000, 16000); |
| 248 | + let out = r.resample(&sine(2048, 0.05), 24000, 16000); |
| 249 | + assert!(out.is_some(), "rate change must rebuild, not fail"); |
| 250 | + assert_eq!(r.in_rate, 24000); |
| 251 | + } |
| 252 | + |
| 253 | + #[test] |
| 254 | + fn pcm16_roundtrip_even_and_scaled() { |
| 255 | + let mut r = StreamResampler::new(); |
| 256 | + let pcm: Vec<u8> = sine(4800, 0.05) |
| 257 | + .iter() |
| 258 | + .flat_map(|s| ((s * 32767.0) as i16).to_le_bytes()) |
| 259 | + .collect(); |
| 260 | + assert!(resample_pcm16(&mut r, &pcm, 24000, 24000).is_none(), "identity → None"); |
| 261 | + let out = resample_pcm16(&mut r, &pcm, 24000, 16000).unwrap(); |
| 262 | + assert_eq!(out.len() % 2, 0, "whole samples only"); |
| 263 | + assert!(!out.is_empty()); |
| 264 | + } |
| 265 | +} |
0 commit comments