Skip to main content

max / audiofiles

8.6 KB · 235 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 /// - 8-bit: unsigned PCM (the WAV spec stores 8-bit as offset-128 unsigned),
15 /// TPDF-dithered before quantization since 8-bit is coarse enough to audibly
16 /// benefit. Supports hardware that wants lo-fi 8-bit samples (e.g. M8).
17 /// - 16-bit: applies TPDF dither before quantization.
18 /// - 24-bit: direct f32-to-i32 scaling, no dither needed.
19 /// - 32-bit: IEEE float, written verbatim (lossless passthrough of the f32 data).
20 #[instrument(skip_all)]
21 pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
22 let sample_format = if bit_depth == 32 {
23 SampleFormat::Float
24 } else {
25 SampleFormat::Int
26 };
27 let spec = WavSpec {
28 channels: audio.channels,
29 sample_rate: audio.sample_rate,
30 bits_per_sample: bit_depth,
31 sample_format,
32 };
33
34 let mut writer =
35 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
36
37 match bit_depth {
38 8 => {
39 // 8-bit WAV is unsigned: midpoint 128, range 0..=255. hound writes
40 // i8 for 8-bit Int specs, so we map the signed quantization into i8
41 // (which hound serializes as the unsigned byte). TPDF dither at the
42 // 8-bit LSB masks quantization distortion on this coarse grid.
43 let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
44 let mut rng = SimpleRng::new(seed);
45 let scale = i8::MAX as f32;
46 for &sample in &audio.samples {
47 let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
48 let dithered = (sample + dither) * scale;
49 let clamped = dithered.round().clamp(i8::MIN as f32, i8::MAX as f32) as i8;
50 writer
51 .write_sample(clamped)
52 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
53 }
54 }
55 16 => {
56 // Seed from data pointer so each export gets a different dither pattern
57 let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
58 let mut rng = SimpleRng::new(seed);
59 let scale = i16::MAX as f32;
60 for &sample in &audio.samples {
61 // TPDF dither: two uniform random values summed
62 let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
63 let dithered = (sample + dither) * scale;
64 let clamped = dithered.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
65 writer
66 .write_sample(clamped)
67 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
68 }
69 }
70 24 => {
71 let scale = 8_388_607.0f32; // 2^23 - 1
72 for &sample in &audio.samples {
73 let scaled = (sample * scale)
74 .round()
75 .clamp(-8_388_608.0, 8_388_607.0) as i32;
76 writer
77 .write_sample(scaled)
78 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
79 }
80 }
81 32 => {
82 // IEEE float: the in-memory representation is already f32, so this is
83 // a lossless passthrough — no scaling, dither, or clamping needed.
84 for &sample in &audio.samples {
85 writer
86 .write_sample(sample)
87 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
88 }
89 }
90 _ => {
91 return Err(CoreError::Export(format!(
92 "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
93 )));
94 }
95 }
96
97 writer
98 .finalize()
99 .map_err(|e| io_err(dest, std::io::Error::other(e)))?;
100
101 Ok(())
102 }
103
104 #[cfg(test)]
105 mod tests {
106 use super::*;
107
108 fn make_audio(samples: Vec<f32>, channels: u16, sample_rate: u32) -> ConvertedAudio {
109 ConvertedAudio {
110 samples,
111 sample_rate,
112 channels,
113 }
114 }
115
116 #[test]
117 fn wav_16bit_roundtrip() {
118 let dir = tempfile::tempdir().unwrap();
119 let path = dir.path().join("test_16.wav");
120
121 let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100);
122 encode_wav(&audio, 16, &path).unwrap();
123
124 // Read back with hound
125 let mut reader = hound::WavReader::open(&path).unwrap();
126 let spec = reader.spec();
127 assert_eq!(spec.channels, 1);
128 assert_eq!(spec.sample_rate, 44100);
129 assert_eq!(spec.bits_per_sample, 16);
130
131 let samples: Vec<i16> = reader.samples::<i16>().map(|s| s.unwrap()).collect();
132 assert_eq!(samples.len(), 4);
133
134 // Check values within quantization error (1/32768 + dither)
135 let tolerance = 2.0 / 32768.0;
136 let originals = [0.0f32, 0.5, -0.5, 0.25];
137 for (i, &orig) in originals.iter().enumerate() {
138 let read_back = samples[i] as f32 / i16::MAX as f32;
139 assert!(
140 (read_back - orig).abs() < tolerance,
141 "sample {i}: expected ~{orig}, got {read_back}"
142 );
143 }
144 }
145
146 #[test]
147 fn wav_24bit_roundtrip() {
148 let dir = tempfile::tempdir().unwrap();
149 let path = dir.path().join("test_24.wav");
150
151 let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 2, 48000);
152 encode_wav(&audio, 24, &path).unwrap();
153
154 let mut reader = hound::WavReader::open(&path).unwrap();
155 let spec = reader.spec();
156 assert_eq!(spec.channels, 2);
157 assert_eq!(spec.sample_rate, 48000);
158 assert_eq!(spec.bits_per_sample, 24);
159
160 let samples: Vec<i32> = reader.samples::<i32>().map(|s| s.unwrap()).collect();
161 assert_eq!(samples.len(), 4);
162
163 // 24-bit has very high precision
164 let scale = 8_388_607.0f32;
165 let tolerance = 2.0 / scale;
166 let originals = [0.0f32, 0.5, -0.5, 0.25];
167 for (i, &orig) in originals.iter().enumerate() {
168 let read_back = samples[i] as f32 / scale;
169 assert!(
170 (read_back - orig).abs() < tolerance,
171 "sample {i}: expected ~{orig}, got {read_back}"
172 );
173 }
174 }
175
176 #[test]
177 fn wav_8bit_roundtrip() {
178 let dir = tempfile::tempdir().unwrap();
179 let path = dir.path().join("test_8.wav");
180
181 let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100);
182 encode_wav(&audio, 8, &path).unwrap();
183
184 let mut reader = hound::WavReader::open(&path).unwrap();
185 let spec = reader.spec();
186 assert_eq!(spec.bits_per_sample, 8);
187 assert_eq!(spec.sample_format, hound::SampleFormat::Int);
188
189 let samples: Vec<i32> = reader.samples::<i32>().map(|s| s.unwrap()).collect();
190 assert_eq!(samples.len(), 4);
191
192 // 8-bit is coarse; tolerance is a few LSBs (quantization + dither).
193 let scale = i8::MAX as f32;
194 let tolerance = 3.0 / scale;
195 let originals = [0.0f32, 0.5, -0.5, 0.25];
196 for (i, &orig) in originals.iter().enumerate() {
197 let read_back = samples[i] as f32 / scale;
198 assert!(
199 (read_back - orig).abs() < tolerance,
200 "sample {i}: expected ~{orig}, got {read_back}"
201 );
202 }
203 }
204
205 #[test]
206 fn wav_32bit_float_roundtrip() {
207 let dir = tempfile::tempdir().unwrap();
208 let path = dir.path().join("test_32f.wav");
209
210 let originals = vec![0.0f32, 0.5, -0.5, 0.25, 0.123_456_7];
211 let audio = make_audio(originals.clone(), 1, 96000);
212 encode_wav(&audio, 32, &path).unwrap();
213
214 let mut reader = hound::WavReader::open(&path).unwrap();
215 let spec = reader.spec();
216 assert_eq!(spec.bits_per_sample, 32);
217 assert_eq!(spec.sample_format, hound::SampleFormat::Float);
218 assert_eq!(spec.sample_rate, 96000);
219
220 // Float is a lossless passthrough — exact equality.
221 let samples: Vec<f32> = reader.samples::<f32>().map(|s| s.unwrap()).collect();
222 assert_eq!(samples, originals);
223 }
224
225 #[test]
226 fn unsupported_bit_depth_returns_error() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join("test_20.wav");
229 let audio = make_audio(vec![0.0], 1, 44100);
230 // 20-bit is not one of the supported depths (8/16/24/32).
231 let result = encode_wav(&audio, 20, &path);
232 assert!(result.is_err());
233 }
234 }
235