Skip to main content

audio_codec_bsd/
wav.rs

1//! WAV (RIFF PCM / IEEE-float) container decoder backed by [`hound`].
2//!
3//! `WavDecoder` runs on a **worker thread**: it performs blocking file I/O and
4//! heap allocation, and is explicitly **not** real-time safe. Decoded frames
5//! must be handed to a lock-free ring buffer so the RT audio thread never calls
6//! into this module.
7//!
8//! ## Sample normalisation
9//!
10//! Every decoded sample is normalised to planar `f32` in the range `[-1.0,
11//! 1.0]` (approximately):
12//!
13//! - **8-bit unsigned PCM** — [`hound`] returns the value centred around zero
14//!   (it applies `u8 → i8` sign-extension internally), so we scale by `1/128`.
15//! - **Signed `N`-bit integer PCM** — scaled by `1 / 2^(N-1)` (`i16 / 32768.0`,
16//!   `i32 / 2_147_483_648.0`, etc.).
17//! - **32-bit IEEE float** — passed through unchanged; non-finite samples
18//!   (`NaN`/`±Inf`) are coerced to `0.0` so the RT graph never sees a poison
19//!   value.
20//!
21//! ## Planar layout
22//!
23//! [`hound`] yields **interleaved** samples (`ch0,ch1,ch0,ch1,…`). The decoder
24//! de-interleaves them into the **planar** layout required by
25//! [`audio_core_bsd::AudioFrame`] (`[ch0…, ch1…]`) before construction. This is the
26//! single highest-risk correctness step and is covered by a dedicated stereo
27//! property test in [`mod@self#tests`].
28//!
29//! [`hound`]: https://crates.io/crates/hound
30
31use std::fs::File;
32use std::io::BufReader;
33use std::path::{Path, PathBuf};
34
35use audio_core_bsd::AudioFrame;
36use hound::{SampleFormat, WavReader, WavSpec};
37
38use crate::decoder::{ContainerDecoder, FormatKind, StreamInfo};
39use crate::error::{CodecError, Result};
40
41/// Number of per-channel frames returned by each [`WavDecoder::next_frame`]
42/// call. A modest chunk keeps peak memory bounded while giving the caller
43/// enough data per frame to amortise ring-buffer hand-off.
44const FRAMES_PER_CHUNK: usize = 1024;
45
46/// A [`hound`]-backed decoder for RIFF WAVE containers.
47///
48/// Construct with [`WavDecoder::open`], then drive through the
49/// [`ContainerDecoder`] trait. See the [module docs](self) for the
50/// worker-thread / sample-normalisation / planar-layout contract.
51pub struct WavDecoder {
52    /// Path the decoder was constructed against; used to decide whether a trait
53    /// `open` call should re-open the underlying reader.
54    path: PathBuf,
55    /// The hound reader; `None` only transiently during a re-open.
56    reader: WavReader<BufReader<File>>,
57    /// Snapshot of the WAV spec at open time.
58    spec: WavSpec,
59}
60
61impl WavDecoder {
62    /// Open the WAV file at `path` for decoding.
63    ///
64    /// The header is parsed eagerly; the spec is available immediately via the
65    /// subsequent [`ContainerDecoder::open`] call. This is the recommended
66    /// constructor — it mirrors [`ContainerDecoder::open`] but yields `Self`
67    /// for ergonomic chaining.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`CodecError::Io`] if the file cannot be opened or its header
72    /// is not a valid RIFF WAVE.
73    pub fn open(path: &Path) -> Result<Self> {
74        let reader = WavReader::open(path).map_err(|e| CodecError::Io(e.to_string()))?;
75        let spec = reader.spec();
76        Ok(Self {
77            path: path.to_path_buf(),
78            reader,
79            spec,
80        })
81    }
82
83    /// Re-open the underlying reader from `path`, replacing any state.
84    fn reopen(&mut self, path: &Path) -> Result<()> {
85        let reader = WavReader::open(path).map_err(|e| CodecError::Io(e.to_string()))?;
86        self.spec = reader.spec();
87        self.reader = reader;
88        self.path = path.to_path_buf();
89        Ok(())
90    }
91
92    /// Build a [`StreamInfo`] snapshot from the current spec.
93    fn stream_info(&self) -> Result<StreamInfo> {
94        if self.spec.channels == 0 {
95            return Err(CodecError::InvalidChannelCount(0));
96        }
97        if self.spec.sample_rate == 0 {
98            return Err(CodecError::InvalidSampleRate(0));
99        }
100        // hound's `len()` is the *total* sample count (interleaved); divide by
101        // channels to obtain the per-channel frame count.
102        let total_frames = u64::from(self.reader.duration());
103        Ok(StreamInfo {
104            format: FormatKind::Wav,
105            sample_rate: self.spec.sample_rate,
106            channels: self.spec.channels,
107            bits_per_sample: u32::from(self.spec.bits_per_sample),
108            total_frames: Some(total_frames),
109        })
110    }
111
112    /// Normalise a single integer sample to `f32` using the `1/2^(bits-1)`
113    /// scaling. Width is taken from the cached spec so this is branch-free in
114    /// the hot path.
115    #[allow(clippy::cast_precision_loss)]
116    fn norm_int(sample: i32, bits: u16) -> f32 {
117        // `2^(bits-1)` as f32; bits ∈ {8,16,24,32} so this is exact.
118        let denom = (1u64 << (bits - 1)) as f32;
119        let v = sample as f32 / denom;
120        if v.is_finite() {
121            v
122        } else {
123            0.0
124        }
125    }
126
127    /// Coerce an IEEE-float sample to a finite value (NaN/Inf → 0.0).
128    fn norm_float(sample: f32) -> f32 {
129        if sample.is_finite() {
130            sample
131        } else {
132            0.0
133        }
134    }
135
136    /// Read up to [`FRAMES_PER_CHUNK`] per-channel frames worth of interleaved
137    /// samples, de-interleave into planar `samples`, and wrap in an
138    /// [`AudioFrame`]. Returns `Ok(None)` at clean end-of-stream.
139    fn read_chunk(&mut self) -> Result<Option<AudioFrame>> {
140        let channels = usize::from(self.spec.channels);
141        let bits = self.spec.bits_per_sample;
142        let want = FRAMES_PER_CHUNK.checked_mul(channels).unwrap_or(0);
143
144        let mut inter: Vec<f32> = Vec::with_capacity(want);
145        match self.spec.sample_format {
146            SampleFormat::Int => {
147                let mut it = self.reader.samples::<i32>();
148                for _ in 0..want {
149                    match it.next() {
150                        Some(Ok(s)) => inter.push(Self::norm_int(s, bits)),
151                        Some(Err(e)) => return Err(CodecError::Decode(e.to_string())),
152                        None => break,
153                    }
154                }
155            }
156            SampleFormat::Float => {
157                let mut it = self.reader.samples::<f32>();
158                for _ in 0..want {
159                    match it.next() {
160                        Some(Ok(s)) => inter.push(Self::norm_float(s)),
161                        Some(Err(e)) => return Err(CodecError::Decode(e.to_string())),
162                        None => break,
163                    }
164                }
165            }
166        }
167
168        if inter.is_empty() {
169            return Ok(None);
170        }
171        if channels == 0 {
172            return Err(CodecError::InvalidChannelCount(0));
173        }
174
175        let frames = inter.len() / channels;
176        // De-interleave: channel c's frame i lands at index c*frames + i.
177        let mut planar = vec![0.0_f32; channels * frames];
178        for i in 0..frames {
179            for c in 0..channels {
180                planar[c * frames + i] = inter[i * channels + c];
181            }
182        }
183
184        Ok(Some(AudioFrame::from_planar(
185            self.spec.channels,
186            self.spec.sample_rate,
187            planar,
188        )))
189    }
190}
191
192impl ContainerDecoder for WavDecoder {
193    fn open(&mut self, path: &Path) -> Result<StreamInfo> {
194        if path != self.path {
195            self.reopen(path)?;
196        }
197        self.stream_info()
198    }
199
200    fn next_frame(&mut self) -> Result<Option<AudioFrame>> {
201        self.read_chunk()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use hound::{SampleFormat, WavSpec, WavWriter};
209    use std::path::PathBuf;
210
211    /// Exact-enough float comparison (uses `<`, never `==`, to stay
212    /// `clippy::float_cmp`-clean).
213    fn approx_eq(a: f32, b: f32) -> bool {
214        (a - b).abs() < 1e-4
215    }
216
217    /// Scale factor for 16-bit integer PCM: `1 / 2^15`.
218    const SCALE_16BIT: f32 = 1.0 / 32_768.0;
219
220    /// Write `interleaved` 16-bit samples to a temp PCM WAV file and return its
221    /// path. Values are written verbatim (no clamping) — pass in-range `i16`s.
222    /// Using native `i16` avoids every `cast_*` pedantic lint in the fixture.
223    fn write_wav_16bit(
224        channels: u16,
225        sample_rate: u32,
226        interleaved: &[i16],
227        suffix: &str,
228    ) -> PathBuf {
229        let spec = WavSpec {
230            channels,
231            sample_rate,
232            bits_per_sample: 16,
233            sample_format: SampleFormat::Int,
234        };
235        let mut path = std::env::temp_dir();
236        path.push(format!(
237            "audio_codec_bsd_wav_test_{}_{}.wav",
238            std::process::id(),
239            suffix
240        ));
241        let mut writer = WavWriter::create(&path, spec).expect("create wav");
242        for &v in interleaved {
243            writer.write_sample(v).expect("write sample");
244        }
245        writer.finalize().expect("finalize wav");
246        path
247    }
248
249    /// (1) **Highest-risk gate:** a stereo de-interleave correctness test.
250    /// Writes a known *independent* pattern on each channel, decodes, and
251    /// asserts `channel_slice(0)` and `channel_slice(1)` recover the two
252    /// *different* independent references — catching the interleaved-vs-planar
253    /// bug that byte counts alone would miss.
254    #[test]
255    fn stereo_deinterleave_recovers_independent_channels() {
256        // Channel 0: ascending ramp; Channel 1: a constant plateau. The two
257        // patterns are deliberately distinct so an interleaved-as-planar bug
258        // would scramble both.
259        const FRAMES: usize = 8;
260        let want_ch0: [i16; FRAMES] = [-3500, -2500, -1500, -500, 500, 1500, 2500, 3500];
261        let want_ch1: [i16; FRAMES] = [16_000; FRAMES];
262
263        let mut interleaved: Vec<i16> = Vec::with_capacity(FRAMES * 2);
264        for i in 0..FRAMES {
265            interleaved.push(want_ch0[i]);
266            interleaved.push(want_ch1[i]);
267        }
268        let path = write_wav_16bit(2, 48_000, &interleaved, "stereo_deinterleave");
269
270        let mut dec = WavDecoder::open(&path).expect("open");
271        let info = dec.open(&path).expect("trait open");
272        assert_eq!(info.format, FormatKind::Wav);
273        assert_eq!(info.channels, 2);
274        assert_eq!(info.sample_rate, 48_000);
275        assert_eq!(info.bits_per_sample, 16);
276
277        let frame = dec.next_frame().expect("decode frame").expect("some frame");
278        assert_eq!(frame.channels, 2);
279        // Planar length invariant.
280        assert_eq!(frame.samples.len(), FRAMES * 2);
281
282        let ch0 = frame.channel_slice(0);
283        let ch1 = frame.channel_slice(1);
284        // The two channels MUST differ (catches interleaved-as-planar).
285        assert_ne!(ch0, ch1);
286        // And each MUST match its independent reference (catches channel swap).
287        for (i, &want) in want_ch0.iter().enumerate() {
288            let expected = f32::from(want) * SCALE_16BIT;
289            assert!(
290                approx_eq(ch0[i], expected),
291                "ch0[{i}] = {}, expected {expected}",
292                ch0[i]
293            );
294        }
295        for (i, &want) in want_ch1.iter().enumerate() {
296            let expected = f32::from(want) * SCALE_16BIT;
297            assert!(
298                approx_eq(ch1[i], expected),
299                "ch1[{i}] = {}, expected {expected}",
300                ch1[i]
301            );
302        }
303
304        let _ = std::fs::remove_file(&path);
305    }
306
307    /// (2) Mono round-trip: written values are recovered within quantisation
308    /// tolerance.
309    #[test]
310    fn mono_round_trip_values_within_tolerance() {
311        let interleaved: [i16; 6] = [0, 4_096, 8_192, 16_384, -8_192, -16_384];
312        let path = write_wav_16bit(1, 44_100, &interleaved, "mono_roundtrip");
313
314        let mut dec = WavDecoder::open(&path).expect("open");
315        let _info = dec.open(&path).expect("trait open");
316        let frame = dec.next_frame().expect("decode").expect("some");
317        assert_eq!(frame.channels, 1);
318        let ch0 = frame.channel_slice(0);
319        assert_eq!(ch0.len(), interleaved.len());
320        for (i, &want) in interleaved.iter().enumerate() {
321            let expected = f32::from(want) * SCALE_16BIT;
322            assert!(
323                approx_eq(ch0[i], expected),
324                "mono[{i}] = {}, expected {expected}",
325                ch0[i]
326            );
327        }
328        let _ = std::fs::remove_file(&path);
329    }
330
331    /// (3) EOF: after draining, `next_frame` returns `Ok(None)` (never panics).
332    #[test]
333    fn eof_returns_none_after_drain() {
334        let interleaved: [i16; 3] = [100, 200, 300];
335        let path = write_wav_16bit(1, 48_000, &interleaved, "eof");
336
337        let mut dec = WavDecoder::open(&path).expect("open");
338        let _info = dec.open(&path).expect("trait open");
339        // Drain all data.
340        let first = dec.next_frame().expect("first").expect("some data");
341        assert!(!first.samples.is_empty());
342        // Subsequent calls must return Ok(None) and never panic.
343        let next = dec.next_frame().expect("second call is ok");
344        assert!(next.is_none(), "expected EOF, got {next:?}");
345
346        let _ = std::fs::remove_file(&path);
347    }
348}