Skip to main content

sva_cli/
wav.rs

1// Concern: reads/writes components as one interleaved WAV, any bit depth or float | Non-concern: choosing the destination | IO: (planes, sample_rate, path) <-> () or Vec<Vec<f32>>
2
3use std::path::Path;
4
5use sva_core::CliError;
6
7/// Integer PCM at up to 16 bits is the format tag Python's stdlib `wave` module can open;
8/// hound marks any `bits_per_sample > 16` format `WAVE_FORMAT_EXTENSIBLE`, which it refuses.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum SampleEncoding {
11    Float,
12    /// This many bits, 16 at most, left-justified in an 8- or 16-bit word.
13    Pcm(u8),
14}
15
16impl SampleEncoding {
17    pub fn of(bits: i32) -> SampleEncoding {
18        match u8::try_from(bits) {
19            Ok(bits) if bits <= 16 => SampleEncoding::Pcm(bits),
20            _ => SampleEncoding::Float,
21        }
22    }
23}
24
25pub fn write_channels(
26    planes: &[&[f32]],
27    sample_rate: u32,
28    path: &Path,
29    encoding: SampleEncoding,
30) -> Result<(), CliError> {
31    match encoding {
32        SampleEncoding::Float => write_as::<f32>(planes, sample_rate, path, 32, |s| s),
33        SampleEncoding::Pcm(bits) if bits <= 8 => {
34            write_as::<i8>(planes, sample_rate, path, 8, |s| quantize(s, bits, 8) as i8)
35        }
36        SampleEncoding::Pcm(bits) => write_as::<i16>(planes, sample_rate, path, 16, |s| {
37            quantize(s, bits, 16) as i16
38        }),
39    }
40}
41
42pub fn write_wav(
43    samples: &[f32],
44    sample_rate: u32,
45    path: &Path,
46    encoding: SampleEncoding,
47) -> Result<(), CliError> {
48    write_channels(&[samples], sample_rate, path, encoding)
49}
50
51/// `bits` of full scale, shifted up to fill a `word`-bit sample.
52fn quantize(sample: f32, bits: u8, word: u8) -> i32 {
53    let full = ((1i32 << (bits - 1)) - 1) as f32;
54    ((sample.clamp(-1.0, 1.0) * full).round() as i32) << (word - bits)
55}
56
57/// A file that is not there is `not_found`, never the `internal_error` of a tool that broke.
58fn unopened(path: &Path, e: &hound::Error) -> CliError {
59    match e {
60        hound::Error::IoError(io) if io.kind() == std::io::ErrorKind::NotFound => {
61            CliError::NotFound(format!("no such file: {}", path.display()))
62        }
63        e => CliError::Io(format!("could not open {}: {e}", path.display())),
64    }
65}
66
67/// De-interleaves every channel into its own plane at the file's own native rate — no
68/// resampling, ever, so a reference recording's own clock is what every reading is against.
69pub fn read_channels(path: &Path) -> Result<(Vec<Vec<f32>>, u32), CliError> {
70    let mut reader = hound::WavReader::open(path).map_err(|e| unopened(path, &e))?;
71    let spec = reader.spec();
72    let channels = spec.channels as usize;
73    if channels == 0 {
74        return Err(CliError::Io(format!(
75            "{} declares 0 channels",
76            path.display()
77        )));
78    }
79    let mut planes: Vec<Vec<f32>> = vec![Vec::new(); channels];
80    let bail = |e: hound::Error| CliError::Io(format!("could not read {}: {e}", path.display()));
81    match spec.sample_format {
82        hound::SampleFormat::Float => {
83            for (i, sample) in reader.samples::<f32>().enumerate() {
84                planes[i % channels].push(sample.map_err(bail)?);
85            }
86        }
87        hound::SampleFormat::Int => {
88            let full_scale = ((1i64 << (spec.bits_per_sample - 1)) - 1) as f32;
89            for (i, sample) in reader.samples::<i32>().enumerate() {
90                planes[i % channels].push(unquantize(sample.map_err(bail)?, full_scale));
91            }
92        }
93    }
94    Ok((planes, spec.sample_rate))
95}
96
97pub fn read_wav(path: &Path) -> Result<(Vec<f32>, u32), CliError> {
98    let (mut planes, sample_rate) = read_channels(path)?;
99    Ok((planes.remove(0), sample_rate))
100}
101
102/// The inverse of [`quantize`]: an integer PCM sample, whatever its bit depth, back to f32 in
103/// `[-1, 1]` around the same full-scale magnitude it was quantized against.
104fn unquantize(sample: i32, full_scale: f32) -> f32 {
105    sample as f32 / full_scale
106}
107
108fn write_as<S: hound::Sample>(
109    planes: &[&[f32]],
110    sample_rate: u32,
111    path: &Path,
112    bits_per_sample: u16,
113    convert: impl Fn(f32) -> S,
114) -> Result<(), CliError> {
115    let sample_format = if bits_per_sample == 32 {
116        hound::SampleFormat::Float
117    } else {
118        hound::SampleFormat::Int
119    };
120    let spec = hound::WavSpec {
121        channels: planes.len().max(1) as u16,
122        sample_rate,
123        bits_per_sample,
124        sample_format,
125    };
126    let mut writer = hound::WavWriter::create(path, spec)
127        .map_err(|e| CliError::Io(format!("could not create {}: {e}", path.display())))?;
128    for i in 0..planes.first().map_or(0, |p| p.len()) {
129        for plane in planes {
130            writer.write_sample(convert(plane[i])).map_err(|e| {
131                CliError::Io(format!("could not write sample to {}: {e}", path.display()))
132            })?;
133        }
134    }
135    writer
136        .finalize()
137        .map_err(|e| CliError::Io(format!("could not finalize {}: {e}", path.display())))?;
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn tmp(name: &str) -> std::path::PathBuf {
146        let path =
147            std::env::temp_dir().join(format!("sva-cli-wav-{name}-{:x}.wav", std::process::id()));
148        let _ = std::fs::remove_file(&path);
149        path
150    }
151
152    #[test]
153    fn pcm16_writes_a_plain_format_tag_a_bare_riff_parser_can_read() {
154        let path = tmp("pcm16-tag");
155        write_wav(
156            &[0.5, -0.5, 1.5, -1.5],
157            44100,
158            &path,
159            SampleEncoding::Pcm(16),
160        )
161        .unwrap();
162
163        let bytes = std::fs::read(&path).unwrap();
164        assert_eq!(&bytes[0..4], b"RIFF");
165        assert_eq!(&bytes[8..12], b"WAVE");
166        assert_eq!(&bytes[12..16], b"fmt ");
167        let format_tag = u16::from_le_bytes([bytes[20], bytes[21]]);
168        assert_ne!(
169            format_tag, 0xFFFE,
170            "WAVE_FORMAT_EXTENSIBLE, which Python's `wave` refuses"
171        );
172        assert_eq!(format_tag, 1, "WAVE_FORMAT_PCM");
173
174        let mut reader = hound::WavReader::open(&path).unwrap();
175        assert_eq!(reader.spec().bits_per_sample, 16);
176        assert_eq!(reader.spec().sample_format, hound::SampleFormat::Int);
177        let samples: Vec<i16> = reader.samples::<i16>().map(Result::unwrap).collect();
178        assert_eq!(samples, vec![16384, -16384, i16::MAX, -i16::MAX]);
179
180        let _ = std::fs::remove_file(&path);
181    }
182
183    #[test]
184    fn float_still_carries_every_bit_and_still_reads_back_as_extensible() {
185        let path = tmp("float-tag");
186        let pcm = [0.125f32, -0.25, 0.75];
187        write_wav(&pcm, 8000, &path, SampleEncoding::Float).unwrap();
188
189        let bytes = std::fs::read(&path).unwrap();
190        let format_tag = u16::from_le_bytes([bytes[20], bytes[21]]);
191        assert_eq!(
192            format_tag, 0xFFFE,
193            "float always needs WAVEFORMATEXTENSIBLE in hound"
194        );
195
196        let mut reader = hound::WavReader::open(&path).unwrap();
197        assert_eq!(reader.spec().bits_per_sample, 32);
198        let samples: Vec<f32> = reader.samples::<f32>().map(Result::unwrap).collect();
199        assert_eq!(samples, pcm);
200
201        let _ = std::fs::remove_file(&path);
202    }
203
204    /// Inverses at 32-bit float: the round trip is exact, at the file's own rate.
205    #[test]
206    fn read_channels_round_trips_a_float_wav_exactly_at_its_own_rate() {
207        let path = tmp("read-float-roundtrip");
208        let left = [0.5f32, -0.25, 0.75, -1.0];
209        let right = [-0.5f32, 0.25, -0.75, 1.0];
210        write_channels(&[&left, &right], 48_000, &path, SampleEncoding::Float).unwrap();
211
212        let (planes, sample_rate) = read_channels(&path).unwrap();
213        assert_eq!(sample_rate, 48_000, "no resampling, ever");
214        assert_eq!(planes, vec![left.to_vec(), right.to_vec()]);
215
216        let _ = std::fs::remove_file(&path);
217    }
218
219    /// 16-bit PCM quantizes on the way in, so the round trip is within one step.
220    #[test]
221    fn read_channels_unquantizes_pcm16_within_one_step() {
222        let path = tmp("read-pcm16-roundtrip");
223        let pcm = [0.5f32, -0.5, 0.1, -0.9];
224        write_wav(&pcm, 44_100, &path, SampleEncoding::Pcm(16)).unwrap();
225
226        let (mono, sample_rate) = read_wav(&path).unwrap();
227        assert_eq!(sample_rate, 44_100);
228        assert_eq!(mono.len(), pcm.len());
229        for (got, want) in mono.iter().zip(&pcm) {
230            assert!(
231                (got - want).abs() < 1.0 / f32::from(i16::MAX),
232                "{got} vs {want}"
233            );
234        }
235
236        let _ = std::fs::remove_file(&path);
237    }
238
239    fn write_int(path: &std::path::Path, bits_per_sample: u16, values: &[i32], sample_rate: u32) {
240        let spec = hound::WavSpec {
241            channels: 1,
242            sample_rate,
243            bits_per_sample,
244            sample_format: hound::SampleFormat::Int,
245        };
246        let mut writer = hound::WavWriter::create(path, spec).unwrap();
247        for &v in values {
248            writer.write_sample(v).unwrap();
249        }
250        writer.finalize().unwrap();
251    }
252
253    /// hound's own `Sample for i32` reader already covers 8/16/24/32-bit int — this is the
254    /// unquantize half, checked at the two depths `write_channels` never itself produces.
255    #[test]
256    fn read_channels_unquantizes_every_int_bit_depth_hound_can_decode() {
257        let path = tmp("read-8bit");
258        write_int(&path, 8, &[127, -128, 0], 22_050);
259        let (planes, sample_rate) = read_channels(&path).unwrap();
260        assert_eq!(sample_rate, 22_050);
261        assert_eq!(planes.len(), 1);
262        assert!((planes[0][0] - 1.0).abs() < 0.01, "{:?}", planes[0]);
263        assert!((planes[0][1] + 1.0).abs() < 0.01, "{:?}", planes[0]);
264        assert_eq!(planes[0][2], 0.0);
265        let _ = std::fs::remove_file(&path);
266
267        let path = tmp("read-24bit");
268        let full_scale = (1i64 << 23) - 1;
269        write_int(
270            &path,
271            24,
272            &[full_scale as i32, -(full_scale as i32), 0],
273            96_000,
274        );
275        let (planes, sample_rate) = read_channels(&path).unwrap();
276        assert_eq!(sample_rate, 96_000);
277        assert_eq!(planes[0], vec![1.0, -1.0, 0.0]);
278        let _ = std::fs::remove_file(&path);
279    }
280
281    /// A round-robin de-interleave, checked against a file `write_channels` never produces
282    /// itself: an odd number of channels with a visibly different signal on each.
283    #[test]
284    fn read_channels_de_interleaves_every_component_into_its_own_plane() {
285        let path = tmp("read-three-channel");
286        let a = [0.1f32, 0.2];
287        let b = [0.3f32, 0.4];
288        let c = [0.5f32, 0.6];
289        write_channels(&[&a, &b, &c], 44_100, &path, SampleEncoding::Float).unwrap();
290
291        let (planes, _) = read_channels(&path).unwrap();
292        assert_eq!(planes, vec![a.to_vec(), b.to_vec(), c.to_vec()]);
293
294        let _ = std::fs::remove_file(&path);
295    }
296
297    #[test]
298    fn read_channels_reports_a_missing_file_as_not_found_not_a_panic() {
299        let path = tmp("read-missing");
300        assert!(matches!(read_channels(&path), Err(CliError::NotFound(_))));
301    }
302}