Skip to main content

max / audiofiles

5.0 KB · 150 lines History Blame Raw
1 //! WAV encoding via hound: writes f32 audio data to 16-bit or 24-bit integer WAV files.
2
3 use std::path::Path;
4
5 use hound::{SampleFormat, WavSpec, WavWriter};
6
7 use super::convert::ConvertedAudio;
8 use super::dither::SimpleRng;
9 use crate::error::{io_err, CoreError};
10 use tracing::instrument;
11
12 /// Encode audio to a WAV file at the given path.
13 ///
14 /// - 16-bit: applies TPDF dither before quantization.
15 /// - 24-bit: direct f32-to-i32 scaling, no dither needed.
16 #[instrument(skip_all)]
17 pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
18 let spec = WavSpec {
19 channels: audio.channels,
20 sample_rate: audio.sample_rate,
21 bits_per_sample: bit_depth,
22 sample_format: SampleFormat::Int,
23 };
24
25 let mut writer =
26 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
27
28 match bit_depth {
29 16 => {
30 // Seed from data pointer so each export gets a different dither pattern
31 let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
32 let mut rng = SimpleRng::new(seed);
33 let scale = i16::MAX as f32;
34 for &sample in &audio.samples {
35 // TPDF dither: two uniform random values summed
36 let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
37 let dithered = (sample + dither) * scale;
38 let clamped = dithered.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
39 writer
40 .write_sample(clamped)
41 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
42 }
43 }
44 24 => {
45 let scale = 8_388_607.0f32; // 2^23 - 1
46 for &sample in &audio.samples {
47 let scaled = (sample * scale)
48 .round()
49 .clamp(-8_388_608.0, 8_388_607.0) as i32;
50 writer
51 .write_sample(scaled)
52 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
53 }
54 }
55 _ => {
56 return Err(CoreError::Export(format!(
57 "unsupported bit depth: {bit_depth}"
58 )));
59 }
60 }
61
62 writer
63 .finalize()
64 .map_err(|e| io_err(dest, std::io::Error::other(e)))?;
65
66 Ok(())
67 }
68
69 #[cfg(test)]
70 mod tests {
71 use super::*;
72
73 fn make_audio(samples: Vec<f32>, channels: u16, sample_rate: u32) -> ConvertedAudio {
74 ConvertedAudio {
75 samples,
76 sample_rate,
77 channels,
78 }
79 }
80
81 #[test]
82 fn wav_16bit_roundtrip() {
83 let dir = tempfile::tempdir().unwrap();
84 let path = dir.path().join("test_16.wav");
85
86 let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100);
87 encode_wav(&audio, 16, &path).unwrap();
88
89 // Read back with hound
90 let mut reader = hound::WavReader::open(&path).unwrap();
91 let spec = reader.spec();
92 assert_eq!(spec.channels, 1);
93 assert_eq!(spec.sample_rate, 44100);
94 assert_eq!(spec.bits_per_sample, 16);
95
96 let samples: Vec<i16> = reader.samples::<i16>().map(|s| s.unwrap()).collect();
97 assert_eq!(samples.len(), 4);
98
99 // Check values within quantization error (1/32768 + dither)
100 let tolerance = 2.0 / 32768.0;
101 let originals = [0.0f32, 0.5, -0.5, 0.25];
102 for (i, &orig) in originals.iter().enumerate() {
103 let read_back = samples[i] as f32 / i16::MAX as f32;
104 assert!(
105 (read_back - orig).abs() < tolerance,
106 "sample {i}: expected ~{orig}, got {read_back}"
107 );
108 }
109 }
110
111 #[test]
112 fn wav_24bit_roundtrip() {
113 let dir = tempfile::tempdir().unwrap();
114 let path = dir.path().join("test_24.wav");
115
116 let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 2, 48000);
117 encode_wav(&audio, 24, &path).unwrap();
118
119 let mut reader = hound::WavReader::open(&path).unwrap();
120 let spec = reader.spec();
121 assert_eq!(spec.channels, 2);
122 assert_eq!(spec.sample_rate, 48000);
123 assert_eq!(spec.bits_per_sample, 24);
124
125 let samples: Vec<i32> = reader.samples::<i32>().map(|s| s.unwrap()).collect();
126 assert_eq!(samples.len(), 4);
127
128 // 24-bit has very high precision
129 let scale = 8_388_607.0f32;
130 let tolerance = 2.0 / scale;
131 let originals = [0.0f32, 0.5, -0.5, 0.25];
132 for (i, &orig) in originals.iter().enumerate() {
133 let read_back = samples[i] as f32 / scale;
134 assert!(
135 (read_back - orig).abs() < tolerance,
136 "sample {i}: expected ~{orig}, got {read_back}"
137 );
138 }
139 }
140
141 #[test]
142 fn unsupported_bit_depth_returns_error() {
143 let dir = tempfile::tempdir().unwrap();
144 let path = dir.path().join("test_32.wav");
145 let audio = make_audio(vec![0.0], 1, 44100);
146 let result = encode_wav(&audio, 32, &path);
147 assert!(result.is_err());
148 }
149 }
150