//! WAV encoding via hound: writes f32 audio data to 16-bit or 24-bit integer WAV files. use std::path::Path; use hound::{SampleFormat, WavSpec, WavWriter}; use super::convert::ConvertedAudio; use super::dither::SimpleRng; use crate::error::{io_err, CoreError}; use tracing::instrument; /// Encode audio to a WAV file at the given path. /// /// - 16-bit: applies TPDF dither before quantization. /// - 24-bit: direct f32-to-i32 scaling, no dither needed. #[instrument(skip_all)] pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> { let spec = WavSpec { channels: audio.channels, sample_rate: audio.sample_rate, bits_per_sample: bit_depth, sample_format: SampleFormat::Int, }; let mut writer = WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?; match bit_depth { 16 => { // Seed from data pointer so each export gets a different dither pattern let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64; let mut rng = SimpleRng::new(seed); let scale = i16::MAX as f32; for &sample in &audio.samples { // TPDF dither: two uniform random values summed let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale; let dithered = (sample + dither) * scale; let clamped = dithered.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; writer .write_sample(clamped) .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?; } } 24 => { let scale = 8_388_607.0f32; // 2^23 - 1 for &sample in &audio.samples { let scaled = (sample * scale) .round() .clamp(-8_388_608.0, 8_388_607.0) as i32; writer .write_sample(scaled) .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?; } } _ => { return Err(CoreError::Export(format!( "unsupported bit depth: {bit_depth}" ))); } } writer .finalize() .map_err(|e| io_err(dest, std::io::Error::other(e)))?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn make_audio(samples: Vec, channels: u16, sample_rate: u32) -> ConvertedAudio { ConvertedAudio { samples, sample_rate, channels, } } #[test] fn wav_16bit_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_16.wav"); let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100); encode_wav(&audio, 16, &path).unwrap(); // Read back with hound let mut reader = hound::WavReader::open(&path).unwrap(); let spec = reader.spec(); assert_eq!(spec.channels, 1); assert_eq!(spec.sample_rate, 44100); assert_eq!(spec.bits_per_sample, 16); let samples: Vec = reader.samples::().map(|s| s.unwrap()).collect(); assert_eq!(samples.len(), 4); // Check values within quantization error (1/32768 + dither) let tolerance = 2.0 / 32768.0; let originals = [0.0f32, 0.5, -0.5, 0.25]; for (i, &orig) in originals.iter().enumerate() { let read_back = samples[i] as f32 / i16::MAX as f32; assert!( (read_back - orig).abs() < tolerance, "sample {i}: expected ~{orig}, got {read_back}" ); } } #[test] fn wav_24bit_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_24.wav"); let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 2, 48000); encode_wav(&audio, 24, &path).unwrap(); let mut reader = hound::WavReader::open(&path).unwrap(); let spec = reader.spec(); assert_eq!(spec.channels, 2); assert_eq!(spec.sample_rate, 48000); assert_eq!(spec.bits_per_sample, 24); let samples: Vec = reader.samples::().map(|s| s.unwrap()).collect(); assert_eq!(samples.len(), 4); // 24-bit has very high precision let scale = 8_388_607.0f32; let tolerance = 2.0 / scale; let originals = [0.0f32, 0.5, -0.5, 0.25]; for (i, &orig) in originals.iter().enumerate() { let read_back = samples[i] as f32 / scale; assert!( (read_back - orig).abs() < tolerance, "sample {i}: expected ~{orig}, got {read_back}" ); } } #[test] fn unsupported_bit_depth_returns_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_32.wav"); let audio = make_audio(vec![0.0], 1, 44100); let result = encode_wav(&audio, 32, &path); assert!(result.is_err()); } }