dreamwell-media 1.0.0

Dreamwell media pipeline — audio/video decode via FFmpeg, format conversion, streaming
Documentation
//! Audio file decoding — converts any supported format to mono f32 PCM.
//!
//! This module provides a pure-Rust WAV decoder as the default path,
//! with optional FFmpeg backend for full codec support (MP3, FLAC, AAC, Opus, etc.).
//!
//! Usage:
//! ```ignore
//! let buffer = dreamwell_media::decode_audio_file(path)?;
//! println!("{}Hz, {} samples", buffer.sample_rate, buffer.samples.len());
//! ```

use std::path::Path;

/// Decoded audio buffer — mono f32 samples at a known sample rate.
/// Ready for direct consumption by oddio::Frames::from_slice().
#[derive(Debug, Clone)]
pub struct AudioBuffer {
    /// Mono f32 samples in [-1.0, 1.0] range.
    pub samples: Vec<f32>,
    /// Sample rate in Hz (e.g., 44100, 48000).
    pub sample_rate: u32,
    /// Original channel count before downmix.
    pub original_channels: u16,
    /// Original format name (e.g., "wav", "mp3", "flac").
    pub format: String,
}

/// Audio decode error.
#[derive(Debug)]
pub enum AudioDecodeError {
    /// File not found or unreadable.
    IoError(String),
    /// Unsupported or unrecognized format.
    UnsupportedFormat(String),
    /// Decode failure (corrupt data, codec error).
    DecodeFailed(String),
    /// No audio stream found in the file.
    NoAudioStream,
}

impl std::fmt::Display for AudioDecodeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::IoError(msg) => write!(f, "audio_io_error:{}", msg),
            Self::UnsupportedFormat(msg) => write!(f, "audio_unsupported_format:{}", msg),
            Self::DecodeFailed(msg) => write!(f, "audio_decode_failed:{}", msg),
            Self::NoAudioStream => write!(f, "audio_no_stream:file contains no audio data"),
        }
    }
}

impl std::error::Error for AudioDecodeError {}

impl From<std::io::Error> for AudioDecodeError {
    fn from(e: std::io::Error) -> Self {
        Self::IoError(e.to_string())
    }
}

/// Decode an audio file to mono f32 PCM.
///
/// Automatically detects format by extension:
/// - `.wav` — Native Rust WAV decoder (always available)
/// - `.mp3`, `.flac`, `.ogg`, `.aac`, `.opus`, `.m4a` — FFmpeg backend (requires `ffmpeg` feature)
///
/// Returns `AudioBuffer` with mono f32 samples ready for oddio playback.
pub fn decode_audio_file(path: &Path) -> Result<AudioBuffer, AudioDecodeError> {
    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|s| s.to_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        "wav" => decode_wav(path),
        #[cfg(feature = "ffmpeg")]
        "mp3" | "flac" | "ogg" | "aac" | "opus" | "m4a" | "wma" | "aiff" | "aif" => {
            crate::ffmpeg_decode::decode_with_ffmpeg(path)
        }
        #[cfg(not(feature = "ffmpeg"))]
        "mp3" | "flac" | "ogg" | "aac" | "opus" | "m4a" | "wma" | "aiff" | "aif" => {
            Err(AudioDecodeError::UnsupportedFormat(format!(
                ".{} requires the 'ffmpeg' feature (cargo build --features ffmpeg)",
                ext
            )))
        }
        other => Err(AudioDecodeError::UnsupportedFormat(format!(".{}", other))),
    }
}

