audio-codec-bsd 0.1.1

FLAC/PCM/WAV multi-format audio container decoder (symphonia + hound, pure Rust) emitting planar audio-core-bsd AudioFrames
Documentation
//! WAV (RIFF PCM / IEEE-float) container decoder backed by [`hound`].
//!
//! `WavDecoder` runs on a **worker thread**: it performs blocking file I/O and
//! heap allocation, and is explicitly **not** real-time safe. Decoded frames
//! must be handed to a lock-free ring buffer so the RT audio thread never calls
//! into this module.
//!
//! ## Sample normalisation
//!
//! Every decoded sample is normalised to planar `f32` in the range `[-1.0,
//! 1.0]` (approximately):
//!
//! - **8-bit unsigned PCM** — [`hound`] returns the value centred around zero
//!   (it applies `u8 → i8` sign-extension internally), so we scale by `1/128`.
//! - **Signed `N`-bit integer PCM** — scaled by `1 / 2^(N-1)` (`i16 / 32768.0`,
//!   `i32 / 2_147_483_648.0`, etc.).
//! - **32-bit IEEE float** — passed through unchanged; non-finite samples
//!   (`NaN`/`±Inf`) are coerced to `0.0` so the RT graph never sees a poison
//!   value.
//!
//! ## Planar layout
//!
//! [`hound`] yields **interleaved** samples (`ch0,ch1,ch0,ch1,…`). The decoder
//! de-interleaves them into the **planar** layout required by
//! [`audio_core_bsd::AudioFrame`] (`[ch0…, ch1…]`) before construction. This is the
//! single highest-risk correctness step and is covered by a dedicated stereo
//! property test in [`mod@self#tests`].
//!
//! [`hound`]: https://crates.io/crates/hound

use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};

use audio_core_bsd::AudioFrame;
use hound::{SampleFormat, WavReader, WavSpec};

use crate::decoder::{ContainerDecoder, FormatKind, StreamInfo};
use crate::error::{CodecError, Result};

/// Number of per-channel frames returned by each [`WavDecoder::next_frame`]
/// call. A modest chunk keeps peak memory bounded while giving the caller
/// enough data per frame to amortise ring-buffer hand-off.
const FRAMES_PER_CHUNK: usize = 1024;

/// A [`hound`]-backed decoder for RIFF WAVE containers.
///
/// Construct with [`WavDecoder::open`], then drive through the
/// [`ContainerDecoder`] trait. See the [module docs](self) for the
/// worker-thread / sample-normalisation / planar-layout contract.
pub struct WavDecoder {
    /// Path the decoder was constructed against; used to decide whether a trait
    /// `open` call should re-open the underlying reader.
    path: PathBuf,
    /// The hound reader; `None` only transiently during a re-open.
    reader: WavReader<BufReader<File>>,
    /// Snapshot of the WAV spec at open time.
    spec: WavSpec,
}

impl WavDecoder {
    /// Open the WAV file at `path` for decoding.
    ///
    /// The header is parsed eagerly; the spec is available immediately via the
    /// subsequent [`ContainerDecoder::open`] call. This is the recommended
    /// constructor — it mirrors [`ContainerDecoder::open`] but yields `Self`
    /// for ergonomic chaining.
    ///
    /// # Errors
    ///
    /// Returns [`CodecError::Io`] if the file cannot be opened or its header
    /// is not a valid RIFF WAVE.
    pub fn open(path: &Path) -> Result<Self> {
        let reader = WavReader::open(path).map_err(|e| CodecError::Io(e.to_string()))?;
        let spec = reader.spec();
        Ok(Self {
            path: path.to_path_buf(),
            reader,
            spec,
        })
    }

    /// Re-open the underlying reader from `path`, replacing any state.
    fn reopen(&mut self, path: &Path) -> Result<()> {
        let reader = WavReader::open(path).map_err(|e| CodecError::Io(e.to_string()))?;
        self.spec = reader.spec();
        self.reader = reader;
        self.path = path.to_path_buf();
        Ok(())
    }

    /// Build a [`StreamInfo`] snapshot from the current spec.
    fn stream_info(&self) -> Result<StreamInfo> {
        if self.spec.channels == 0 {
            return Err(CodecError::InvalidChannelCount(0));
        }
        if self.spec.sample_rate == 0 {
            return Err(CodecError::InvalidSampleRate(0));
        }
        // hound's `len()` is the *total* sample count (interleaved); divide by
        // channels to obtain the per-channel frame count.
        let total_frames = u64::from(self.reader.duration());
        Ok(StreamInfo {
            format: FormatKind::Wav,
            sample_rate: self.spec.sample_rate,
            channels: self.spec.channels,
            bits_per_sample: u32::from(self.spec.bits_per_sample),
            total_frames: Some(total_frames),
        })
    }

