Skip to main content

flowcat_core/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Audio codec helpers: G.711 (μ-law / A-law) ↔ PCM16 and sample-rate conversion.
4//!
5//! Telephony is G.711 μ-law @ 8 kHz; Gemini Live wants 16 kHz PCM in and emits
6//! 24 kHz PCM out (see DESIGN.md "Audio path"). G.711 via `audio-codec-algorithms`,
7//! resampling via `rubato`.
8//!
9//! ## Streaming resampler design
10//!
11//! Telephony delivers audio in small, frequently *variable*-size chunks (≈20 ms:
12//! 160 samples @ 8 kHz, 320 @ 16 kHz, 480 @ 24 kHz — and gateways may coalesce or
13//! split frames). `rubato`'s async sinc resampler ([`rubato::Async`]) runs in one
14//! of two fixed modes:
15//!
16//! - `FixedAsync::Input` consumes a **fixed** number of input frames per `process`
17//!   call and produces a variable number of output frames.
18//! - `FixedAsync::Output` produces a fixed number of output frames and pulls a
19//!   variable number of input frames.
20//!
21//! A real-time media path naturally has a *fixed input cadence* (whatever the
22//! carrier hands us) and an unconstrained output, so `FixedAsync::Input` is the
23//! right base. But it still requires *exactly* `chunk_size` samples on every
24//! call, which an arbitrary 20 ms frame will not satisfy. We therefore decouple
25//! the carrier's chunk size from the resampler's block size with a small internal
26//! **carry buffer**:
27//!
28//! 1. Append the incoming samples to `in_buf`.
29//! 2. While `in_buf` holds at least one full `BLOCK` of samples, feed exactly
30//!    `BLOCK` frames through the resampler (re-using pre-allocated scratch I/O
31//!    buffers) and append the result to the output.
32//! 3. Keep the `< BLOCK` remainder in `in_buf` for the next call.
33//!
34//! This yields **continuous, gap-free** resampling across an arbitrary stream of
35//! variable chunks: every input sample is consumed exactly once and in order, the
36//! resampler's internal filter state is preserved across calls (so block
37//! boundaries are seamless), and the resampler plus its scratch buffers are
38//! allocated **once** in [`Resampler::new`] — there is no per-call reallocation.
39//! A pass-through (`from_rate == to_rate`) skips `rubato` entirely.
40
41use audioadapter_buffers::direct::InterleavedSlice;
42use rubato::{
43    Async, FixedAsync, Resampler as RubatoResampler, SincInterpolationParameters,
44    SincInterpolationType, WindowFunction,
45};
46
47use crate::error::FlowcatError;
48use crate::types::AudioChunk;
49
50// ---------------------------------------------------------------------------
51// G.711 (μ-law / A-law) <-> PCM16
52// ---------------------------------------------------------------------------
53
54/// Decode a G.711 μ-law byte stream into 16-bit PCM samples.
55pub fn ulaw_to_pcm16(ulaw: &[u8]) -> Vec<i16> {
56    ulaw.iter()
57        .map(|&b| audio_codec_algorithms::decode_ulaw(b))
58        .collect()
59}
60
61/// Encode 16-bit PCM samples into a G.711 μ-law byte stream.
62pub fn pcm16_to_ulaw(pcm: &[i16]) -> Vec<u8> {
63    pcm.iter()
64        .map(|&s| audio_codec_algorithms::encode_ulaw(s))
65        .collect()
66}
67
68/// Decode a G.711 A-law byte stream into 16-bit PCM samples.
69///
70/// Provided alongside the μ-law path because a SIP trunk may negotiate A-law
71/// (PCMA) instead of μ-law (PCMU) (see `sip::sdp`); same `audio-codec-algorithms`
72/// backend.
73pub fn alaw_to_pcm16(alaw: &[u8]) -> Vec<i16> {
74    alaw.iter()
75        .map(|&b| audio_codec_algorithms::decode_alaw(b))
76        .collect()
77}
78
79/// Encode 16-bit PCM samples into a G.711 A-law byte stream.
80pub fn pcm16_to_alaw(pcm: &[i16]) -> Vec<u8> {
81    pcm.iter()
82        .map(|&s| audio_codec_algorithms::encode_alaw(s))
83        .collect()
84}
85
86// ---------------------------------------------------------------------------
87// Sample-rate conversion (streaming)
88// ---------------------------------------------------------------------------
89
90/// Fixed resampler input block size (frames per `rubato` call).
91///
92/// Independent of the carrier's frame size — the carry buffer feeds the resampler
93/// in `BLOCK`-sized units regardless of how the input is chunked. 256 keeps the
94/// sinc filter cheap while staying well above any single 20 ms telephony frame.
95const BLOCK: usize = 256;
96
97/// A mono sample-rate converter (e.g. 8k→16k, 24k→8k) backed by `rubato`.
98///
99/// Holds resampler state across calls so streaming chunks convert correctly
100/// (see the module docs for the carry-buffer streaming design).
101pub struct Resampler {
102    /// Source sample rate in Hz.
103    pub from_rate: u32,
104    /// Destination sample rate in Hz.
105    pub to_rate: u32,
106
107    /// The underlying `rubato` resampler. `None` when `from_rate == to_rate`
108    /// (pass-through — no resampling work to do).
109    inner: Option<Async<f32>>,
110    /// Carry buffer of not-yet-resampled input samples (`< BLOCK` between calls).
111    in_buf: Vec<i16>,
112    /// Pre-allocated single-channel input scratch (`BLOCK` f32 samples) — avoids
113    /// per-call allocation when handing data to `rubato`.
114    scratch_in: Vec<f32>,
115    /// Pre-allocated flat mono output scratch sized to the resampler's max
116    /// output frames.
117    scratch_out: Vec<f32>,
118}
119
120impl Resampler {
121    /// Create a resampler converting `from_rate` → `to_rate` (mono).
122    ///
123    /// Both rates must be non-zero. When the rates are equal the resampler is a
124    /// cheap pass-through. The `rubato` resampler and all scratch buffers are
125    /// allocated here, once, so [`process`](Self::process) never reallocates.
126    pub fn new(from_rate: u32, to_rate: u32) -> Result<Self, FlowcatError> {
127        if from_rate == 0 || to_rate == 0 {
128            return Err(FlowcatError::Codec(format!(
129                "invalid sample rate: from={from_rate} to={to_rate} (must be > 0)"
130            )));
131        }
132
133        if from_rate == to_rate {
134            return Ok(Self {
135                from_rate,
136                to_rate,
137                inner: None,
138                in_buf: Vec::new(),
139                scratch_in: Vec::new(),
140                scratch_out: Vec::new(),
141            });
142        }
143
144        let ratio = to_rate as f64 / from_rate as f64;
145        let params = SincInterpolationParameters {
146            sinc_len: 256,
147            f_cutoff: 0.95,
148            oversampling_factor: 256,
149            interpolation: SincInterpolationType::Cubic,
150            window: WindowFunction::BlackmanHarris2,
151        };
152
153        // Fixed input: BLOCK frames in, variable frames out — matches the
154        // carrier's fixed input cadence. `max_resample_ratio_relative = 1.0`
155        // because our ratio is constant for the life of the call. (rubato 3.x:
156        // the consolidated `Async` resampler in sinc mode, `FixedAsync::Input`.)
157        let inner = Async::<f32>::new_sinc(ratio, 1.0, &params, BLOCK, 1, FixedAsync::Input)
158            .map_err(|e| FlowcatError::Codec(format!("rubato init {from_rate}->{to_rate}: {e}")))?;
159
160        // Flat mono output scratch sized to the resampler's max output frames.
161        let scratch_out = vec![0.0f32; inner.output_frames_max()];
162        let scratch_in = Vec::with_capacity(BLOCK);
163
164        Ok(Self {
165            from_rate,
166            to_rate,
167            inner: Some(inner),
168            in_buf: Vec::new(),
169            scratch_in,
170            scratch_out,
171        })
172    }
173
174    /// Resample one mono PCM chunk from `from_rate` to `to_rate`.
175    ///
176    /// Accepts a chunk of **any** length (including empty); buffers a sub-block
177    /// remainder internally and returns the samples produced so far. The returned
178    /// [`AudioChunk`] is tagged at `to_rate`. Across a sequence of calls the
179    /// concatenation of outputs is a continuous resampling of the concatenated
180    /// inputs.
181    pub fn process(&mut self, input: &AudioChunk) -> Result<AudioChunk, FlowcatError> {
182        if input.sample_rate != self.from_rate {
183            return Err(FlowcatError::Codec(format!(
184                "resampler expected input at {} Hz, got {} Hz",
185                self.from_rate, input.sample_rate
186            )));
187        }
188
189        // Pass-through: rates match, no work.
190        let resampler = match self.inner.as_mut() {
191            None => return Ok(AudioChunk::new(input.pcm.clone(), self.to_rate)),
192            Some(r) => r,
193        };
194
195        self.in_buf.extend_from_slice(&input.pcm);
196
197        let mut out: Vec<i16> = Vec::new();
198        let mut consumed = 0usize;
199        while self.in_buf.len() - consumed >= BLOCK {
200            let block = &self.in_buf[consumed..consumed + BLOCK];
201
202            // Fill the pre-allocated f32 input scratch (i16 -> f32 in [-1, 1)).
203            self.scratch_in.clear();
204            self.scratch_in
205                .extend(block.iter().map(|&s| s as f32 / 32768.0));
206
207            // rubato 3.x takes `audioadapter` views; mono PCM is a 1-channel
208            // interleaved buffer, so the flat scratch vecs wrap directly.
209            let in_view = InterleavedSlice::new(&self.scratch_in, 1, BLOCK)
210                .map_err(|e| FlowcatError::Codec(format!("rubato input view: {e}")))?;
211            let cap = self.scratch_out.len();
212            let mut out_view = InterleavedSlice::new_mut(&mut self.scratch_out, 1, cap)
213                .map_err(|e| FlowcatError::Codec(format!("rubato output view: {e}")))?;
214            let (_in_frames, out_frames) = resampler
215                .process_into_buffer(&in_view, &mut out_view, None)
216                .map_err(|e| FlowcatError::Codec(format!("rubato process: {e}")))?;
217
218            out.extend(
219                self.scratch_out[..out_frames]
220                    .iter()
221                    .map(|&s| f32_to_i16(s)),
222            );
223            consumed += BLOCK;
224        }
225
226        // Drop the consumed prefix, keep the `< BLOCK` remainder for next time.
227        if consumed > 0 {
228            self.in_buf.drain(..consumed);
229        }
230
231        Ok(AudioChunk::new(out, self.to_rate))
232    }
233
234    /// Flush any buffered remainder (< one `BLOCK`) at end-of-stream by
235    /// zero-padding the final partial block, returning the tail output.
236    ///
237    /// Optional — the streaming `process` path never needs this mid-call, but a
238    /// caller that wants every last sample (e.g. before finalizing a recording)
239    /// can drain the resampler here. Returns an empty chunk for a pass-through or
240    /// an already-empty buffer.
241    pub fn flush(&mut self) -> Result<AudioChunk, FlowcatError> {
242        let resampler = match self.inner.as_mut() {
243            None => return Ok(AudioChunk::new(Vec::new(), self.to_rate)),
244            Some(r) => r,
245        };
246        if self.in_buf.is_empty() {
247            return Ok(AudioChunk::new(Vec::new(), self.to_rate));
248        }
249
250        self.scratch_in.clear();
251        self.scratch_in
252            .extend(self.in_buf.iter().map(|&s| s as f32 / 32768.0));
253        self.scratch_in.resize(BLOCK, 0.0); // zero-pad the final partial block
254        self.in_buf.clear();
255
256        let in_view = InterleavedSlice::new(&self.scratch_in, 1, BLOCK)
257            .map_err(|e| FlowcatError::Codec(format!("rubato input view: {e}")))?;
258        let cap = self.scratch_out.len();
259        let mut out_view = InterleavedSlice::new_mut(&mut self.scratch_out, 1, cap)
260            .map_err(|e| FlowcatError::Codec(format!("rubato output view: {e}")))?;
261        let (_in_frames, out_frames) = resampler
262            .process_into_buffer(&in_view, &mut out_view, None)
263            .map_err(|e| FlowcatError::Codec(format!("rubato flush: {e}")))?;
264
265        let out: Vec<i16> = self.scratch_out[..out_frames]
266            .iter()
267            .map(|&s| f32_to_i16(s))
268            .collect();
269        Ok(AudioChunk::new(out, self.to_rate))
270    }
271}
272
273/// Convert a resampled f32 sample back to i16 with clamping (rubato can overshoot
274/// slightly past ±1.0 around transients).
275#[inline]
276fn f32_to_i16(s: f32) -> i16 {
277    (s * 32768.0).round().clamp(-32768.0, 32767.0) as i16
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    /// Representative samples spanning the i16 range, including the rails.
285    fn representative_samples() -> Vec<i16> {
286        vec![
287            0, 1, -1, 100, -100, 1000, -1000, 8000, -8000, 16000, -16000, 32000, -32000, 32767,
288            -32768, 255, -256, 4095, -4096,
289        ]
290    }
291
292    // ── G.711 absolute reference values (ITU-T G.191) ───────────────────────────
293    // Pins companding to the standard so a codec-dependency swap or a mistaken
294    // hand-roll that garbles/chops audio fails LOUDLY — the round-trip tests above
295    // are self-referential and would NOT catch a wrong-companding regression.
296    #[test]
297    fn ulaw_decodes_to_g711_reference_values() {
298        assert_eq!(
299            ulaw_to_pcm16(&[0x00]),
300            vec![-32124],
301            "0x00 = negative full-scale"
302        );
303        assert_eq!(
304            ulaw_to_pcm16(&[0x80]),
305            vec![32124],
306            "0x80 = positive full-scale"
307        );
308        assert_eq!(ulaw_to_pcm16(&[0xFF]), vec![0], "0xFF = -0 (silence)");
309        assert_eq!(
310            ulaw_to_pcm16(&[0x7F]),
311            vec![0],
312            "0x7F = +0 (the other zero)"
313        );
314    }
315
316    #[test]
317    fn ulaw_silence_round_trips_to_canonical_zero_code() {
318        assert_eq!(
319            pcm16_to_ulaw(&[0]),
320            vec![0xFF],
321            "encode(0) = canonical -0 code"
322        );
323        assert_eq!(ulaw_to_pcm16(&[0xFF]), vec![0], "0xFF decodes to silence");
324    }
325
326    #[test]
327    fn ulaw_full_scale_encode_does_not_panic_or_wrap() {
328        let enc = pcm16_to_ulaw(&[i16::MAX, i16::MIN]);
329        let dec = ulaw_to_pcm16(&enc);
330        assert!(
331            dec[0] > 30000,
332            "i16::MAX -> near +full-scale, got {}",
333            dec[0]
334        );
335        assert!(
336            dec[1] < -30000,
337            "i16::MIN -> near -full-scale, got {}",
338            dec[1]
339        );
340    }
341
342    #[test]
343    fn alaw_decodes_to_g711_reference_values() {
344        assert_eq!(alaw_to_pcm16(&[0x00]), vec![-5504]);
345        assert_eq!(alaw_to_pcm16(&[0xFF]), vec![848]);
346        // A-law has no exact-zero code; 0x55/0xD5 are the smallest-magnitude
347        // ("silence") codewords, decoding to -8 / +8 (verified against the
348        // G.191 table in audio-codec-algorithms 0.8.0).
349        assert_eq!(alaw_to_pcm16(&[0x55]), vec![-8]);
350        assert_eq!(alaw_to_pcm16(&[0xD5]), vec![8]);
351    }
352
353    #[test]
354    fn empty_lengths_are_preserved_both_directions() {
355        assert!(ulaw_to_pcm16(&[]).is_empty());
356        assert!(pcm16_to_ulaw(&[]).is_empty());
357        assert!(alaw_to_pcm16(&[]).is_empty());
358        assert!(pcm16_to_alaw(&[]).is_empty());
359    }
360
361    // ── Resampler robustness: empty, sub-BLOCK, the 16k→8k sibling rate, clamp ──
362    #[test]
363    fn resample_empty_chunk_does_not_panic() {
364        let mut rs = Resampler::new(8000, 16000).unwrap();
365        let out = rs.process(&AudioChunk::new(vec![], 8000)).unwrap();
366        assert_eq!(out.sample_rate, 16000);
367        assert!(out.is_empty(), "empty in -> empty out");
368        assert!(rs.flush().unwrap().is_empty());
369    }
370
371    #[test]
372    fn resample_sub_block_chunk_buffers_without_panic() {
373        let mut rs = Resampler::new(8000, 16000).unwrap();
374        let out = rs.process(&AudioChunk::new(sine(8000, 37), 8000)).unwrap();
375        assert!(out.is_empty(), "37 (< BLOCK) samples are fully buffered");
376        let tail = rs.flush().unwrap();
377        assert_eq!(tail.sample_rate, 16000);
378        assert!(tail.len() < BLOCK * 2);
379    }
380
381    #[test]
382    fn resample_16k_to_8k_roughly_halves() {
383        let mut rs = Resampler::new(16000, 8000).unwrap();
384        let total_in = 3200usize; // 200 ms @ 16k
385        let out = rs
386            .process(&AudioChunk::new(sine(16000, total_in), 16000))
387            .unwrap();
388        let total_out = out.len() + rs.flush().unwrap().len();
389        assert_eq!(out.sample_rate, 8000);
390        let expected = total_in / 2;
391        assert!(
392            ((expected - BLOCK)..=(expected + BLOCK)).contains(&total_out),
393            "16k->8k expected ~{expected}, got {total_out}"
394        );
395    }
396
397    #[test]
398    fn f32_to_i16_clamps_overshoot_without_wrapping() {
399        assert_eq!(f32_to_i16(2.0), i16::MAX);
400        assert_eq!(f32_to_i16(-2.0), i16::MIN);
401        assert_eq!(f32_to_i16(0.0), 0);
402        assert_eq!(f32_to_i16(1.0), i16::MAX); // +1.0 -> 32768 clamps to 32767
403        assert_eq!(f32_to_i16(-1.0), i16::MIN);
404    }
405
406    #[test]
407    fn ulaw_decode_then_pcm_is_stable_through_reencode() {
408        // μ-law is a *fixed 8-bit code*. Re-encoding a decoded sample is stable
409        // **at the PCM level** for every codeword: decode→encode→decode yields the
410        // exact same PCM value as the first decode. (The codeword itself is not
411        // perfectly idempotent for *one* value — G.711 has two zero codes, 0x7F
412        // "+0" and 0xFF "-0", both decoding to PCM 0; re-encoding 0 picks the
413        // canonical -0 code. That is correct companding, not a wrapper bug — so we
414        // assert the meaningful PCM-stable property, which holds for all 256 codes.)
415        for code in 0u8..=255 {
416            let pcm1 = ulaw_to_pcm16(&[code]);
417            let reencoded = pcm16_to_ulaw(&pcm1);
418            let pcm2 = ulaw_to_pcm16(&reencoded);
419            assert_eq!(
420                pcm2, pcm1,
421                "μ-law code {code} not PCM-stable: {pcm1:?} -> {reencoded:?} -> {pcm2:?}"
422            );
423        }
424    }
425
426    #[test]
427    fn pcm_ulaw_roundtrip_within_g711_quantization() {
428        // PCM -> μ-law -> PCM is lossy 8-bit companding. μ-law is logarithmic, so
429        // the meaningful bound is *relative*: away from zero the round-trip error
430        // is a small fraction of the magnitude. For small magnitudes the absolute
431        // step is tiny, and at the very rails the topmost reconstruction level sits
432        // ~2% below full-scale — all expected. We assert both bounds.
433        let pcm = representative_samples();
434        let ulaw = pcm16_to_ulaw(&pcm);
435        let back = ulaw_to_pcm16(&ulaw);
436        assert_eq!(back.len(), pcm.len());
437        for (orig, rt) in pcm.iter().zip(back.iter()) {
438            let o = *orig as i32;
439            let err = (o - *rt as i32).abs();
440            // Small absolute floor for near-zero samples, OR <= ~3% relative for
441            // larger magnitudes (μ-law's near-constant relative quantization).
442            let rel_ok = err as f64 <= (o.unsigned_abs() as f64) * 0.03;
443            assert!(
444                err <= 8 || rel_ok,
445                "μ-law quantization error out of bounds: orig={orig} roundtrip={rt} err={err}"
446            );
447        }
448    }
449
450    #[test]
451    fn alaw_decode_then_pcm_is_stable_through_reencode() {
452        for code in 0u8..=255 {
453            let pcm1 = alaw_to_pcm16(&[code]);
454            let reencoded = pcm16_to_alaw(&pcm1);
455            let pcm2 = alaw_to_pcm16(&reencoded);
456            assert_eq!(pcm2, pcm1, "A-law code {code} not PCM-stable");
457        }
458    }
459
460    /// A 440 Hz-ish sine generator at a given rate, `n` samples.
461    fn sine(rate: u32, n: usize) -> Vec<i16> {
462        (0..n)
463            .map(|i| {
464                let t = i as f32 / rate as f32;
465                (16000.0 * (2.0 * std::f32::consts::PI * 440.0 * t).sin()) as i16
466            })
467            .collect()
468    }
469
470    #[test]
471    fn rate_passthrough_returns_input_unchanged() {
472        let mut rs = Resampler::new(8000, 8000).unwrap();
473        let chunk = AudioChunk::new(sine(8000, 160), 8000);
474        let out = rs.process(&chunk).unwrap();
475        assert_eq!(out.sample_rate, 8000);
476        assert_eq!(out.pcm, chunk.pcm);
477    }
478
479    #[test]
480    fn resample_8k_to_16k_roughly_doubles_over_a_stream() {
481        // Feed several variable-size 8 kHz chunks (telephony cadence) and confirm
482        // the *cumulative* output is ~2x the cumulative input — proving the
483        // streaming carry-buffer is continuous, not a single-chunk fluke.
484        let mut rs = Resampler::new(8000, 16000).unwrap();
485        let chunk_sizes = [160usize, 160, 80, 320, 200, 160, 240]; // varied, incl. < BLOCK
486        let total_in: usize = chunk_sizes.iter().sum();
487
488        let mut total_out = 0usize;
489        let mut phase = 0usize;
490        for &n in &chunk_sizes {
491            let samples: Vec<i16> = sine(8000, phase + n)[phase..].to_vec();
492            phase += n;
493            let out = rs.process(&AudioChunk::new(samples, 8000)).unwrap();
494            assert_eq!(out.sample_rate, 16000);
495            total_out += out.len();
496        }
497        total_out += rs.flush().unwrap().len();
498
499        let expected = total_in * 2;
500        let lo = expected.saturating_sub(BLOCK * 2);
501        let hi = expected + BLOCK * 2;
502        assert!(
503            (lo..=hi).contains(&total_out),
504            "8k->16k expected ~{expected} samples (in={total_in}), got {total_out}"
505        );
506    }
507
508    #[test]
509    fn resample_24k_to_8k_roughly_thirds_over_a_stream() {
510        // 24 kHz Gemini-out -> 8 kHz carrier: cumulative output ~= input / 3,
511        // checked over a multi-chunk stream with varied (incl. sub-BLOCK) sizes.
512        let mut rs = Resampler::new(24000, 8000).unwrap();
513        let chunk_sizes = [480usize, 480, 240, 960, 300, 480]; // 20ms @24k = 480
514        let total_in: usize = chunk_sizes.iter().sum();
515
516        let mut total_out = 0usize;
517        let mut phase = 0usize;
518        for &n in &chunk_sizes {
519            let samples: Vec<i16> = sine(24000, phase + n)[phase..].to_vec();
520            phase += n;
521            let out = rs.process(&AudioChunk::new(samples, 24000)).unwrap();
522            assert_eq!(out.sample_rate, 8000);
523            total_out += out.len();
524        }
525        total_out += rs.flush().unwrap().len();
526
527        let expected = total_in / 3;
528        let lo = expected.saturating_sub(BLOCK);
529        let hi = expected + BLOCK;
530        assert!(
531            (lo..=hi).contains(&total_out),
532            "24k->8k expected ~{expected} samples (in={total_in}), got {total_out}"
533        );
534    }
535
536    #[test]
537    fn resample_8k_to_24k_roughly_triples() {
538        let mut rs = Resampler::new(8000, 24000).unwrap();
539        let total_in = 1600usize; // 200 ms
540        let out = rs
541            .process(&AudioChunk::new(sine(8000, total_in), 8000))
542            .unwrap();
543        let tail = rs.flush().unwrap().len();
544        let total_out = out.len() + tail;
545        let expected = total_in * 3;
546        assert!(
547            ((expected - BLOCK * 3)..=(expected + BLOCK * 3)).contains(&total_out),
548            "8k->24k expected ~{expected}, got {total_out}"
549        );
550    }
551
552    #[test]
553    fn rejects_zero_rate() {
554        assert!(Resampler::new(0, 16000).is_err());
555        assert!(Resampler::new(8000, 0).is_err());
556    }
557
558    #[test]
559    fn rejects_mismatched_input_rate() {
560        let mut rs = Resampler::new(8000, 16000).unwrap();
561        let wrong = AudioChunk::new(vec![0i16; 160], 16000);
562        assert!(rs.process(&wrong).is_err());
563    }
564}