Skip to content

Commit 3fad345

Browse files
jithinABclaude
andcommitted
feat(C-G5 pt1): standardized streaming resampler + mel stale-tail fix
PIPECAT_FIX_PLAN C-G5, increment 1 (the module + the latent-bug fix; the ingress/egress wirings land next with their validator + client-rate plumbing). - core/audio/resampler.rs: StreamResampler — rubato SincFixedIn with filter history across chunks (no boundary clicks), lazy init, 0.2s stale-state clear (a new utterance never inherits the previous one's filter tail), explicit reset() for barge-in, mid-stream rate-change rebuild, and a ZERO-COPY identity contract (None = use your input — the critique-corrected signature; the common 16k-client case does no work at all). resample_pcm16 for the byte-oriented TTS egress. Degradation policy: a resample failure passes audio through unresampled rather than dropping it silently (RC5). - mel_extractor stale-tail fix (the C-critic-verified latent bug): clear_frames()/reset() now reset the sinc delay line — previously the next utterance inherited the previous one's filter tail. 7 unit tests: identity-zero-work, 48k→16k ratio, chunked seam-no-click, stale-clear-after-gap, explicit reset, rate-change rebuild, pcm16 roundtrip. Floor: lib 6045/0 (ALL features incl. smart-turn+silero) · clippy 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2fd43ce commit 3fad345

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

gateway/src/core/audio/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
//! - [`AudioRingBuffer`] - Pre-allocated ring buffer for audio samples
88
99
pub mod ring_buffer;
10+
// The single streaming resampler (C-G5) — available exactly when `rubato`
11+
// is pulled in (same gating pattern as `core::onnx`).
12+
#[cfg(any(feature = "silero-vad", feature = "smart-turn", feature = "noise-filter"))]
13+
pub mod resampler;
1014
pub mod vad;
1115

1216
pub use ring_buffer::AudioRingBuffer;
17+
#[cfg(any(feature = "silero-vad", feature = "smart-turn", feature = "noise-filter"))]
18+
pub use resampler::{StreamResampler, resample_pcm16};
1319
pub use vad::{VADAnalyzer, VADParams, VADState, VADTransition};
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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+
}

gateway/src/core/smart_turn/mel_extractor.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,13 +609,22 @@ impl MelExtractor {
609609
self.mel_frames.clear();
610610
self.audio_buffer.clear();
611611
self.resample_input_buffer.clear();
612+
// Drop the sinc delay line too (C-G5 stale-tail fix): without this,
613+
// the next utterance inherits the previous one's filter tail — a
614+
// click/energy-leak source at utterance boundaries.
615+
if let Some(r) = self.resampler.as_mut() {
616+
r.reset();
617+
}
612618
}
613619

614620
/// Resets the extractor to initial state.
615621
pub fn reset(&mut self) {
616622
self.mel_frames.clear();
617623
self.audio_buffer.clear();
618624
self.resample_input_buffer.clear();
625+
if let Some(r) = self.resampler.as_mut() {
626+
r.reset();
627+
}
619628
self.total_samples = 0;
620629
self.total_extraction_time_us = 0;
621630
}

0 commit comments

Comments
 (0)