    /// Normalise a single integer sample to `f32` using the `1/2^(bits-1)`
    /// scaling. Width is taken from the cached spec so this is branch-free in
    /// the hot path.
    #[allow(clippy::cast_precision_loss)]
    fn norm_int(sample: i32, bits: u16) -> f32 {
        // `2^(bits-1)` as f32; bits ∈ {8,16,24,32} so this is exact.
        let denom = (1u64 << (bits - 1)) as f32;
        let v = sample as f32 / denom;
        if v.is_finite() {
            v
        } else {
            0.0
        }
    }

    /// Coerce an IEEE-float sample to a finite value (NaN/Inf → 0.0).
    fn norm_float(sample: f32) -> f32 {
        if sample.is_finite() {
            sample
        } else {
            0.0
        }
    }

    /// Read up to [`FRAMES_PER_CHUNK`] per-channel frames worth of interleaved
    /// samples, de-interleave into planar `samples`, and wrap in an
    /// [`AudioFrame`]. Returns `Ok(None)` at clean end-of-stream.
    fn read_chunk(&mut self) -> Result<Option<AudioFrame>> {
        let channels = usize::from(self.spec.channels);
        let bits = self.spec.bits_per_sample;
        let want = FRAMES_PER_CHUNK.checked_mul(channels).unwrap_or(0);

        let mut inter: Vec<f32> = Vec::with_capacity(want);
        match self.spec.sample_format {
            SampleFormat::Int => {
                let mut it = self.reader.samples::<i32>();
                for _ in 0..want {
                    match it.next() {
                        Some(Ok(s)) => inter.push(Self::norm_int(s, bits)),
                        Some(Err(e)) => return Err(CodecError::Decode(e.to_string())),
                        None => break,
                    }
                }
            }
            SampleFormat::Float => {
                let mut it = self.reader.samples::<f32>();
                for _ in 0..want {
                    match it.next() {
                        Some(Ok(s)) => inter.push(Self::norm_float(s)),
                        Some(Err(e)) => return Err(CodecError::Decode(e.to_string())),
                        None => break,
                    }
                }
            }
        }

        if inter.is_empty() {
            return Ok(None);
        }
        if channels == 0 {
            return Err(CodecError::InvalidChannelCount(0));
        }

        let frames = inter.len() / channels;
        // De-interleave: channel c's frame i lands at index c*frames + i.
        let mut planar = vec![0.0_f32; channels * frames];
        for i in 0..frames {
            for c in 0..channels {
                planar[c * frames + i] = inter[i * channels + c];
            }
        }

        Ok(Some(AudioFrame::from_planar(
            self.spec.channels,
            self.spec.sample_rate,
            planar,
        )))
    }
}

impl ContainerDecoder for WavDecoder {
    fn open(&mut self, path: &Path) -> Result<StreamInfo> {
        if path != self.path {
            self.reopen(path)?;
        }
        self.stream_info()
    }

