//! 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. /// /// - 8-bit: unsigned PCM (the WAV spec stores 8-bit as offset-128 unsigned), /// TPDF-dithered before quantization since 8-bit is coarse enough to audibly /// benefit. Supports hardware that wants lo-fi 8-bit samples (e.g. M8). /// - 16-bit: applies TPDF dither before quantization. /// - 24-bit: direct f32-to-i32 scaling, no dither needed. /// - 32-bit: IEEE float, written verbatim (lossless passthrough of the f32 data). #[instrument(skip_all)] pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> { let sample_format = if bit_depth == 32 { SampleFormat::Float } else { SampleFormat::Int }; let spec = WavSpec { channels: audio.channels, sample_rate: audio.sample_rate, bits_per_sample: bit_depth, sample_format, }; let mut writer = WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?; match bit_depth { 8 => { // 8-bit WAV is unsigned: midpoint 128, range 0..=255. hound writes // i8 for 8-bit Int specs, so we map the signed quantization into i8 // (which hound serializes as the unsigned byte). TPDF dither at the // 8-bit LSB masks quantization distortion on this coarse grid. let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64; let mut rng = SimpleRng::new(seed); let scale = i8::MAX as f32; for &sample in &audio.samples { let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale; let dithered = (sample + dither) * scale; let clamped = dithered.round().clamp(i8::MIN as f32, i8::MAX as f32) as i8; writer .write_sample(clamped) .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?; } } 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}")))?; } } 32 => { // IEEE float: the in-memory representation is already f32, so this is // a lossless passthrough — no scaling, dither, or clamping needed. for &sample in &audio.samples { writer .write_sample(sample) .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?; } } _ => { return Err(CoreError::Export(format!( "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)" ))); } } 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 wav_8bit_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_8.wav"); let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100); encode_wav(&audio, 8, &path).unwrap(); let mut reader = hound::WavReader::open(&path).unwrap(); let spec = reader.spec(); assert_eq!(spec.bits_per_sample, 8); assert_eq!(spec.sample_format, hound::SampleFormat::Int); let samples: Vec = reader.samples::().map(|s| s.unwrap()).collect(); assert_eq!(samples.len(), 4); // 8-bit is coarse; tolerance is a few LSBs (quantization + dither). let scale = i8::MAX as f32; let tolerance = 3.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 wav_32bit_float_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_32f.wav"); let originals = vec![0.0f32, 0.5, -0.5, 0.25, 0.123_456_7]; let audio = make_audio(originals.clone(), 1, 96000); encode_wav(&audio, 32, &path).unwrap(); let mut reader = hound::WavReader::open(&path).unwrap(); let spec = reader.spec(); assert_eq!(spec.bits_per_sample, 32); assert_eq!(spec.sample_format, hound::SampleFormat::Float); assert_eq!(spec.sample_rate, 96000); // Float is a lossless passthrough — exact equality. let samples: Vec = reader.samples::().map(|s| s.unwrap()).collect(); assert_eq!(samples, originals); } #[test] fn unsupported_bit_depth_returns_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test_20.wav"); let audio = make_audio(vec![0.0], 1, 44100); // 20-bit is not one of the supported depths (8/16/24/32). let result = encode_wav(&audio, 20, &path); assert!(result.is_err()); } }