Skip to main content

sva_cli/
recording.rs

1// Concern: reads a recording file to planes at its own rate: wav exactly, mp3, aac in mp4, flac, ogg decoded | Non-concern: writing a WAV (wav.rs), resampling | IO: (path) -> planes, a rate or CliError
2
3use std::path::Path;
4
5use sva_core::CliError;
6use symphonia::core::codecs::audio::AudioDecoderOptions;
7use symphonia::core::errors::Error;
8use symphonia::core::formats::probe::Hint;
9use symphonia::core::formats::{FormatOptions, TrackType};
10use symphonia::core::io::MediaSourceStream;
11use symphonia::core::meta::MetadataOptions;
12
13/// One plane per channel, and the rate they were recorded at.
14type Planes = (Vec<Vec<f32>>, u32);
15
16/// Every extension a recording is read from.
17pub const READS: [&str; 8] = ["wav", "mp3", "m4a", "mp4", "aac", "flac", "ogg", "oga"];
18
19/// A recording in no format this reads, or one that does not decode.
20pub const UNREADABLE: &str = sva_core::RECORDING_UNREADABLE;
21
22/// Every channel in its own plane at the file's own rate, never resampled, so a reference
23/// recording's own clock is what every reading is against.
24pub fn read_recording(path: &Path) -> Result<Planes, CliError> {
25    let extension = path
26        .extension()
27        .and_then(|e| e.to_str())
28        .map(str::to_ascii_lowercase)
29        .unwrap_or_default();
30    match extension.as_str() {
31        "wav" => wav(path),
32        known if READS.contains(&known) => decoded(path, known),
33        _ => Err(CliError::usage(
34            UNREADABLE,
35            format!(
36                "`{}` is in no format this reads; it reads `.{}`",
37                path.display(),
38                READS.join("`, `.")
39            ),
40        )),
41    }
42}
43
44/// A file that is not there is `not_found`, never the `internal_error` of a tool that broke.
45fn missing(path: &Path, e: &std::io::Error) -> CliError {
46    match e.kind() {
47        std::io::ErrorKind::NotFound => {
48            CliError::NotFound(format!("no such file: {}", path.display()))
49        }
50        _ => CliError::Io(format!("could not open {}: {e}", path.display())),
51    }
52}
53
54fn wav(path: &Path) -> Result<Planes, CliError> {
55    let mut reader = hound::WavReader::open(path).map_err(|e| match e {
56        hound::Error::IoError(io) => missing(path, &io),
57        e => CliError::usage(
58            UNREADABLE,
59            format!("could not read {}: {e}", path.display()),
60        ),
61    })?;
62    let spec = reader.spec();
63    let channels = spec.channels as usize;
64    if channels == 0 {
65        return Err(CliError::usage(
66            UNREADABLE,
67            format!("{} declares 0 channels", path.display()),
68        ));
69    }
70    let mut planes: Vec<Vec<f32>> = vec![Vec::new(); channels];
71    let bail = |e: hound::Error| CliError::Io(format!("could not read {}: {e}", path.display()));
72    match spec.sample_format {
73        hound::SampleFormat::Float => {
74            for (i, sample) in reader.samples::<f32>().enumerate() {
75                planes[i % channels].push(sample.map_err(bail)?);
76            }
77        }
78        hound::SampleFormat::Int => {
79            // The full scale `wav.rs` quantizes against, `2^(b-1) - 1`.
80            let full_scale = ((1i64 << (spec.bits_per_sample - 1)) - 1) as f32;
81            for (i, sample) in reader.samples::<i32>().enumerate() {
82                planes[i % channels].push(sample.map_err(bail)? as f32 / full_scale);
83            }
84        }
85    }
86    Ok((planes, spec.sample_rate))
87}
88
89fn decoded(path: &Path, extension: &str) -> Result<Planes, CliError> {
90    let file = std::fs::File::open(path).map_err(|e| missing(path, &e))?;
91    let refused = |why: String| CliError::usage(UNREADABLE, why);
92    let unread = |e: Error| refused(format!("could not decode `{}`: {e}", path.display()));
93    let mut hint = Hint::new();
94    hint.with_extension(extension);
95    let stream = MediaSourceStream::new(Box::new(file), Default::default());
96    let mut format = symphonia::default::get_probe()
97        .probe(
98            &hint,
99            stream,
100            FormatOptions::default(),
101            MetadataOptions::default(),
102        )
103        .map_err(unread)?;
104    let track = format
105        .default_track(TrackType::Audio)
106        .ok_or_else(|| refused(format!("`{}` holds no audio track", path.display())))?;
107    let id = track.id;
108    let params = track
109        .codec_params
110        .as_ref()
111        .and_then(|p| p.audio())
112        .ok_or_else(|| refused(format!("`{}` names no audio codec", path.display())))?;
113    // An integer source reads at the wav full scale, `2^(b-1) - 1`, where the decoder's is `2^(b-1)`.
114    let scale = match (extension, params.bits_per_sample) {
115        ("flac", Some(bits)) if (2..=32).contains(&bits) => {
116            let full = 2f64.powi(bits as i32 - 1);
117            (full / (full - 1.0)) as f32
118        }
119        _ => 1.0,
120    };
121    let mut decoder = symphonia::default::get_codecs()
122        .make_audio_decoder(params, &AudioDecoderOptions::default())
123        .map_err(unread)?;
124    let (mut planes, mut rate): (Vec<Vec<f32>>, Option<u32>) = (Vec::new(), None);
125    let mut block: Vec<Vec<f32>> = Vec::new();
126    while let Some(packet) = format.next_packet().map_err(unread)? {
127        if packet.track_id != id {
128            continue;
129        }
130        let audio = decoder.decode(&packet).map_err(unread)?;
131        rate.get_or_insert(audio.spec().rate());
132        audio.copy_to_vecs_planar(&mut block);
133        planes.resize_with(block.len(), Vec::new);
134        for (plane, part) in planes.iter_mut().zip(&block) {
135            plane.extend(part.iter().map(|v| v * scale));
136        }
137    }
138    let rate = rate.ok_or_else(|| refused(format!("`{}` holds no audio", path.display())))?;
139    if matches!(extension, "m4a" | "mp4") {
140        let bytes = std::fs::read(path).map_err(|e| missing(path, &e))?;
141        if let Some((skip, keep)) = edit(&bytes, rate) {
142            for plane in &mut planes {
143                plane.drain(..skip.min(plane.len()));
144                if let Some(keep) = keep {
145                    plane.truncate(keep);
146                }
147            }
148        }
149    }
150    Ok((planes, rate))
151}
152
153/// Each box in `data[from..to]`: its type and its body's bounds.
154fn boxes(data: &[u8], from: usize, to: usize) -> Vec<([u8; 4], usize, usize)> {
155    let mut out = Vec::new();
156    let mut at = from;
157    while at + 8 <= to {
158        let size = u32::from_be_bytes(data[at..at + 4].try_into().expect("4 bytes")) as usize;
159        let kind: [u8; 4] = data[at + 4..at + 8].try_into().expect("4 bytes");
160        let (body, end) = match size {
161            0 => (at + 8, to),
162            1 if at + 16 <= to => {
163                let wide = u64::from_be_bytes(data[at + 8..at + 16].try_into().expect("8 bytes"));
164                (at + 16, at.saturating_add(wide as usize))
165            }
166            n => (at + 8, at.saturating_add(n)),
167        };
168        if end > to || end < body {
169            break;
170        }
171        out.push((kind, body, end));
172        at = end;
173    }
174    out
175}
176
177fn word(data: &[u8], at: usize) -> Option<u64> {
178    Some(u64::from(u32::from_be_bytes(
179        data.get(at..at + 4)?.try_into().ok()?,
180    )))
181}
182
183fn wide(data: &[u8], at: usize) -> Option<u64> {
184    Some(u64::from_be_bytes(data.get(at..at + 8)?.try_into().ok()?))
185}
186
187/// A box's timescale, after its version byte: at 12 in version 0, 20 in version 1.
188fn timescale(data: &[u8], body: usize) -> Option<u64> {
189    word(data, body + if data.get(body)? == &1 { 20 } else { 12 })
190}
191
192/// The frames the sound track's first edit of media skips, and keeps where it states a
193/// duration: an MP4 states its encoder's priming and padding in its edit list, which the
194/// decoder does not apply. An empty edit, media time -1, places no media and is passed over.
195fn edit(data: &[u8], rate: u32) -> Option<(usize, Option<usize>)> {
196    let find = |within: (usize, usize), kind: &[u8; 4]| {
197        boxes(data, within.0, within.1)
198            .into_iter()
199            .find(|(k, ..)| k == kind)
200            .map(|(_, b, e)| (b, e))
201    };
202    let moov = find((0, data.len()), b"moov")?;
203    let movie = timescale(data, find(moov, b"mvhd")?.0)?;
204    for (kind, body, end) in boxes(data, moov.0, moov.1) {
205        if &kind != b"trak" {
206            continue;
207        }
208        let mdia = find((body, end), b"mdia")?;
209        if data.get(find(mdia, b"hdlr")?.0 + 8..)?.get(..4)? != b"soun" {
210            continue;
211        }
212        let media = timescale(data, find(mdia, b"mdhd")?.0)?;
213        let elst = find(find((body, end), b"edts")?, b"elst")?.0;
214        let v1 = data.get(elst)? == &1;
215        let frames = |count: u64, scale: u64| {
216            (count as f64 * f64::from(rate) / scale as f64).round() as usize
217        };
218        for entry in 0..word(data, elst + 4)? as usize {
219            let at = elst + 8 + entry * if v1 { 20 } else { 12 };
220            let (duration, start) = match v1 {
221                true => (wide(data, at)?, wide(data, at + 8)? as i64),
222                false => (
223                    word(data, at)?,
224                    i64::from(word(data, at + 4)? as u32 as i32),
225                ),
226            };
227            if let Ok(start) = u64::try_from(start) {
228                let keep = (duration > 0).then(|| frames(duration, movie));
229                return Some((frames(start, media), keep));
230            }
231        }
232        return None;
233    }
234    None
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::wav::{SampleEncoding, write_channels};
241
242    fn tmp(name: &str) -> std::path::PathBuf {
243        let path = std::env::temp_dir().join(format!(
244            "sva-cli-recording-{name}-{:x}.wav",
245            std::process::id()
246        ));
247        let _ = std::fs::remove_file(&path);
248        path
249    }
250
251    /// Inverses at 32-bit float: the round trip is exact, at the file's own rate.
252    #[test]
253    fn reads_round_trips_a_float_wav_exactly_at_its_own_rate() {
254        let path = tmp("read-float-roundtrip");
255        let left = [0.5f32, -0.25, 0.75, -1.0];
256        let right = [-0.5f32, 0.25, -0.75, 1.0];
257        write_channels(&[&left, &right], 48_000, &path, SampleEncoding::Float).unwrap();
258
259        let (planes, sample_rate) = read_recording(&path).unwrap();
260        assert_eq!(sample_rate, 48_000, "no resampling, ever");
261        assert_eq!(planes, vec![left.to_vec(), right.to_vec()]);
262
263        let _ = std::fs::remove_file(&path);
264    }
265
266    /// 16-bit PCM quantizes on the way in, so the round trip is within one step.
267    #[test]
268    fn reads_unquantizes_pcm16_within_one_step() {
269        let path = tmp("read-pcm16-roundtrip");
270        let pcm = [0.5f32, -0.5, 0.1, -0.9];
271        write_channels(&[&pcm], 44_100, &path, SampleEncoding::Pcm(16)).unwrap();
272
273        let (planes, sample_rate) = read_recording(&path).unwrap();
274        let mono = &planes[0];
275        assert_eq!(sample_rate, 44_100);
276        assert_eq!(mono.len(), pcm.len());
277        for (got, want) in mono.iter().zip(&pcm) {
278            assert!(
279                (got - want).abs() < 1.0 / f32::from(i16::MAX),
280                "{got} vs {want}"
281            );
282        }
283
284        let _ = std::fs::remove_file(&path);
285    }
286
287    fn write_int(path: &std::path::Path, bits_per_sample: u16, values: &[i32], sample_rate: u32) {
288        let spec = hound::WavSpec {
289            channels: 1,
290            sample_rate,
291            bits_per_sample,
292            sample_format: hound::SampleFormat::Int,
293        };
294        let mut writer = hound::WavWriter::create(path, spec).unwrap();
295        for &v in values {
296            writer.write_sample(v).unwrap();
297        }
298        writer.finalize().unwrap();
299    }
300
301    /// hound's own `Sample for i32` reader already covers 8/16/24/32-bit int — this is the
302    /// unquantize half, checked at the two depths `write_channels` never itself produces.
303    #[test]
304    fn reads_unquantizes_every_int_bit_depth_hound_can_decode() {
305        let path = tmp("read-8bit");
306        write_int(&path, 8, &[127, -128, 0], 22_050);
307        let (planes, sample_rate) = read_recording(&path).unwrap();
308        assert_eq!(sample_rate, 22_050);
309        assert_eq!(planes.len(), 1);
310        assert!((planes[0][0] - 1.0).abs() < 0.01, "{:?}", planes[0]);
311        assert!((planes[0][1] + 1.0).abs() < 0.01, "{:?}", planes[0]);
312        assert_eq!(planes[0][2], 0.0);
313        let _ = std::fs::remove_file(&path);
314
315        let path = tmp("read-24bit");
316        let full_scale = (1i64 << 23) - 1;
317        write_int(
318            &path,
319            24,
320            &[full_scale as i32, -(full_scale as i32), 0],
321            96_000,
322        );
323        let (planes, sample_rate) = read_recording(&path).unwrap();
324        assert_eq!(sample_rate, 96_000);
325        assert_eq!(planes[0], vec![1.0, -1.0, 0.0]);
326        let _ = std::fs::remove_file(&path);
327    }
328
329    /// A round-robin de-interleave, checked against a file `write_channels` never produces
330    /// itself: an odd number of channels with a visibly different signal on each.
331    #[test]
332    fn reads_de_interleaves_every_component_into_its_own_plane() {
333        let path = tmp("read-three-channel");
334        let a = [0.1f32, 0.2];
335        let b = [0.3f32, 0.4];
336        let c = [0.5f32, 0.6];
337        write_channels(&[&a, &b, &c], 44_100, &path, SampleEncoding::Float).unwrap();
338
339        let (planes, _) = read_recording(&path).unwrap();
340        assert_eq!(planes, vec![a.to_vec(), b.to_vec(), c.to_vec()]);
341
342        let _ = std::fs::remove_file(&path);
343    }
344
345    #[test]
346    fn reads_reports_a_missing_file_as_not_found_not_a_panic() {
347        let path = tmp("read-missing");
348        assert!(matches!(read_recording(&path), Err(CliError::NotFound(_))));
349    }
350
351    /// `kind`'s box around `body`.
352    fn boxed(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
353        let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec();
354        out.extend(kind);
355        out.extend(body);
356        out
357    }
358
359    /// A version 0 movie at timescale 1000 holding one sound track at 48000 with `edits`,
360    /// each `(duration in the movie's scale, media time in the track's)`.
361    fn movie(edits: &[(u32, i32)]) -> Vec<u8> {
362        let scaled = |scale: u32| [&[0u8; 12][..], &scale.to_be_bytes()].concat();
363        let mut elst = vec![0, 0, 0, 0];
364        elst.extend((edits.len() as u32).to_be_bytes());
365        for (duration, start) in edits {
366            elst.extend(duration.to_be_bytes());
367            elst.extend(start.to_be_bytes());
368            elst.extend([0, 1, 0, 0]);
369        }
370        let hdlr = [&[0u8; 8][..], b"soun"].concat();
371        let mdia = [boxed(b"mdhd", &scaled(48_000)), boxed(b"hdlr", &hdlr)].concat();
372        let trak = [
373            boxed(b"edts", &boxed(b"elst", &elst)),
374            boxed(b"mdia", &mdia),
375        ]
376        .concat();
377        boxed(
378            b"moov",
379            &[boxed(b"mvhd", &scaled(1000)), boxed(b"trak", &trak)].concat(),
380        )
381    }
382
383    #[test]
384    fn an_edit_list_skips_the_priming_and_keeps_the_duration_it_states() {
385        assert_eq!(
386            edit(&movie(&[(5000, 1024)]), 48_000),
387            Some((1024, Some(240_000)))
388        );
389        assert_eq!(
390            edit(&movie(&[(5000, 1024)]), 44_100),
391            Some((941, Some(220_500)))
392        );
393    }
394
395    #[test]
396    fn an_empty_edit_is_passed_over_and_an_unstated_duration_keeps_every_frame() {
397        assert_eq!(
398            edit(&movie(&[(20, -1), (5000, 2112)]), 48_000),
399            Some((2112, Some(240_000)))
400        );
401        assert_eq!(edit(&movie(&[(0, 1024)]), 48_000), Some((1024, None)));
402        assert_eq!(edit(&movie(&[(20, -1)]), 48_000), None);
403    }
404
405    #[test]
406    fn a_format_it_does_not_read_refuses_naming_every_one_it_does() {
407        let refused = read_recording(Path::new("take.opus")).unwrap_err();
408        assert!(
409            matches!(
410                refused,
411                CliError::Usage {
412                    code: UNREADABLE,
413                    ..
414                }
415            ),
416            "{refused:?}"
417        );
418        for extension in READS {
419            assert!(
420                refused.message().contains(&format!("`.{extension}`")),
421                "{refused:?}"
422            );
423        }
424    }
425}