    fn next_frame(&mut self) -> Result<Option<AudioFrame>> {
        self.read_chunk()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use hound::{SampleFormat, WavSpec, WavWriter};
    use std::path::PathBuf;

    /// Exact-enough float comparison (uses `<`, never `==`, to stay
    /// `clippy::float_cmp`-clean).
    fn approx_eq(a: f32, b: f32) -> bool {
        (a - b).abs() < 1e-4
    }

    /// Scale factor for 16-bit integer PCM: `1 / 2^15`.
    const SCALE_16BIT: f32 = 1.0 / 32_768.0;

    /// Write `interleaved` 16-bit samples to a temp PCM WAV file and return its
    /// path. Values are written verbatim (no clamping) — pass in-range `i16`s.
    /// Using native `i16` avoids every `cast_*` pedantic lint in the fixture.
    fn write_wav_16bit(
        channels: u16,
        sample_rate: u32,
        interleaved: &[i16],
        suffix: &str,
    ) -> PathBuf {
        let spec = WavSpec {
            channels,
            sample_rate,
            bits_per_sample: 16,
            sample_format: SampleFormat::Int,
        };
        let mut path = std::env::temp_dir();
        path.push(format!(
            "audio_codec_bsd_wav_test_{}_{}.wav",
            std::process::id(),
            suffix
        ));
        let mut writer = WavWriter::create(&path, spec).expect("create wav");
        for &v in interleaved {
            writer.write_sample(v).expect("write sample");
        }
        writer.finalize().expect("finalize wav");
        path
    }

    /// (1) **Highest-risk gate:** a stereo de-interleave correctness test.
    /// Writes a known *independent* pattern on each channel, decodes, and
    /// asserts `channel_slice(0)` and `channel_slice(1)` recover the two
    /// *different* independent references — catching the interleaved-vs-planar
    /// bug that byte counts alone would miss.
    #[test]
    fn stereo_deinterleave_recovers_independent_channels() {
        // Channel 0: ascending ramp; Channel 1: a constant plateau. The two
        // patterns are deliberately distinct so an interleaved-as-planar bug
        // would scramble both.
        const FRAMES: usize = 8;
        let want_ch0: [i16; FRAMES] = [-3500, -2500, -1500, -500, 500, 1500, 2500, 3500];
        let want_ch1: [i16; FRAMES] = [16_000; FRAMES];

        let mut interleaved: Vec<i16> = Vec::with_capacity(FRAMES * 2);
        for i in 0..FRAMES {
            interleaved.push(want_ch0[i]);
            interleaved.push(want_ch1[i]);
        }
        let path = write_wav_16bit(2, 48_000, &interleaved, "stereo_deinterleave");

        let mut dec = WavDecoder::open(&path).expect("open");
        let info = dec.open(&path).expect("trait open");
        assert_eq!(info.format, FormatKind::Wav);
        assert_eq!(info.channels, 2);
        assert_eq!(info.sample_rate, 48_000);
        assert_eq!(info.bits_per_sample, 16);

        let frame = dec.next_frame().expect("decode frame").expect("some frame");
        assert_eq!(frame.channels, 2);
        // Planar length invariant.
        assert_eq!(frame.samples.len(), FRAMES * 2);

        let ch0 = frame.channel_slice(0);
        let ch1 = frame.channel_slice(1);
        // The two channels MUST differ (catches interleaved-as-planar).
        assert_ne!(ch0, ch1);
        // And each MUST match its independent reference (catches channel swap).
        for (i, &want) in want_ch0.iter().enumerate() {
            let expected = f32::from(want) * SCALE_16BIT;
            assert!(
                approx_eq(ch0[i], expected),
                "ch0[{i}] = {}, expected {expected}",
                ch0[i]
            );
        }
        for (i, &want) in want_ch1.iter().enumerate() {
            let expected = f32::from(want) * SCALE_16BIT;
            assert!(
                approx_eq(ch1[i], expected),
                "ch1[{i}] = {}, expected {expected}",
                ch1[i]
            );
        }

        let _ = std::fs::remove_file(&path);
    }

    /// (2) Mono round-trip: written values are recovered within quantisation
    /// tolerance.
    #[test]
    fn mono_round_trip_values_within_tolerance() {
        let interleaved: [i16; 6] = [0, 4_096, 8_192, 16_384, -8_192, -16_384];
        let path = write_wav_16bit(1, 44_100, &interleaved, "mono_roundtrip");

        let mut dec = WavDecoder::open(&path).expect("open");
        let _info = dec.open(&path).expect("trait open");
        let frame = dec.next_frame().expect("decode").expect("some");
        assert_eq!(frame.channels, 1);
        let ch0 = frame.channel_slice(0);
        assert_eq!(ch0.len(), interleaved.len());
        for (i, &want) in interleaved.iter().enumerate() {
            let expected = f32::from(want) * SCALE_16BIT;
            assert!(
                approx_eq(ch0[i], expected),
                "mono[{i}] = {}, expected {expected}",
                ch0[i]
            );
        }
        let _ = std::fs::remove_file(&path);
    }

    /// (3) EOF: after draining, `next_frame` returns `Ok(None)` (never panics).
    #[test]
    fn eof_returns_none_after_drain() {
        let interleaved: [i16; 3] = [100, 200, 300];
        let path = write_wav_16bit(1, 48_000, &interleaved, "eof");

        let mut dec = WavDecoder::open(&path).expect("open");
        let _info = dec.open(&path).expect("trait open");
        // Drain all data.
        let first = dec.next_frame().expect("first").expect("some data");
        assert!(!first.samples.is_empty());
        // Subsequent calls must return Ok(None) and never panic.
        let next = dec.next_frame().expect("second call is ok");
        assert!(next.is_none(), "expected EOF, got {next:?}");

        let _ = std::fs::remove_file(&path);
    }
}