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 = "wav")]
103impl AudioFrame<'_> {
104    /// Write this frame to a WAV file at `path`.
105    ///
106    /// Always writes mono f32 PCM at the frame's native sample rate.
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use wavekat_core::AudioFrame;
112    ///
113    /// let frame = AudioFrame::from_vec(vec![0.0f32; 16000], 16000);
114    /// frame.write_wav("output.wav").unwrap();
115    /// ```
116    pub fn write_wav(&self, path: impl AsRef<std::path::Path>) -> Result<(), crate::CoreError> {
117        let spec = hound::WavSpec {
118            channels: 1,
119            sample_rate: self.sample_rate,
120            bits_per_sample: 32,
121            sample_format: hound::SampleFormat::Float,
122        };
123        let mut writer = hound::WavWriter::create(path, spec)?;
124        for &sample in self.samples() {
125            writer.write_sample(sample)?;
126        }
127        writer.finalize()?;
128        Ok(())
129    }
130}
131
132#[cfg(feature = "wav")]
133impl AudioFrame<'static> {
134    /// Read a mono WAV file and return an owned `AudioFrame`.
135    ///
136    /// Accepts both f32 and i16 WAV files. i16 samples are normalised to
137    /// `[-1.0, 1.0]` (divided by 32768).
138    ///
139    /// # Example
140    ///
141    /// ```no_run
142    /// use wavekat_core::AudioFrame;
143    ///
144    /// let frame = AudioFrame::from_wav("input.wav").unwrap();
145    /// println!("{} Hz, {} samples", frame.sample_rate(), frame.len());
146    /// ```
147    pub fn from_wav(path: impl AsRef<std::path::Path>) -> Result<Self, crate::CoreError> {
148        let mut reader = hound::WavReader::open(path)?;
149        let spec = reader.spec();
150        let sample_rate = spec.sample_rate;
151        let samples: Vec<f32> = match spec.sample_format {
152            hound::SampleFormat::Float => reader.samples::<f32>().collect::<Result<_, _>>()?,
153            hound::SampleFormat::Int => reader
154                .samples::<i16>()
155                .map(|s| s.map(|v| v as f32 / 32768.0))
156                .collect::<Result<_, _>>()?,
157        };
158        Ok(AudioFrame::from_vec(samples, sample_rate))
159    }
160}
161
162/// Trait for types that can be converted into audio samples.
163///
164/// Implemented for `&[f32]` (zero-copy) and `&[i16]` (normalized conversion).
165pub trait IntoSamples<'a> {
166    /// Convert into f32 samples normalized to `[-1.0, 1.0]`.
167    fn into_samples(self) -> Cow<'a, [f32]>;
168}
169
170impl<'a> IntoSamples<'a> for &'a [f32] {
171    #[inline]
172    fn into_samples(self) -> Cow<'a, [f32]> {
173        Cow::Borrowed(self)
174    }
175}
176
177impl<'a> IntoSamples<'a> for &'a Vec<f32> {
178    #[inline]
179    fn into_samples(self) -> Cow<'a, [f32]> {
180        Cow::Borrowed(self.as_slice())
181    }
182}
183
184impl<'a, const N: usize> IntoSamples<'a> for &'a [f32; N] {
185    #[inline]
186    fn into_samples(self) -> Cow<'a, [f32]> {
187        Cow::Borrowed(self.as_slice())
188    }
189}
190
191impl<'a> IntoSamples<'a> for &'a [i16] {
192    #[inline]
193    fn into_samples(self) -> Cow<'a, [f32]> {
194        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
195    }
196}
197
198impl<'a> IntoSamples<'a> for &'a Vec<i16> {
199    #[inline]
200    fn into_samples(self) -> Cow<'a, [f32]> {
201        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
202    }
203}
204
205impl<'a, const N: usize> IntoSamples<'a> for &'a [i16; N] {
206    #[inline]
207    fn into_samples(self) -> Cow<'a, [f32]> {
208        Cow::Owned(self.iter().map(|&s| s as f32 / 32768.0).collect())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn f32_is_zero_copy() {
218        let samples = vec![0.1f32, -0.2, 0.3];
219        let frame = AudioFrame::new(samples.as_slice(), 16000);
220        // Cow::Borrowed — the pointer should be the same
221        assert!(matches!(frame.samples, Cow::Borrowed(_)));
222        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
223    }
224
225    #[test]
226    fn i16_normalizes_to_f32() {
227        let samples: Vec<i16> = vec![0, 16384, -16384, i16::MAX, i16::MIN];
228        let frame = AudioFrame::new(samples.as_slice(), 16000);
229        assert!(matches!(frame.samples, Cow::Owned(_)));
230
231        let s = frame.samples();
232        assert!((s[0] - 0.0).abs() < f32::EPSILON);
233        assert!((s[1] - 0.5).abs() < 0.001);
234        assert!((s[2] - -0.5).abs() < 0.001);
235        assert!((s[3] - (i16::MAX as f32 / 32768.0)).abs() < f32::EPSILON);
236        assert!((s[4] - -1.0).abs() < f32::EPSILON);
237    }
238
239    #[test]
240    fn metadata() {
241        let samples = vec![0.0f32; 160];
242        let frame = AudioFrame::new(samples.as_slice(), 16000);
243        assert_eq!(frame.sample_rate(), 16000);
244        assert_eq!(frame.len(), 160);
245        assert!(!frame.is_empty());
246        assert!((frame.duration_secs() - 0.01).abs() < 1e-9);
247    }
248
249    #[test]
250    fn empty_frame() {
251        let samples: &[f32] = &[];
252        let frame = AudioFrame::new(samples, 16000);
253        assert!(frame.is_empty());
254        assert_eq!(frame.len(), 0);
255    }
256
257    #[test]
258    fn into_owned() {
259        let samples = vec![0.5f32, -0.5];
260        let frame = AudioFrame::new(samples.as_slice(), 16000);
261        let owned: AudioFrame<'static> = frame.into_owned();
262        assert_eq!(owned.samples(), &[0.5, -0.5]);
263        assert_eq!(owned.sample_rate(), 16000);
264    }
265
266    #[cfg(feature = "wav")]
267    #[test]
268    fn wav_read_i16() {
269        // Write an i16 WAV directly via hound, then read it with from_wav.
270        let path = std::env::temp_dir().join("wavekat_test_i16.wav");
271        let spec = hound::WavSpec {
272            channels: 1,
273            sample_rate: 16000,
274            bits_per_sample: 16,
275            sample_format: hound::SampleFormat::Int,
276        };
277        let i16_samples: &[i16] = &[0, i16::MAX, i16::MIN, 16384];
278        let mut writer = hound::WavWriter::create(&path, spec).unwrap();
279        for &s in i16_samples {
280            writer.write_sample(s).unwrap();
281        }
282        writer.finalize().unwrap();
283
284        let frame = AudioFrame::from_wav(&path).unwrap();
285        assert_eq!(frame.sample_rate(), 16000);
286        assert_eq!(frame.len(), 4);
287        let s = frame.samples();
288        assert!((s[0] - 0.0).abs() < 1e-6);
289        assert!((s[1] - (i16::MAX as f32 / 32768.0)).abs() < 1e-6);
290        assert!((s[2] - -1.0).abs() < 1e-6);
291        assert!((s[3] - 0.5).abs() < 1e-4);
292    }
293
294    #[cfg(feature = "wav")]
295    #[test]
296    fn wav_round_trip() {
297        let original = AudioFrame::from_vec(vec![0.5f32, -0.5, 0.0, 1.0], 16000);
298        let path = std::env::temp_dir().join("wavekat_test.wav");
299        original.write_wav(&path).unwrap();
300        let loaded = AudioFrame::from_wav(&path).unwrap();
301        assert_eq!(loaded.sample_rate(), 16000);
302        for (a, b) in original.samples().iter().zip(loaded.samples()) {
303            assert!((a - b).abs() < 1e-6, "sample mismatch: {a} vs {b}");
304        }
305    }
306
307    #[test]
308    fn from_vec_is_zero_copy() {
309        let samples = vec![0.5f32, -0.5];
310        let ptr = samples.as_ptr();
311        let frame = AudioFrame::from_vec(samples, 24000);
312        assert_eq!(frame.samples().as_ptr(), ptr);
313        assert_eq!(frame.sample_rate(), 24000);
314    }
315
316    #[test]
317    fn into_samples_vec_f32() {
318        let samples = vec![0.1f32, -0.2, 0.3];
319        let frame = AudioFrame::new(&samples, 16000);
320        assert!(matches!(frame.samples, Cow::Borrowed(_)));
321        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
322    }
323
324    #[test]
325    fn into_samples_array_f32() {
326        let samples = [0.1f32, -0.2, 0.3];
327        let frame = AudioFrame::new(&samples, 16000);
328        assert!(matches!(frame.samples, Cow::Borrowed(_)));
329        assert_eq!(frame.samples(), &[0.1, -0.2, 0.3]);
330    }
331
332    #[test]
333    fn into_samples_vec_i16() {
334        let samples: Vec<i16> = vec![0, 16384, i16::MIN];
335        let frame = AudioFrame::new(&samples, 16000);
336        assert!(matches!(frame.samples, Cow::Owned(_)));
337        let s = frame.samples();
338        assert!((s[0] - 0.0).abs() < f32::EPSILON);
339        assert!((s[1] - 0.5).abs() < 0.001);
340        assert!((s[2] - -1.0).abs() < f32::EPSILON);
341    }
342
343    #[test]
344    fn into_samples_array_i16() {
345        let samples: [i16; 3] = [0, 16384, i16::MIN];
346        let frame = AudioFrame::new(&samples, 16000);
347        assert!(matches!(frame.samples, Cow::Owned(_)));
348        let s = frame.samples();
349        assert!((s[0] - 0.0).abs() < f32::EPSILON);
350        assert!((s[1] - 0.5).abs() < 0.001);
351        assert!((s[2] - -1.0).abs() < f32::EPSILON);
352    }
353}