Skip to main content

sipx_audio/
wav.rs

1//! WAV files, for the narrow case the tests need: 8 kHz 16-bit mono PCM.
2//!
3//! Deliberately not a general WAV library. It reads what it writes and what a call records,
4//! and it refuses anything else by name rather than by producing noise — a WAV reader that
5//! silently misinterprets a format produces audio that is *almost* right, which is far harder
6//! to diagnose than a refusal.
7
8use std::io::{Read, Write};
9
10/// What can go wrong with a WAV file.
11#[derive(Debug, thiserror::Error)]
12#[non_exhaustive]
13pub enum WavError {
14    /// The file could not be read or written.
15    #[error("io: {0}")]
16    Io(#[from] std::io::Error),
17    /// It is not a RIFF/WAVE file at all.
18    #[error("not a WAVE file")]
19    NotWave,
20    /// It is a WAVE file this crate does not handle.
21    #[error("unsupported: {0}")]
22    Unsupported(String),
23    /// A chunk ran past the end of the file.
24    #[error("truncated")]
25    Truncated,
26}
27
28/// 16-bit mono PCM at a given sample rate.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Wav {
31    /// Samples per second.
32    pub sample_rate: u32,
33    /// The samples.
34    pub samples: Vec<i16>,
35}
36
37impl Wav {
38    /// A clip at 8 kHz, the rate G.711 uses.
39    #[must_use]
40    pub fn narrowband(samples: Vec<i16>) -> Self {
41        Self {
42            sample_rate: 8000,
43            samples,
44        }
45    }
46
47    /// How long the clip is.
48    #[must_use]
49    pub fn duration(&self) -> std::time::Duration {
50        if self.sample_rate == 0 {
51            return std::time::Duration::ZERO;
52        }
53        // Integer arithmetic rather than floating point: a duration computed in f64 is
54        // exact for any clip that fits in memory, but saying so requires a proof that
55        // nanoseconds do not.
56        std::time::Duration::from_nanos(
57            (self.samples.len() as u64).saturating_mul(1_000_000_000) / u64::from(self.sample_rate),
58        )
59    }
60}
61
62fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
63    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
64}
65
66fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
67    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
68}
69
70/// Read a WAV file.
71pub fn read_wav(mut source: impl Read) -> Result<Wav, WavError> {
72    let mut bytes = Vec::new();
73    source.read_to_end(&mut bytes)?;
74
75    if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WAVE") {
76        return Err(WavError::NotWave);
77    }
78
79    let mut sample_rate = None;
80    let mut samples = None;
81    let mut offset = 12usize;
82
83    // Chunks are walked rather than assumed in order: real files carry LIST and fact chunks
84    // between fmt and data, and a reader that assumes a fixed 44-byte header reads metadata as
85    // audio.
86    while offset + 8 <= bytes.len() {
87        let id = bytes.get(offset..offset + 4).ok_or(WavError::Truncated)?;
88        let size = u32_at(&bytes, offset + 4).ok_or(WavError::Truncated)? as usize;
89        let body_at = offset + 8;
90        let body = bytes
91            .get(body_at..body_at + size)
92            .ok_or(WavError::Truncated)?;
93
94        match id {
95            b"fmt " => {
96                let format = u16_at(body, 0).ok_or(WavError::Truncated)?;
97                let channels = u16_at(body, 2).ok_or(WavError::Truncated)?;
98                let rate = u32_at(body, 4).ok_or(WavError::Truncated)?;
99                let bits = u16_at(body, 14).ok_or(WavError::Truncated)?;
100
101                if format != 1 {
102                    return Err(WavError::Unsupported(format!(
103                        "format tag {format}; only uncompressed PCM is handled"
104                    )));
105                }
106                if channels != 1 {
107                    return Err(WavError::Unsupported(format!(
108                        "{channels} channels; mono only"
109                    )));
110                }
111                if bits != 16 {
112                    return Err(WavError::Unsupported(format!("{bits}-bit; 16-bit only")));
113                }
114                sample_rate = Some(rate);
115            }
116            b"data" => {
117                samples = Some(
118                    body.chunks_exact(2)
119                        .map(|pair| {
120                            i16::from_le_bytes([
121                                pair.first().copied().unwrap_or(0),
122                                pair.get(1).copied().unwrap_or(0),
123                            ])
124                        })
125                        .collect::<Vec<i16>>(),
126                );
127            }
128            _ => {}
129        }
130
131        // Chunks are word-aligned: an odd size is followed by a pad byte that is not part of
132        // it. Ignoring the pad shifts every later chunk by one.
133        offset = body_at + size + (size % 2);
134    }
135
136    Ok(Wav {
137        sample_rate: sample_rate.ok_or(WavError::NotWave)?,
138        samples: samples.ok_or(WavError::NotWave)?,
139    })
140}
141
142/// Write a WAV file.
143pub fn write_wav(mut sink: impl Write, wav: &Wav) -> Result<(), WavError> {
144    let data_len = u32::try_from(wav.samples.len() * 2).unwrap_or(u32::MAX);
145    let byte_rate = wav.sample_rate * 2;
146
147    sink.write_all(b"RIFF")?;
148    sink.write_all(&(36 + data_len).to_le_bytes())?;
149    sink.write_all(b"WAVE")?;
150
151    sink.write_all(b"fmt ")?;
152    sink.write_all(&16u32.to_le_bytes())?;
153    sink.write_all(&1u16.to_le_bytes())?; // PCM
154    sink.write_all(&1u16.to_le_bytes())?; // mono
155    sink.write_all(&wav.sample_rate.to_le_bytes())?;
156    sink.write_all(&byte_rate.to_le_bytes())?;
157    sink.write_all(&2u16.to_le_bytes())?; // block align
158    sink.write_all(&16u16.to_le_bytes())?; // bits per sample
159
160    sink.write_all(b"data")?;
161    sink.write_all(&data_len.to_le_bytes())?;
162    for sample in &wav.samples {
163        sink.write_all(&sample.to_le_bytes())?;
164    }
165    Ok(())
166}
167
168#[cfg(test)]
169#[allow(
170    clippy::cast_possible_truncation,
171    clippy::unwrap_used,
172    clippy::expect_used,
173    clippy::panic,
174    clippy::indexing_slicing
175)]
176mod tests {
177    use super::*;
178
179    fn tone(samples: usize) -> Wav {
180        Wav::narrowband(
181            (0..samples)
182                .map(|i| {
183                    let phase = f64::from(u32::try_from(i).unwrap_or(0))
184                        * 2.0
185                        * std::f64::consts::PI
186                        * 440.0
187                        / 8000.0;
188                    let value = (phase.sin() * 16000.0).round();
189                    i16::try_from(value as i32).unwrap_or(0)
190                })
191                .collect(),
192        )
193    }
194
195    #[test]
196    fn a_clip_survives_a_round_trip_exactly() {
197        let original = tone(800);
198        let mut buffer = Vec::new();
199        write_wav(&mut buffer, &original).expect("writes");
200        let read = read_wav(buffer.as_slice()).expect("reads");
201        assert_eq!(read, original);
202    }
203
204    #[test]
205    fn the_duration_follows_the_sample_count_and_rate() {
206        assert_eq!(tone(8000).duration(), std::time::Duration::from_secs(1));
207        assert_eq!(tone(4000).duration(), std::time::Duration::from_millis(500));
208    }
209
210    /// Real files put LIST and fact chunks between fmt and data. A reader that assumes a
211    /// fixed 44-byte header reads that metadata as audio.
212    #[test]
213    fn chunks_between_fmt_and_data_are_skipped() {
214        let clip = Wav::narrowband(vec![1, -1, 2, -2]);
215        let mut buffer = Vec::new();
216        write_wav(&mut buffer, &clip).expect("writes");
217
218        // Splice a LIST chunk in before `data`.
219        let data_at = buffer
220            .windows(4)
221            .position(|w| w == b"data")
222            .expect("a data chunk");
223        let mut spliced = buffer[..data_at].to_vec();
224        spliced.extend_from_slice(b"LIST");
225        spliced.extend_from_slice(&8u32.to_le_bytes());
226        spliced.extend_from_slice(b"INFOxxxx");
227        spliced.extend_from_slice(&buffer[data_at..]);
228        // Fix the RIFF size so the file stays consistent.
229        let total = u32::try_from(spliced.len() - 8).expect("fits");
230        spliced[4..8].copy_from_slice(&total.to_le_bytes());
231
232        assert_eq!(read_wav(spliced.as_slice()).expect("reads"), clip);
233    }
234
235    /// An odd-sized chunk is followed by a pad byte that is not part of it. Ignoring the pad
236    /// shifts every later chunk by one and turns the audio into noise.
237    #[test]
238    fn an_odd_sized_chunk_is_padded_to_a_word_boundary() {
239        let clip = Wav::narrowband(vec![7, -7]);
240        let mut buffer = Vec::new();
241        write_wav(&mut buffer, &clip).expect("writes");
242        let data_at = buffer
243            .windows(4)
244            .position(|w| w == b"data")
245            .expect("a data chunk");
246
247        let mut spliced = buffer[..data_at].to_vec();
248        spliced.extend_from_slice(b"ODDC");
249        spliced.extend_from_slice(&3u32.to_le_bytes());
250        spliced.extend_from_slice(b"abc\0"); // three bytes plus the pad
251        spliced.extend_from_slice(&buffer[data_at..]);
252        let total = u32::try_from(spliced.len() - 8).expect("fits");
253        spliced[4..8].copy_from_slice(&total.to_le_bytes());
254
255        assert_eq!(read_wav(spliced.as_slice()).expect("reads"), clip);
256    }
257
258    /// A format this crate cannot handle is refused by name. Reading it as if it were
259    /// 16-bit mono would produce audio that is almost right, which is far harder to diagnose.
260    #[test]
261    fn an_unsupported_format_is_refused_rather_than_misread() {
262        let mut stereo = Vec::new();
263        write_wav(&mut stereo, &Wav::narrowband(vec![1, 2, 3, 4])).expect("writes");
264        // Claim two channels.
265        let fmt_at = stereo
266            .windows(4)
267            .position(|w| w == b"fmt ")
268            .expect("a fmt chunk");
269        stereo[fmt_at + 10..fmt_at + 12].copy_from_slice(&2u16.to_le_bytes());
270
271        match read_wav(stereo.as_slice()) {
272            Err(WavError::Unsupported(message)) => assert!(message.contains("channels")),
273            other => panic!("expected a refusal, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn a_file_that_is_not_a_wave_is_refused() {
279        assert!(matches!(
280            read_wav(b"this is not a wav file at all".as_slice()),
281            Err(WavError::NotWave)
282        ));
283    }
284
285    #[test]
286    fn a_truncated_chunk_is_an_error_not_a_partial_read() {
287        let clip = Wav::narrowband(vec![1, 2, 3, 4]);
288        let mut buffer = Vec::new();
289        write_wav(&mut buffer, &clip).expect("writes");
290        buffer.truncate(buffer.len() - 4);
291        assert!(matches!(
292            read_wav(buffer.as_slice()),
293            Err(WavError::Truncated)
294        ));
295    }
296
297    #[test]
298    fn an_empty_clip_round_trips() {
299        let clip = Wav::narrowband(Vec::new());
300        let mut buffer = Vec::new();
301        write_wav(&mut buffer, &clip).expect("writes");
302        assert_eq!(read_wav(buffer.as_slice()).expect("reads"), clip);
303    }
304}