/// Pure-Rust WAV decoder — handles PCM 8/16/24/32-bit and 32-bit float.
/// No external dependencies. Supports mono and stereo (downmixed to mono).
fn decode_wav(path: &Path) -> Result<AudioBuffer, AudioDecodeError> {
    let data = std::fs::read(path)?;

    if data.len() < 44 {
        return Err(AudioDecodeError::DecodeFailed("file too small for WAV header".into()));
    }
    if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
        return Err(AudioDecodeError::UnsupportedFormat("not a RIFF/WAVE file".into()));
    }

    let channels = u16::from_le_bytes([data[22], data[23]]);
    let sample_rate = u32::from_le_bytes([data[24], data[25], data[26], data[27]]);
    let bits_per_sample = u16::from_le_bytes([data[34], data[35]]);
    let audio_format = u16::from_le_bytes([data[20], data[21]]);

    // Find data chunk (skip over any non-data chunks like LIST, fact, etc.)
    let mut data_offset = 12;
    let mut data_size = 0u32;
    while data_offset + 8 <= data.len() {
        let chunk_id = &data[data_offset..data_offset + 4];
        let chunk_size = u32::from_le_bytes([
            data[data_offset + 4],
            data[data_offset + 5],
            data[data_offset + 6],
            data[data_offset + 7],
        ]);
        if chunk_id == b"data" {
            data_offset += 8;
            data_size = chunk_size.min((data.len() - data_offset - 8) as u32);
            break;
        }
        data_offset += 8 + chunk_size as usize;
        // Align to 2-byte boundary (WAV spec)
        if data_offset % 2 != 0 {
            data_offset += 1;
        }
    }

    if data_size == 0 {
        return Err(AudioDecodeError::DecodeFailed("no data chunk found".into()));
    }

    let pcm_data = &data[data_offset..data_offset + data_size as usize];
    let bytes_per_sample = (bits_per_sample / 8) as usize;
    let frame_size = bytes_per_sample * channels as usize;
    let frame_count = pcm_data.len() / frame_size;

    let mut samples = Vec::with_capacity(frame_count);

    for i in 0..frame_count {
        let frame_offset = i * frame_size;

        // Decode first channel (mono) or average all channels (downmix)
        let mut sum = 0.0f32;
        for ch in 0..channels as usize {
            let byte_offset = frame_offset + ch * bytes_per_sample;
            let sample = match (audio_format, bits_per_sample) {
                (1, 8) => {
                    // Unsigned 8-bit PCM
                    let s = pcm_data[byte_offset] as f32;
                    (s - 128.0) / 128.0
                }
                (1, 16) => {
                    // Signed 16-bit PCM
                    let s = i16::from_le_bytes([pcm_data[byte_offset], pcm_data[byte_offset + 1]]);
                    s as f32 / 32768.0
                }
                (1, 24) => {
                    // Signed 24-bit PCM (stored in 3 bytes)
                    let lo = pcm_data[byte_offset] as i32;
                    let mid = pcm_data[byte_offset + 1] as i32;
                    let hi = pcm_data[byte_offset + 2] as i32;
                    let s = (hi << 24 | mid << 16 | lo << 8) >> 8; // sign-extend
                    s as f32 / 8388608.0
                }
                (1, 32) => {
                    // Signed 32-bit PCM
                    let s = i32::from_le_bytes([
                        pcm_data[byte_offset],
                        pcm_data[byte_offset + 1],
                        pcm_data[byte_offset + 2],
                        pcm_data[byte_offset + 3],
                    ]);
                    s as f32 / 2147483648.0
                }
                (3, 32) => {
                    // 32-bit IEEE float
                    f32::from_le_bytes([
                        pcm_data[byte_offset],
                        pcm_data[byte_offset + 1],
                        pcm_data[byte_offset + 2],
                        pcm_data[byte_offset + 3],
                    ])
                }
                _ => {
                    return Err(AudioDecodeError::UnsupportedFormat(format!(
                        "WAV format={} bits={}",
                        audio_format, bits_per_sample
                    )));
                }
            };
            sum += sample;
        }

        // Downmix to mono (average channels)
        samples.push(sum / channels as f32);
    }

    log::info!(
        "WAV decode: {} ({} samples, {}Hz, {}ch, {}bit)",
        path.display(),
        samples.len(),
        sample_rate,
        channels,
        bits_per_sample
    );

    Ok(AudioBuffer {
        samples,
        sample_rate,
        original_channels: channels,
        format: "wav".into(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_nonexistent_file() {
        let result = decode_audio_file(Path::new("nonexistent.wav"));
        assert!(result.is_err());
    }

    #[test]
    fn decode_unsupported_extension() {
        let result = decode_audio_file(Path::new("test.xyz"));
        assert!(matches!(result, Err(AudioDecodeError::UnsupportedFormat(_))));
    }

    #[test]
    fn decode_error_display() {
        let e = AudioDecodeError::NoAudioStream;
        assert!(format!("{}", e).contains("no audio data"));
    }

    #[test]
    fn audio_buffer_clone() {
        let buf = AudioBuffer {
            samples: vec![0.0, 0.5, -0.5],
            sample_rate: 44100,
            original_channels: 1,
            format: "wav".into(),
        };
        let cloned = buf.clone();
        assert_eq!(cloned.samples.len(), 3);
        assert_eq!(cloned.sample_rate, 44100);
    }

    #[test]
    fn decode_wav_invalid_header() {
        let result = decode_wav(Path::new("nonexistent.wav"));
        assert!(result.is_err());
    }

    #[test]
    fn decode_wav_too_small() {
        let temp = std::env::temp_dir().join("dreamwell_tiny.wav");
        std::fs::write(&temp, &[0u8; 10]).ok();
        if temp.exists() {
            let result = decode_wav(&temp);
            assert!(matches!(result, Err(AudioDecodeError::DecodeFailed(_))));
            let _ = std::fs::remove_file(&temp);
        }
    }

    #[test]
    fn decode_wav_not_riff() {
        let temp = std::env::temp_dir().join("dreamwell_notriff.wav");
        let mut data = vec![0u8; 44];
        data[0..4].copy_from_slice(b"NOPE");
        std::fs::write(&temp, &data).ok();
        if temp.exists() {
            let result = decode_wav(&temp);
            assert!(matches!(result, Err(AudioDecodeError::UnsupportedFormat(_))));
            let _ = std::fs::remove_file(&temp);
        }
    }
}