Skip to main content

wavekat_core/
audio.rs

1use std::borrow::Cow;
2
3/// A frame of audio samples with associated sample rate.
4///
5/// `AudioFrame` is the standard audio input type across the WaveKat ecosystem.
6/// It stores samples as f32 normalized to `[-1.0, 1.0]`, regardless of the
7/// original input format.
8///
9/// Construct via [`AudioFrame::new`], which accepts both `&[f32]` (zero-copy)
10/// and `&[i16]` (converts once) through the [`IntoSamples`] trait.
11///
12/// # Examples
13///
14/// ```
15/// use wavekat_core::AudioFrame;
16///
17/// // f32 input — zero-copy via Cow::Borrowed
18/// let samples = [0.1f32, -0.2, 0.3];
19/// let frame = AudioFrame::new(&samples, 16000);
20/// assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
21///
22/// // i16 input — normalized to f32 [-1.0, 1.0]
23/// let samples = [i16::MAX, 0, i16::MIN];
24/// let frame = AudioFrame::new(&samples, 16000);
25/// assert!((frame.samples()[0] - 1.0).abs() < 0.001);
26/// ```
27#[derive(Debug, Clone)]
28pub struct AudioFrame<'a> {
29    samples: Cow<'a, [f32]>,
30    sample_rate: u32,
31}
32
33impl<'a> AudioFrame<'a> {
34    /// Create a new audio frame from any supported sample type.
35    ///
36    /// Accepts `&[f32]` (zero-copy) or `&[i16]` (converts to normalized f32).
37    pub fn new(samples: impl IntoSamples<'a>, sample_rate: u32) -> Self {
38        Self {
39            samples: samples.into_samples(),
40            sample_rate,
41        }
42    }
43
44    /// The audio samples as f32 normalized to `[-1.0, 1.0]`.
45    pub fn samples(&self) -> &[f32] {
46        &self.samples
47    }
48
49    /// Sample rate in Hz (e.g. 16000).
50    pub fn sample_rate(&self) -> u32 {
51        self.sample_rate
52    }
53
54    /// Number of samples in the frame.
55    pub fn len(&self) -> usize {
56        self.samples.len()
57    }
58
59    /// Returns `true` if the frame contains no samples.
60    pub fn is_empty(&self) -> bool {
61        self.samples.is_empty()
62    }
63
64    /// Duration of this frame in seconds.
65    pub fn duration_secs(&self) -> f64 {
66        self.samples.len() as f64 / self.sample_rate as f64
67    }
68
69    /// Consume the frame and return the owned samples.
70    pub fn into_owned(self) -> AudioFrame<'static> {
71        AudioFrame {
72            samples: Cow::Owned(self.samples.into_owned()),
73            sample_rate: self.sample_rate,
74        }
75    }
76}
77
78impl AudioFrame<'static> {
79    /// Construct an owned frame directly from a `Vec<f32>`.
80    ///
81    /// Zero-copy — wraps the vec as `Cow::Owned` without cloning.
82    /// Intended for audio producers (TTS, ASR) that generate owned data.
83    ///
84    /// # Example
85    ///
86    /// ```
87    /// use wavekat_core::AudioFrame;
88    ///
89    /// let samples = vec![0.5f32, -0.5, 0.3];
90    /// let frame = AudioFrame::from_vec(samples, 24000);
91    /// assert_eq!(frame.sample_rate(), 24000);
92    /// assert_eq!(frame.len(), 3);
93    /// ```
94    pub fn from_vec(samples: Vec<f32>, sample_rate: u32) -> Self {
95        Self {
96            samples: Cow::Owned(samples),
97            sample_rate,
98        }
99    }
100}
101
102#[cfg(feature = "resample")]
103impl AudioFrame<'_> {
104    /// Resample this frame to a different sample rate.
105    ///
106    /// Returns a new owned `AudioFrame` at `target_rate`. If the frame is
107    /// already at the target rate, returns a clone without touching the
108    /// resampler.
109    ///
110    /// Uses high-quality sinc interpolation via [`rubato`].
111    ///
112    /// # Errors
113    ///
114    /// Returns [`CoreError::Audio`] if the resampler cannot be constructed
115    /// (e.g. zero sample rate) or if processing fails.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use wavekat_core::AudioFrame;
121    ///
122    /// let frame = AudioFrame::from_vec(vec![0.0f32; 4410], 44100);
123    /// let resampled = frame.resample(16000).unwrap();
124    /// assert_eq!(resampled.sample_rate(), 16000);
125    /// ```
126    pub fn resample(&self, target_rate: u32) -> Result<AudioFrame<'static>, crate::CoreError> {
127        use rubato::audioadapter_buffers::direct::InterleavedSlice;
128        use rubato::Resampler;
129
130        if self.sample_rate == target_rate {
131            return Ok(self.clone().into_owned());
132        }
133
134        if self.is_empty() {
135            return Ok(AudioFrame::from_vec(Vec::new(), target_rate));
136        }
137
138        let nbr_input_frames = self.samples.len();
139        // Match chunk size to input when shorter than the default — avoids
140        // wasting work padding a 160-sample G.711 frame up to 1024 samples.
141        let chunk_size = nbr_input_frames.min(1024);
142        let mut resampler = build_sinc_resampler(self.sample_rate, target_rate, chunk_size)?;
143
144        // Ask rubato exactly how much output space `process_all_into_buffer`
145        // needs — it accounts for the per-chunk pad-up, the resampler's
146        // internal delay, and the input-length-times-ratio expected output.
147        let out_len = resampler.process_all_needed_output_len(nbr_input_frames);
148        let mut outdata = vec![0.0f32; out_len];
149
150        let input_adapter = InterleavedSlice::new(self.samples.as_ref(), 1, nbr_input_frames)
151            .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
152        let mut output_adapter = InterleavedSlice::new_mut(&mut outdata, 1, out_len)
153            .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
154
155        let (_in_consumed, out_produced) = resampler
156            .process_all_into_buffer(&input_adapter, &mut output_adapter, nbr_input_frames, None)
157            .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
158
159        outdata.truncate(out_produced);
160        Ok(AudioFrame::from_vec(outdata, target_rate))
161    }
162}
163
164/// Shared rubato builder used by both [`AudioFrame::resample`] and
165/// [`StreamingResampler`]. Keeps the sinc parameters (and the version
166/// bumps that come with rubato API churn) in one place.
167#[cfg(feature = "resample")]
168fn build_sinc_resampler(
169    source_rate: u32,
170    target_rate: u32,
171    chunk_size: usize,
172) -> Result<rubato::Async<f32>, crate::CoreError> {
173    use rubato::{
174        Async, FixedAsync, SincInterpolationParameters, SincInterpolationType, WindowFunction,
175    };
176
177    if source_rate == 0 || target_rate == 0 {
178        return Err(crate::CoreError::Audio(
179            "sample rate must be non-zero".into(),
180        ));
181    }
182    if chunk_size == 0 {
183        return Err(crate::CoreError::Audio(
184            "chunk_size must be non-zero".into(),
185        ));
186    }
187
188    let params = SincInterpolationParameters {
189        sinc_len: 256,
190        f_cutoff: 0.95,
191        interpolation: SincInterpolationType::Cubic,
192        oversampling_factor: 128,
193        window: WindowFunction::BlackmanHarris2,
194    };
195    let ratio = target_rate as f64 / source_rate as f64;
196    Async::<f32>::new_sinc(ratio, 1.0, &params, chunk_size, 1, FixedAsync::Input)
197        .map_err(|e| crate::CoreError::Audio(e.to_string()))
198}
199
200/// Stateful streaming resampler.
201///
202/// [`AudioFrame::resample`] is convenient but constructs a fresh rubato
203/// resampler per call. For real-time pipelines that hand the resampler
204/// short frames (e.g. 20 ms G.711 packets off an RTP socket) the per-call
205/// resampler has no state to carry across frame boundaries, and sinc
206/// reconstruction produces audible edge artifacts at the frame rate —
207/// 50 Hz for 20 ms packets, perceived as continuous noise/buzz over the
208/// voice. `StreamingResampler` builds rubato once at stream open and
209/// reuses its internal filter state for every call, so output samples
210/// stitch together cleanly.
211///
212/// Build it with [`StreamingResampler::new`], then call
213/// [`process`](Self::process) for each arriving block of audio. Samples
214/// accumulate inside the resampler until a full `chunk_size` is ready,
215/// then a chunk's worth of output is appended to the caller's buffer.
216///
217/// If `source_rate == target_rate`, `process` becomes a pure copy and
218/// `chunk_size` is ignored.
219///
220/// # Example
221///
222/// ```
223/// use wavekat_core::StreamingResampler;
224///
225/// // 8 kHz → 44.1 kHz, 160-sample input chunks (matches 20 ms G.711).
226/// let mut resampler = StreamingResampler::new(8000, 44100, 160).unwrap();
227///
228/// let mut out = Vec::new();
229/// for _packet in 0..5 {
230///     let input = vec![0.0f32; 160]; // 20 ms of silence per packet
231///     resampler.process(&input, &mut out).unwrap();
232/// }
233/// // Five 160-sample inputs at 8 kHz expand to roughly 5 × 882 samples
234/// // at 44.1 kHz (the exact count depends on rubato's edge handling).
235/// assert!(out.len() > 4000);
236/// ```
237#[cfg(feature = "resample")]
238pub struct StreamingResampler {
239    // `None` when source_rate == target_rate (pass-through fast path).
240    inner: Option<rubato::Async<f32>>,
241    source_rate: u32,
242    target_rate: u32,
243    chunk_size: usize,
244    // Accumulates partial input across calls until we have `chunk_size`
245    // samples for the next rubato step.
246    input_buf: Vec<f32>,
247    // Reusable scratch sized to `output_frames_max()` so we don't
248    // re-allocate on every chunk.
249    output_buf: Vec<f32>,
250}
251
252#[cfg(feature = "resample")]
253impl StreamingResampler {
254    /// Build a streaming resampler.
255    ///
256    /// `chunk_size` is how many input samples are processed per internal
257    /// rubato step. Match it to the natural arrival size of your input
258    /// — e.g. 160 for 20 ms G.711 frames at 8 kHz. Smaller chunks mean
259    /// lower latency; larger chunks are marginally more efficient.
260    ///
261    /// Returns [`CoreError::Audio`] if the resampler cannot be built
262    /// (zero rate, zero chunk size, or rubato rejects the ratio).
263    pub fn new(
264        source_rate: u32,
265        target_rate: u32,
266        chunk_size: usize,
267    ) -> Result<Self, crate::CoreError> {
268        if source_rate == target_rate {
269            // Pass-through still validates the rates so calling code
270            // can't smuggle a zero rate past us.
271            if source_rate == 0 {
272                return Err(crate::CoreError::Audio(
273                    "sample rate must be non-zero".into(),
274                ));
275            }
276            return Ok(Self {
277                inner: None,
278                source_rate,
279                target_rate,
280                chunk_size,
281                input_buf: Vec::new(),
282                output_buf: Vec::new(),
283            });
284        }
285
286        let inner = build_sinc_resampler(source_rate, target_rate, chunk_size)?;
287        let out_max = {
288            use rubato::Resampler;
289            inner.output_frames_max()
290        };
291        Ok(Self {
292            inner: Some(inner),
293            source_rate,
294            target_rate,
295            chunk_size,
296            input_buf: Vec::with_capacity(chunk_size),
297            output_buf: vec![0.0; out_max],
298        })
299    }
300
301    /// Source sample rate this resampler was built for.
302    pub fn source_rate(&self) -> u32 {
303        self.source_rate
304    }
305
306    /// Target sample rate this resampler emits.
307    pub fn target_rate(&self) -> u32 {
308        self.target_rate
309    }
310
311    /// Input chunk size — how many samples per internal step.
312    pub fn chunk_size(&self) -> usize {
313        self.chunk_size
314    }
315
316    /// Resample `input` and append the output samples to `out`.
317    ///
318    /// Input is buffered internally until a full `chunk_size` has been
319    /// received; partial chunks remain buffered until the next call.
320    /// State is carried across calls so there are no boundary artifacts
321    /// — feeding two adjacent 160-sample chunks is equivalent to
322    /// feeding one 320-sample chunk (modulo the resampler's group
323    /// delay, paid once at the start of the stream).
324    pub fn process(&mut self, input: &[f32], out: &mut Vec<f32>) -> Result<(), crate::CoreError> {
325        let Some(inner) = self.inner.as_mut() else {
326            out.extend_from_slice(input);
327            return Ok(());
328        };
329        use rubato::audioadapter_buffers::direct::InterleavedSlice;
330        use rubato::Resampler;
331
332        let mut remaining = input;
333        while !remaining.is_empty() {
334            let need = self.chunk_size - self.input_buf.len();
335            let take = need.min(remaining.len());
336            self.input_buf.extend_from_slice(&remaining[..take]);
337            remaining = &remaining[take..];
338
339            if self.input_buf.len() < self.chunk_size {
340                break;
341            }
342
343            let in_adapter = InterleavedSlice::new(&self.input_buf[..], 1, self.chunk_size)
344                .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
345            let out_buf_len = self.output_buf.len();
346            let mut out_adapter =
347                InterleavedSlice::new_mut(&mut self.output_buf[..], 1, out_buf_len)
348                    .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
349            let (_in_used, out_produced) = inner
350                .process_into_buffer(&in_adapter, &mut out_adapter, None)
351                .map_err(|e| crate::CoreError::Audio(e.to_string()))?;
352            out.extend_from_slice(&self.output_buf[..out_produced]);
353            self.input_buf.clear();
354        }
355        Ok(())
356    }
357}
358
359#[cfg(feature = "wav")]
360impl AudioFrame<'_> {
361    /// Write this frame to a WAV file at `path`.
362    ///
363    /// Always writes mono f32 PCM at the frame's native sample rate.
364    ///
365    /// # Example
366    ///
367    /// ```no_run
368    /// use wavekat_core::AudioFrame;
369    ///
370    /// let frame = AudioFrame::from_vec(vec![0.0f32; 16000], 16000);
371    /// frame.write_wav("output.wav").unwrap();
372    /// ```
373    pub fn write_wav(&self, path: impl AsRef<std::path::Path>) -> Result<(), crate::CoreError> {
374        let spec = hound::WavSpec {
375            channels: 1,
376            sample_rate: self.sample_rate,
377            bits_per_sample: 32,
378            sample_format: hound::SampleFormat::Float,
379        };
380        let mut writer = hound::WavWriter::create(path, spec)?;
381        for &sample in self.samples() {
382            writer.write_sample(sample)?;
383        }
384        writer.finalize()?;
385        Ok(())
386    }
387}
388
389#[cfg(feature = "wav")]
390impl AudioFrame<'static> {
391    /// Read a mono WAV file and return an owned `AudioFrame`.
392    ///
393    /// Accepts f32, i16, and G.711 (A-law / μ-law) WAV files. i16 samples
394    /// are normalised to `[-1.0, 1.0]` (divided by 32768); G.711 bytes are
395    /// expanded to 16-bit PCM first, then normalised the same way.
396    ///
397    /// # Example
398    ///
399    /// ```no_run
400    /// use wavekat_core::AudioFrame;
401    ///
402    /// let frame = AudioFrame::from_wav("input.wav").unwrap();
403    /// println!("{} Hz, {} samples", frame.sample_rate(), frame.len());
404    /// ```
405    pub fn from_wav(path: impl AsRef<std::path::Path>) -> Result<Self, crate::CoreError> {
406        let bytes = std::fs::read(path)?;
407        // hound only parses integer-PCM and float WAVs, but telephony
408        // tooling commonly emits G.711 WAVs (format tag 6 = A-law,
409        // 7 = μ-law) — sniff the fmt chunk and decode those ourselves.
410        if let Some((samples, sample_rate)) = g711_wav_samples(&bytes)? {
411            return Ok(AudioFrame::from_vec(samples, sample_rate));
412        }
413        let mut reader = hound::WavReader::new(std::io::Cursor::new(bytes))?;
414        let spec = reader.spec();
415        let sample_rate = spec.sample_rate;
416        let samples: Vec<f32> = match spec.sample_format {
417            hound::SampleFormat::Float => reader.samples::<f32>().collect::<Result<_, _>>()?,
418            hound::SampleFormat::Int => reader
419                .samples::<i16>()
420                .map(|s| s.map(|v| v as f32 / 32768.0))
421                .collect::<Result<_, _>>()?,
422        };
423        Ok(AudioFrame::from_vec(samples, sample_rate))
424    }
425}
426
427/// WAVE format tags for the two G.711 companding laws (RFC 2361).
428#[cfg(feature = "wav")]
429const WAVE_FORMAT_ALAW: u16 = 0x0006;
430#[cfg(feature = "wav")]
431const WAVE_FORMAT_MULAW: u16 = 0x0007;
432
433/// Return the payload of the first RIFF chunk named `id`, or `None` if the
434/// bytes are not a RIFF/WAVE file or the chunk is absent. A declared chunk
435/// size that runs past the end of the file is clamped to the bytes present.
436#[cfg(feature = "wav")]
437fn riff_chunk<'a>(bytes: &'a [u8], id: &[u8; 4]) -> Option<&'a [u8]> {
438    if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
439        return None;
440    }
441    let mut pos = 12;
442    while pos + 8 <= bytes.len() {
443        let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().ok()?) as usize;
444        let start = pos + 8;
445        if &bytes[pos..pos + 4] == id {
446            return Some(&bytes[start..start.checked_add(size)?.min(bytes.len())]);
447        }
448        // Chunks are word-aligned: an odd size is followed by a pad byte.
449        pos = start.checked_add(size)?.checked_add(size & 1)?;
450    }
451    None
452}
453
454/// If `bytes` are a G.711 WAV (format tag 6 or 7), decode the data chunk to
455/// normalised f32 samples and return them with the sample rate. Returns
456/// `Ok(None)` for anything that isn't G.711 WAV — including non-RIFF bytes —
457/// so the caller can fall through to hound (whose parse errors are more
458/// descriptive than anything this sniffer could produce).
459#[cfg(feature = "wav")]
460fn g711_wav_samples(bytes: &[u8]) -> Result<Option<(Vec<f32>, u32)>, crate::CoreError> {
461    let Some(fmt) = riff_chunk(bytes, b"fmt ") else {
462        return Ok(None);
463    };
464    if fmt.len() < 16 {
465        return Ok(None);
466    }
467    let decode: fn(u8) -> i16 = match u16::from_le_bytes([fmt[0], fmt[1]]) {
468        WAVE_FORMAT_ALAW => crate::codec::g711::alaw_to_linear,
469        WAVE_FORMAT_MULAW => crate::codec::g711::ulaw_to_linear,
470        _ => return Ok(None),
471    };
472    let sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
473    let data = riff_chunk(bytes, b"data")
474        .ok_or_else(|| crate::CoreError::Audio("G.711 WAV has no data chunk".to_string()))?;
475    let samples = data.iter().map(|&b| decode(b) as f32 / 32768.0).collect();
476    Ok(Some((samples, sample_rate)))
477}
478
479/// Trait for types that can be converted into audio samples.
480///
481/// Implemented for `&[f32]` (zero-copy) and `&[i16]` (normalized conversion).
482pub trait IntoSamples<'a> {
483    /// Convert into f32 samples normalized to `[-1.0, 1.0]`.
484    fn into_samples(self) -> Cow<'a, [f32]>;
485}
486
487impl<'a> IntoSamples<'a> for &'a [f32] {
488    #[inline]
489    fn into_samples(self) -> Cow<'a, [f32]> {
490        Cow::Borrowed(self)
491    }
492}
493
494impl<'a> IntoSamples<'a> for &'a Vec<f32> {
495    #[inline]
496    fn into_samples(self) -> Cow<'a, [f32]> {
497        Cow::Borrowed(self.as_slice())
498    }
499}
500
501impl<'a, const N: usize> IntoSamples<'a> for &'a [f32; N] {
502    #[inline]
503    fn into_samples(self) -> Cow<'a, [f32]> {
504        Cow::Borrowed(self.as_slice())
505    }
506}
507
508impl<'a> IntoSamples<'a> for &'a [i16] {
509    #[inline]
510    fn into_samples(self) -> Cow<'a, [f32]> {
511        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
512    }
513}
514
515impl<'a> IntoSamples<'a> for &'a Vec<i16> {
516    #[inline]
517    fn into_samples(self) -> Cow<'a, [f32]> {
518        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
519    }
520}
521
522impl<'a, const N: usize> IntoSamples<'a> for &'a [i16; N] {
523    #[inline]
524    fn into_samples(self) -> Cow<'a, [f32]> {
525        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn f32_is_zero_copy() {
535        let samples = vec![0.1f32, -0.2, 0.3];
536        let frame = AudioFrame::new(samples.as_slice(), 16000);
537        // Cow::Borrowed — the pointer should be the same
538        assert!(matches!(frame.samples, Cow::Borrowed(_)));
539        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
540    }
541
542    #[test]
543    fn i16_normalizes_to_f32() {
544        let samples: Vec<i16> = vec![0, 16384, -16384, i16::MAX, i16::MIN];
545        let frame = AudioFrame::new(samples.as_slice(), 16000);
546        assert!(matches!(frame.samples, Cow::Owned(_)));
547
548        let s = frame.samples();
549        assert!((s[0] - 0.0).abs() < f32::EPSILON);
550        assert!((s[1] - 0.5).abs() < 0.001);
551        assert!((s[2] - -0.5).abs() < 0.001);
552        assert!((s[3] - (i16::MAX as f32 / 32768.0)).abs() < f32::EPSILON);
553        assert!((s[4] - -1.0).abs() < f32::EPSILON);
554    }
555
556    #[test]
557    fn metadata() {
558        let samples = vec![0.0f32; 160];
559        let frame = AudioFrame::new(samples.as_slice(), 16000);
560        assert_eq!(frame.sample_rate(), 16000);
561        assert_eq!(frame.len(), 160);
562        assert!(!frame.is_empty());
563        assert!((frame.duration_secs() - 0.01).abs() < 1e-9);
564    }
565
566    #[test]
567    fn empty_frame() {
568        let samples: &[f32] = &[];
569        let frame = AudioFrame::new(samples, 16000);
570        assert!(frame.is_empty());
571        assert_eq!(frame.len(), 0);
572    }
573
574    #[test]
575    fn into_owned() {
576        let samples = vec![0.5f32, -0.5];
577        let frame = AudioFrame::new(samples.as_slice(), 16000);
578        let owned: AudioFrame<'static> = frame.into_owned();
579        assert_eq!(owned.samples(), &[0.5, -0.5]);
580        assert_eq!(owned.sample_rate(), 16000);
581    }
582
583    #[cfg(feature = "wav")]
584    #[test]
585    fn wav_read_i16() {
586        // Write an i16 WAV directly via hound, then read it with from_wav.
587        let path = std::env::temp_dir().join("wavekat_test_i16.wav");
588        let spec = hound::WavSpec {
589            channels: 1,
590            sample_rate: 16000,
591            bits_per_sample: 16,
592            sample_format: hound::SampleFormat::Int,
593        };
594        let i16_samples: &[i16] = &[0, i16::MAX, i16::MIN, 16384];
595        let mut writer = hound::WavWriter::create(&path, spec).unwrap();
596        for &s in i16_samples {
597            writer.write_sample(s).unwrap();
598        }
599        writer.finalize().unwrap();
600
601        let frame = AudioFrame::from_wav(&path).unwrap();
602        assert_eq!(frame.sample_rate(), 16000);
603        assert_eq!(frame.len(), 4);
604        let s = frame.samples();
605        assert!((s[0] - 0.0).abs() < 1e-6);
606        assert!((s[1] - (i16::MAX as f32 / 32768.0)).abs() < 1e-6);
607        assert!((s[2] - -1.0).abs() < 1e-6);
608        assert!((s[3] - 0.5).abs() < 1e-4);
609    }
610
611    #[cfg(feature = "wav")]
612    #[test]
613    fn wav_round_trip() {
614        let original = AudioFrame::from_vec(vec![0.5f32, -0.5, 0.0, 1.0], 16000);
615        let path = std::env::temp_dir().join("wavekat_test.wav");
616        original.write_wav(&path).unwrap();
617        let loaded = AudioFrame::from_wav(&path).unwrap();
618        assert_eq!(loaded.sample_rate(), 16000);
619        for (a, b) in original.samples().iter().zip(loaded.samples()) {
620            assert!((a - b).abs() < 1e-6, "sample mismatch: {a} vs {b}");
621        }
622    }
623
624    /// Build a G.711 WAV the way telephony encoders do: 18-byte fmt chunk
625    /// (cbSize = 0) followed by a `fact` chunk, so reading also exercises
626    /// unknown-chunk skipping in the RIFF walker.
627    #[cfg(feature = "wav")]
628    fn write_g711_wav(path: &std::path::Path, format_tag: u16, sample_rate: u32, data: &[u8]) {
629        let mut bytes = Vec::new();
630        bytes.extend_from_slice(b"RIFF");
631        bytes.extend_from_slice(&0u32.to_le_bytes()); // patched below
632        bytes.extend_from_slice(b"WAVE");
633        bytes.extend_from_slice(b"fmt ");
634        bytes.extend_from_slice(&18u32.to_le_bytes());
635        bytes.extend_from_slice(&format_tag.to_le_bytes());
636        bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
637        bytes.extend_from_slice(&sample_rate.to_le_bytes());
638        bytes.extend_from_slice(&sample_rate.to_le_bytes()); // byte rate: 1 byte/sample
639        bytes.extend_from_slice(&1u16.to_le_bytes()); // block align
640        bytes.extend_from_slice(&8u16.to_le_bytes()); // bits per sample
641        bytes.extend_from_slice(&0u16.to_le_bytes()); // cbSize
642        bytes.extend_from_slice(b"fact");
643        bytes.extend_from_slice(&4u32.to_le_bytes());
644        bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
645        bytes.extend_from_slice(b"data");
646        bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
647        bytes.extend_from_slice(data);
648        if data.len() % 2 == 1 {
649            bytes.push(0); // RIFF chunks are word-aligned
650        }
651        let riff_size = (bytes.len() - 8) as u32;
652        bytes[4..8].copy_from_slice(&riff_size.to_le_bytes());
653        std::fs::write(path, bytes).unwrap();
654    }
655
656    #[cfg(feature = "wav")]
657    #[test]
658    fn wav_read_g711_ulaw() {
659        use crate::codec::g711;
660        let pcm: &[i16] = &[0, 1000, -1000, i16::MAX, i16::MIN + 1];
661        let encoded: Vec<u8> = pcm.iter().map(|&s| g711::linear_to_ulaw(s)).collect();
662        let path = std::env::temp_dir().join("wavekat_test_ulaw.wav");
663        write_g711_wav(&path, 0x0007, 8000, &encoded);
664
665        let frame = AudioFrame::from_wav(&path).unwrap();
666        assert_eq!(frame.sample_rate(), 8000);
667        assert_eq!(frame.len(), pcm.len());
668        for (got, &byte) in frame.samples().iter().zip(&encoded) {
669            let want = g711::ulaw_to_linear(byte) as f32 / 32768.0;
670            assert!(
671                (got - want).abs() < 1e-6,
672                "sample mismatch: {got} vs {want}"
673            );
674        }
675    }
676
677    #[cfg(feature = "wav")]
678    #[test]
679    fn wav_read_g711_alaw() {
680        use crate::codec::g711;
681        let pcm: &[i16] = &[0, 1000, -1000, i16::MAX, i16::MIN + 1];
682        let encoded: Vec<u8> = pcm.iter().map(|&s| g711::linear_to_alaw(s)).collect();
683        let path = std::env::temp_dir().join("wavekat_test_alaw.wav");
684        write_g711_wav(&path, 0x0006, 8000, &encoded);
685
686        let frame = AudioFrame::from_wav(&path).unwrap();
687        assert_eq!(frame.sample_rate(), 8000);
688        assert_eq!(frame.len(), pcm.len());
689        for (got, &byte) in frame.samples().iter().zip(&encoded) {
690            let want = g711::alaw_to_linear(byte) as f32 / 32768.0;
691            assert!(
692                (got - want).abs() < 1e-6,
693                "sample mismatch: {got} vs {want}"
694            );
695        }
696    }
697
698    #[cfg(feature = "wav")]
699    #[test]
700    fn wav_read_g711_missing_data_chunk_errors() {
701        // fmt says μ-law but there is no data chunk at all.
702        let mut bytes = Vec::new();
703        bytes.extend_from_slice(b"RIFF");
704        bytes.extend_from_slice(&30u32.to_le_bytes());
705        bytes.extend_from_slice(b"WAVE");
706        bytes.extend_from_slice(b"fmt ");
707        bytes.extend_from_slice(&18u32.to_le_bytes());
708        bytes.extend_from_slice(&0x0007u16.to_le_bytes());
709        bytes.extend_from_slice(&1u16.to_le_bytes());
710        bytes.extend_from_slice(&8000u32.to_le_bytes());
711        bytes.extend_from_slice(&8000u32.to_le_bytes());
712        bytes.extend_from_slice(&1u16.to_le_bytes());
713        bytes.extend_from_slice(&8u16.to_le_bytes());
714        bytes.extend_from_slice(&0u16.to_le_bytes());
715        let path = std::env::temp_dir().join("wavekat_test_ulaw_nodata.wav");
716        std::fs::write(&path, bytes).unwrap();
717
718        let err = AudioFrame::from_wav(&path).unwrap_err();
719        assert!(
720            err.to_string().contains("data chunk"),
721            "unexpected error: {err}"
722        );
723    }
724
725    #[cfg(feature = "wav")]
726    #[test]
727    fn wav_read_truncated_fmt_chunk_falls_through_to_hound() {
728        // A fmt chunk shorter than the 16-byte minimum can't carry a format
729        // tag — the sniffer must not decode it as G.711, and hound rejects it.
730        let mut bytes = Vec::new();
731        bytes.extend_from_slice(b"RIFF");
732        bytes.extend_from_slice(&20u32.to_le_bytes());
733        bytes.extend_from_slice(b"WAVE");
734        bytes.extend_from_slice(b"fmt ");
735        bytes.extend_from_slice(&8u32.to_le_bytes());
736        bytes.extend_from_slice(&0x0007u16.to_le_bytes());
737        bytes.extend_from_slice(&1u16.to_le_bytes());
738        bytes.extend_from_slice(&8000u32.to_le_bytes());
739        let path = std::env::temp_dir().join("wavekat_test_short_fmt.wav");
740        std::fs::write(&path, bytes).unwrap();
741
742        assert!(AudioFrame::from_wav(&path).is_err());
743    }
744
745    #[cfg(feature = "wav")]
746    #[test]
747    fn wav_read_non_riff_bytes_still_error() {
748        // Not a WAV at all: the G.711 sniffer must pass it through to
749        // hound, which rejects it — not panic or return silence.
750        let path = std::env::temp_dir().join("wavekat_test_not_a_wav.wav");
751        std::fs::write(&path, b"these bytes are not a RIFF WAV").unwrap();
752        assert!(AudioFrame::from_wav(&path).is_err());
753    }
754
755    #[test]
756    fn from_vec_is_zero_copy() {
757        let samples = vec![0.5f32, -0.5];
758        let ptr = samples.as_ptr();
759        let frame = AudioFrame::from_vec(samples, 24000);
760        assert_eq!(frame.samples().as_ptr(), ptr);
761        assert_eq!(frame.sample_rate(), 24000);
762    }
763
764    #[test]
765    fn into_samples_vec_f32() {
766        let samples = vec![0.1f32, -0.2, 0.3];
767        let frame = AudioFrame::new(&samples, 16000);
768        assert!(matches!(frame.samples, Cow::Borrowed(_)));
769        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
770    }
771
772    #[test]
773    fn into_samples_array_f32() {
774        let samples = [0.1f32, -0.2, 0.3];
775        let frame = AudioFrame::new(&samples, 16000);
776        assert!(matches!(frame.samples, Cow::Borrowed(_)));
777        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
778    }
779
780    #[test]
781    fn into_samples_vec_i16() {
782        let samples: Vec<i16> = vec![0, 16384, i16::MIN];
783        let frame = AudioFrame::new(&samples, 16000);
784        assert!(matches!(frame.samples, Cow::Owned(_)));
785        let s = frame.samples();
786        assert!((s[0] - 0.0).abs() < f32::EPSILON);
787        assert!((s[1] - 0.5).abs() < 0.001);
788        assert!((s[2] - -1.0).abs() < f32::EPSILON);
789    }
790
791    #[test]
792    fn into_samples_array_i16() {
793        let samples: [i16; 3] = [0, 16384, i16::MIN];
794        let frame = AudioFrame::new(&samples, 16000);
795        assert!(matches!(frame.samples, Cow::Owned(_)));
796        let s = frame.samples();
797        assert!((s[0] - 0.0).abs() < f32::EPSILON);
798        assert!((s[1] - 0.5).abs() < 0.001);
799        assert!((s[2] - -1.0).abs() < f32::EPSILON);
800    }
801
802    #[cfg(feature = "resample")]
803    #[test]
804    fn resample_noop_same_rate() {
805        let samples = vec![0.1f32, -0.2, 0.3, 0.4, 0.5];
806        let frame = AudioFrame::from_vec(samples.clone(), 16000);
807        let resampled = frame.resample(16000).unwrap();
808        assert_eq!(resampled.sample_rate(), 16000);
809        assert_eq!(resampled.samples(), &samples[..]);
810    }
811
812    #[cfg(feature = "resample")]
813    #[test]
814    fn resample_empty_frame() {
815        let frame = AudioFrame::from_vec(Vec::new(), 44100);
816        let resampled = frame.resample(16000).unwrap();
817        assert_eq!(resampled.sample_rate(), 16000);
818        assert!(resampled.is_empty());
819    }
820
821    #[cfg(feature = "resample")]
822    #[test]
823    fn resample_downsample() {
824        // 1 second of silence at 48 kHz → 16 kHz
825        let frame = AudioFrame::from_vec(vec![0.0f32; 48000], 48000);
826        let resampled = frame.resample(16000).unwrap();
827        assert_eq!(resampled.sample_rate(), 16000);
828        // Should produce ~16000 samples (allow small tolerance from resampler)
829        let expected = 16000;
830        let tolerance = 50;
831        assert!(
832            (resampled.len() as i64 - expected as i64).unsigned_abs() < tolerance,
833            "expected ~{expected} samples, got {}",
834            resampled.len()
835        );
836    }
837
838    #[cfg(feature = "resample")]
839    #[test]
840    fn resample_upsample() {
841        // 1 second at 16 kHz → 24 kHz
842        let frame = AudioFrame::from_vec(vec![0.0f32; 16000], 16000);
843        let resampled = frame.resample(24000).unwrap();
844        assert_eq!(resampled.sample_rate(), 24000);
845        let expected = 24000;
846        let tolerance = 50;
847        assert!(
848            (resampled.len() as i64 - expected as i64).unsigned_abs() < tolerance,
849            "expected ~{expected} samples, got {}",
850            resampled.len()
851        );
852    }
853
854    #[cfg(feature = "resample")]
855    #[test]
856    fn resample_short_input_upsample_large_ratio() {
857        // The exact case from the wavekat-voice RTP path: a 20 ms G.711 frame
858        // (160 samples @ 8 kHz) upsampled to 44.1 kHz. Before the fix this
859        // returned `InsufficientOutputBufferSize`.
860        let frame = AudioFrame::from_vec(vec![0.0f32; 160], 8000);
861        let resampled = frame.resample(44_100).unwrap();
862        assert_eq!(resampled.sample_rate(), 44_100);
863        let expected = (160.0 * 44_100.0 / 8_000.0) as i64; // 882
864        let actual = resampled.len() as i64;
865        assert!(
866            (actual - expected).unsigned_abs() < 50,
867            "expected ~{expected} samples, got {actual}"
868        );
869    }
870
871    #[cfg(feature = "resample")]
872    #[test]
873    fn resample_short_input_upsample_small_ratio() {
874        // 160 samples @ 8 kHz → 16 kHz. Also failed before the fix even
875        // though the ratio is modest, because nbr_input_frames < chunk_size.
876        let frame = AudioFrame::from_vec(vec![0.0f32; 160], 8000);
877        let resampled = frame.resample(16_000).unwrap();
878        assert_eq!(resampled.sample_rate(), 16_000);
879        let expected: i64 = 320;
880        let actual = resampled.len() as i64;
881        assert!(
882            (actual - expected).unsigned_abs() < 50,
883            "expected ~{expected} samples, got {actual}"
884        );
885    }
886
887    #[cfg(feature = "resample")]
888    #[test]
889    fn resample_single_g711_frame_to_48k() {
890        // The other common device rate: 160 @ 8 kHz → 48 kHz.
891        let frame = AudioFrame::from_vec(vec![0.0f32; 160], 8000);
892        let resampled = frame.resample(48_000).unwrap();
893        assert_eq!(resampled.sample_rate(), 48_000);
894        let expected: i64 = 960;
895        let actual = resampled.len() as i64;
896        assert!(
897            (actual - expected).unsigned_abs() < 50,
898            "expected ~{expected} samples, got {actual}"
899        );
900    }
901
902    #[cfg(feature = "resample")]
903    #[test]
904    fn resample_preserves_sine_frequency() {
905        // Generate a 440 Hz sine at 44100 Hz, resample to 16000 Hz,
906        // then verify the dominant frequency is still ~440 Hz by
907        // checking zero-crossing rate.
908        let sr_in: u32 = 44100;
909        let sr_out: u32 = 16000;
910        let duration_secs = 1.0;
911        let freq = 440.0;
912        let n = (sr_in as f64 * duration_secs) as usize;
913        let samples: Vec<f32> = (0..n)
914            .map(|i| (2.0 * std::f64::consts::PI * freq * i as f64 / sr_in as f64).sin() as f32)
915            .collect();
916
917        let frame = AudioFrame::from_vec(samples, sr_in);
918        let resampled = frame.resample(sr_out).unwrap();
919
920        // Count zero crossings (sign changes)
921        let s = resampled.samples();
922        let crossings: usize = s
923            .windows(2)
924            .filter(|w| w[0].signum() != w[1].signum())
925            .count();
926        // A pure sine at f Hz has 2*f zero crossings per second
927        let measured_freq = crossings as f64 / (2.0 * duration_secs);
928        assert!(
929            (measured_freq - freq).abs() < 5.0,
930            "expected ~{freq} Hz, measured {measured_freq} Hz"
931        );
932    }
933
934    #[cfg(feature = "resample")]
935    #[test]
936    fn streaming_resampler_same_rate_is_passthrough() {
937        // No-op short-circuit: no resampler is built, no work is done,
938        // samples pass through verbatim. Guards against accidentally
939        // putting a same-rate stream through rubato (which adds group
940        // delay we don't want).
941        use crate::StreamingResampler;
942        let mut r = StreamingResampler::new(16000, 16000, 160).unwrap();
943        let input = vec![0.1, -0.2, 0.3, -0.4];
944        let mut out = Vec::new();
945        r.process(&input, &mut out).unwrap();
946        assert_eq!(out, input);
947    }
948
949    #[cfg(feature = "resample")]
950    #[test]
951    fn streaming_resampler_accessors_report_construction_args() {
952        use crate::StreamingResampler;
953        let r = StreamingResampler::new(8000, 44100, 160).unwrap();
954        assert_eq!(r.source_rate(), 8000);
955        assert_eq!(r.target_rate(), 44100);
956        assert_eq!(r.chunk_size(), 160);
957    }
958
959    #[cfg(feature = "resample")]
960    #[test]
961    fn streaming_resampler_short_input_chunked_calls() {
962        // The exact shape `wavekat-voice`'s RTP receive path drives:
963        // repeated 160-sample inputs at 8 kHz → 44.1 kHz. Each call
964        // produces ~882 output samples; total over N calls is ~N × 882
965        // (the first chunk may emit slightly less while rubato fills
966        // its filter delay).
967        use crate::StreamingResampler;
968        let mut r = StreamingResampler::new(8000, 44100, 160).unwrap();
969        let mut out = Vec::new();
970        for _ in 0..10 {
971            let input = vec![0.0f32; 160];
972            r.process(&input, &mut out).unwrap();
973        }
974        // 10 × 160 input @ 8k = 200 ms; @ 44.1k that's ~8820 samples.
975        // Allow generous tolerance for rubato's initial transient.
976        let expected = (10 * 160 * 44100 / 8000) as i64;
977        let actual = out.len() as i64;
978        assert!(
979            (actual - expected).unsigned_abs() < 2000,
980            "expected ~{expected} samples, got {actual}"
981        );
982    }
983
984    #[cfg(feature = "resample")]
985    #[test]
986    fn streaming_resampler_buffers_across_partial_calls() {
987        // Splitting an input across two `process` calls must produce
988        // the same output as one big call. Catches a regression where
989        // partial input is dropped on the floor instead of buffered.
990        use crate::StreamingResampler;
991        let input: Vec<f32> = (0..320).map(|i| (i as f32) * 0.01).collect();
992
993        let mut split_out = Vec::new();
994        let mut r1 = StreamingResampler::new(8000, 16000, 160).unwrap();
995        r1.process(&input[..50], &mut split_out).unwrap();
996        // No full chunk yet — buffered.
997        assert!(split_out.is_empty(), "no output before a full chunk");
998        r1.process(&input[50..], &mut split_out).unwrap();
999
1000        let mut whole_out = Vec::new();
1001        let mut r2 = StreamingResampler::new(8000, 16000, 160).unwrap();
1002        r2.process(&input, &mut whole_out).unwrap();
1003
1004        assert_eq!(
1005            split_out.len(),
1006            whole_out.len(),
1007            "split call must produce same number of samples as one-shot"
1008        );
1009        // The samples themselves must match too — same rubato state
1010        // either way.
1011        for (i, (a, b)) in split_out.iter().zip(whole_out.iter()).enumerate() {
1012            assert!(
1013                (a - b).abs() < 1e-6,
1014                "split vs whole differ at {i}: {a} vs {b}"
1015            );
1016        }
1017    }
1018
1019    #[cfg(feature = "resample")]
1020    #[test]
1021    fn streaming_resampler_avoids_per_frame_edge_artifacts() {
1022        // The motivating regression: a stateless per-call resampler
1023        // (i.e. `AudioFrame::resample` invoked on each 160-sample
1024        // chunk) produces edge artifacts at every chunk boundary,
1025        // because rubato assumes silence before t=0 and after t=N for
1026        // each isolated chunk — sinc reconstruction near the edges
1027        // sees an abrupt step.
1028        //
1029        // We don't compare against a reference signal (group-delay
1030        // offsets across approaches make sample-index alignment
1031        // unreliable). Instead we check the output's own *smoothness*:
1032        // a band-limited signal at the input rate, upsampled, produces
1033        // a band-limited output, so consecutive-sample deltas are
1034        // bounded by `2π × freq / sr_out`. Edge artifacts show up as
1035        // spikes in that consecutive delta — much larger than the
1036        // smooth bound.
1037        use crate::StreamingResampler;
1038        let sr_in: u32 = 8000;
1039        let sr_out: u32 = 44100;
1040        let chunks = 30;
1041        let chunk_size = 160;
1042
1043        // Mid-band sine that exercises the sinc filter without
1044        // touching the anti-aliasing edge.
1045        let freq = 600.0_f32;
1046        let signal: Vec<f32> = (0..chunks * chunk_size)
1047            .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sr_in as f32).sin())
1048            .collect();
1049
1050        // Streaming: state carried across calls.
1051        let mut streaming = StreamingResampler::new(sr_in, sr_out, chunk_size).unwrap();
1052        let mut streaming_out: Vec<f32> = Vec::new();
1053        for c in 0..chunks {
1054            streaming
1055                .process(
1056                    &signal[c * chunk_size..(c + 1) * chunk_size],
1057                    &mut streaming_out,
1058                )
1059                .unwrap();
1060        }
1061
1062        // Stateless per-chunk: fresh resampler every call (the bug).
1063        let mut stateless_out: Vec<f32> = Vec::new();
1064        for c in 0..chunks {
1065            let chunk =
1066                AudioFrame::from_vec(signal[c * chunk_size..(c + 1) * chunk_size].to_vec(), sr_in);
1067            let resampled = chunk.resample(sr_out).unwrap();
1068            stateless_out.extend_from_slice(resampled.samples());
1069        }
1070
1071        // Skip the initial group-delay transient and the trailing
1072        // tail; we want steady-state behavior.
1073        let skip = 1500;
1074        let tail = 500;
1075
1076        // Smooth bound: for a 600 Hz sine sampled at 44.1 kHz, the
1077        // maximum delta between adjacent samples is ~ 2π × 600 / 44100
1078        // ≈ 0.085. Allow generous headroom (4×) before we call a delta
1079        // "spiky."
1080        let expected_max_delta = 2.0 * std::f32::consts::PI * freq / sr_out as f32;
1081        let spike_threshold = expected_max_delta * 4.0;
1082
1083        let count_spikes = |samples: &[f32], skip: usize, tail: usize| -> usize {
1084            samples[skip..samples.len() - tail]
1085                .windows(2)
1086                .filter(|w| (w[1] - w[0]).abs() > spike_threshold)
1087                .count()
1088        };
1089
1090        let streaming_spikes = count_spikes(&streaming_out, skip, tail);
1091        let stateless_spikes = count_spikes(&stateless_out, skip, tail);
1092
1093        // Streaming output should be smooth: essentially zero spikes
1094        // in steady state.
1095        assert!(
1096            streaming_spikes < 10,
1097            "streaming output should be smooth, found {streaming_spikes} sample-delta spikes (threshold {spike_threshold})"
1098        );
1099        // Stateless per-chunk output should have many spikes — one
1100        // per chunk boundary, at minimum. We have ~25 chunks in the
1101        // compared range, so expect at least 25 spikes.
1102        assert!(
1103            stateless_spikes > streaming_spikes * 5,
1104            "stateless per-chunk should have far more spikes than streaming; got stateless={stateless_spikes}, streaming={streaming_spikes}"
1105        );
1106    }
1107
1108    #[cfg(feature = "resample")]
1109    #[test]
1110    fn streaming_resampler_rejects_zero_rate() {
1111        use crate::StreamingResampler;
1112        assert!(StreamingResampler::new(0, 16000, 160).is_err());
1113        assert!(StreamingResampler::new(16000, 0, 160).is_err());
1114        assert!(StreamingResampler::new(0, 0, 160).is_err());
1115    }
1116
1117    #[cfg(feature = "resample")]
1118    #[test]
1119    fn streaming_resampler_rejects_zero_chunk_size() {
1120        use crate::StreamingResampler;
1121        assert!(StreamingResampler::new(8000, 16000, 0).is_err());
1122    }
1123}