Skip to main content

xil_audio/
mpeg.rs

1//! MP3 stream info the way mutagen computes it. A transcription of
2//! `mutagen.mp3.MPEGInfo` and `mutagen.mp3._util`.
3//!
4//! The Python pipeline reads durations and bit rates from mutagen headers,
5//! not from a decode, and mutagen disagrees with ffprobe on roughly one
6//! file in six of the real library (encoder delay handling, LAME padding,
7//! CBR estimates). A report that says `2.4s` where Python says `2.5s`
8//! fails parity, so the arithmetic here follows mutagen line for line,
9//! including its file-size fallback and its half-to-even bit rate rounding.
10
11use std::fs::File;
12use std::io::{self, Read, Seek, SeekFrom};
13use std::path::Path;
14
15/// What mutagen raises, by the name Python code prints via
16/// `type(exc).__name__` — that name reaches CSV output in `sfx-impact`.
17#[derive(Debug, thiserror::Error)]
18pub enum MpegError {
19    #[error("can't sync to MPEG frame")]
20    HeaderNotFound,
21    /// Any OS error opening or reading. mutagen wraps these in its own
22    /// `MutagenError`, so the Python name is the same for all of them.
23    #[error("{0}")]
24    Mutagen(String),
25}
26
27impl MpegError {
28    /// The `type(exc).__name__` Python would print.
29    pub fn python_name(&self) -> &'static str {
30        match self {
31            MpegError::HeaderNotFound => "HeaderNotFoundError",
32            MpegError::Mutagen(_) => "MutagenError",
33        }
34    }
35}
36
37impl From<io::Error> for MpegError {
38    fn from(e: io::Error) -> Self {
39        MpegError::Mutagen(e.to_string())
40    }
41}
42
43/// `MPEGInfo`'s useful attributes.
44#[derive(Debug, Clone, PartialEq)]
45pub struct MpegInfo {
46    /// Audio length in seconds.
47    pub length: f64,
48    /// Bits per second; an estimate from the first frame for CBR files.
49    pub bitrate: u64,
50    pub sample_rate: u32,
51    pub channels: u16,
52    /// mutagen's "may not be valid MPEG audio" flag.
53    pub sketchy: bool,
54}
55
56/// Per-`(version, layer)` bit rate tables in kbps, index by the 4-bit field.
57fn bitrate_table(version: Version, layer: u8) -> &'static [u32; 15] {
58    const V1L1: [u32; 15] = [
59        0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448,
60    ];
61    const V1L2: [u32; 15] = [
62        0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384,
63    ];
64    const V1L3: [u32; 15] = [
65        0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
66    ];
67    const V2L1: [u32; 15] = [
68        0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256,
69    ];
70    const V2L23: [u32; 15] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
71    match (version, layer) {
72        (Version::V1, 1) => &V1L1,
73        (Version::V1, 2) => &V1L2,
74        (Version::V1, _) => &V1L3,
75        (_, 1) => &V2L1,
76        _ => &V2L23,
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq)]
81enum Version {
82    V1,
83    V2,
84    V25,
85}
86
87fn sample_rates(version: Version) -> [u32; 3] {
88    match version {
89        Version::V1 => [44100, 48000, 32000],
90        Version::V2 => [22050, 24000, 16000],
91        Version::V25 => [11025, 12000, 8000],
92    }
93}
94
95/// One parsed frame header plus whatever the Xing/VBRI/LAME tags in it
96/// said. `length` is `None` where mutagen leaves the attribute unset.
97struct Frame {
98    frame_offset: u64,
99    /// Where the next frame starts.
100    next_offset: u64,
101    bitrate: u64,
102    sample_rate: u32,
103    channels: u16,
104    sketchy: bool,
105    length: Option<f64>,
106}
107
108fn read_exact_at<R: Read + Seek>(f: &mut R, pos: u64, n: usize) -> io::Result<Option<Vec<u8>>> {
109    f.seek(SeekFrom::Start(pos))?;
110    let mut buf = vec![0u8; n];
111    let mut got = 0;
112    while got < n {
113        let k = f.read(&mut buf[got..])?;
114        if k == 0 {
115            break;
116        }
117        got += k;
118    }
119    Ok(if got == n { Some(buf) } else { None })
120}
121
122fn u32be(b: &[u8]) -> u32 {
123    u32::from_be_bytes([b[0], b[1], b[2], b[3]])
124}
125fn u16be(b: &[u8]) -> u16 {
126    u16::from_be_bytes([b[0], b[1]])
127}
128
129/// `skip_id3`: step over every leading `ID3` tag (WMP stacks them).
130fn skip_id3<R: Read + Seek>(f: &mut R, mut pos: u64) -> io::Result<u64> {
131    loop {
132        let Some(h) = read_exact_at(f, pos, 10)? else {
133            return Ok(pos);
134        };
135        // BitPaddedInt over the 4 syncsafe size bytes.
136        let insize = h[6..10]
137            .iter()
138            .fold(0u64, |acc, b| (acc << 7) | u64::from(b & 0x7f));
139        if &h[0..3] == b"ID3" && insize > 0 {
140            pos += 10 + insize;
141        } else {
142            return Ok(pos);
143        }
144    }
145}
146
147/// Parse the frame at `pos`. `None` is mutagen's `HeaderNotFoundError`.
148fn parse_frame<R: Read + Seek>(f: &mut R, pos: u64) -> io::Result<Option<Frame>> {
149    let Some(h) = read_exact_at(f, pos, 4)? else {
150        return Ok(None); // truncated header
151    };
152    let bits = u32be(&h);
153    if bits >> 21 != 0x7ff {
154        return Ok(None); // invalid sync
155    }
156    let version = (bits >> 19) & 0x3;
157    let layer_bits = (bits >> 17) & 0x3;
158    let bitrate_idx = (bits >> 12) & 0xf;
159    let sr_idx = (bits >> 10) & 0x3;
160    let padding = (bits >> 9) & 0x1;
161    let mode = (bits >> 6) & 0x3;
162
163    // Strict, to keep false positives down.
164    if version == 1 || layer_bits == 0 || sr_idx == 0x3 || bitrate_idx == 0xf || bitrate_idx == 0 {
165        return Ok(None);
166    }
167    let version = match version {
168        0 => Version::V25,
169        2 => Version::V2,
170        _ => Version::V1,
171    };
172    let layer = (4 - layer_bits) as u8;
173    let channels: u16 = if mode == 3 { 1 } else { 2 };
174    let bitrate = u64::from(bitrate_table(version, layer)[bitrate_idx as usize]) * 1000;
175    let sample_rate = sample_rates(version)[sr_idx as usize];
176
177    let (frame_size, slot): (u64, u64) = if layer == 1 {
178        (384, 4)
179    } else if version != Version::V1 && layer == 3 {
180        (576, 1)
181    } else {
182        (1152, 1)
183    };
184    let frame_length =
185        ((frame_size / 8 * bitrate) / u64::from(sample_rate) + u64::from(padding)) * slot;
186
187    let mut frame = Frame {
188        frame_offset: pos,
189        next_offset: pos + frame_length,
190        bitrate,
191        sample_rate,
192        channels,
193        sketchy: true,
194        length: None,
195    };
196    if layer == 3 {
197        parse_vbr_header(f, &mut frame, version, mode, frame_size, frame_length)?;
198    }
199    Ok(Some(frame))
200}
201
202/// A Xing/Info tag trumps the header's bit rate and gives a real length;
203/// failing that, a Fraunhofer VBRI tag. Neither raises.
204fn parse_vbr_header<R: Read + Seek>(
205    f: &mut R,
206    frame: &mut Frame,
207    version: Version,
208    mode: u32,
209    frame_size: u64,
210    frame_length: u64,
211) -> io::Result<()> {
212    let xing_offset: u64 = match (version, mode) {
213        (Version::V1, m) if m != 3 => 36,
214        (Version::V1, _) => 21,
215        (_, m) if m != 3 => 21,
216        _ => 13,
217    };
218    if let Some(xing) = parse_xing(f, frame.frame_offset + xing_offset)? {
219        frame.sketchy = false;
220        if let Some(frames) = xing.frames {
221            let mut samples = frame_size as i64 * i64::from(frames);
222            if let Some(bytes) = xing.bytes {
223                if samples > 0 {
224                    // The first frame is counted in xing.bytes but not in
225                    // xing.frames.
226                    let audio_bytes = (i64::from(bytes) - frame_length as i64).max(0);
227                    let v =
228                        (audio_bytes as f64 * 8.0 * f64::from(frame.sample_rate)) / samples as f64;
229                    frame.bitrate = intround(v);
230                }
231            }
232            if let Some((delay, padding)) = xing.lame_delay_padding {
233                samples -= i64::from(delay);
234                samples -= i64::from(padding);
235            }
236            if samples < 0 {
237                // Older LAME wrote bogus delay/padding for short low-bitrate
238                // files.
239                samples = 0;
240            }
241            frame.length = Some(samples as f64 / f64::from(frame.sample_rate));
242        }
243        return Ok(());
244    }
245
246    if let Some((vbri_bytes, vbri_frames)) = parse_vbri(f, frame.frame_offset + 36)? {
247        frame.sketchy = false;
248        let length = (frame_size * u64::from(vbri_frames)) as f64 / f64::from(frame.sample_rate);
249        frame.length = Some(length);
250        if length != 0.0 {
251            frame.bitrate = ((f64::from(vbri_bytes) * 8.0) / length) as u64;
252        }
253    }
254    Ok(())
255}
256
257/// `intround`: `Decimal.from_float(v).to_integral_value(ROUND_HALF_EVEN)`.
258fn intround(v: f64) -> u64 {
259    let r = v.round_ties_even();
260    if r < 0.0 {
261        0
262    } else {
263        r as u64
264    }
265}
266
267struct Xing {
268    frames: Option<u32>,
269    bytes: Option<u32>,
270    /// `(encoder_delay_start, encoder_padding_end)` from a LAME info tag.
271    lame_delay_padding: Option<(u32, u32)>,
272}
273
274fn parse_xing<R: Read + Seek>(f: &mut R, pos: u64) -> io::Result<Option<Xing>> {
275    let Some(head) = read_exact_at(f, pos, 8)? else {
276        return Ok(None);
277    };
278    if &head[0..4] != b"Xing" && &head[0..4] != b"Info" {
279        return Ok(None);
280    }
281    let flags = u32be(&head[4..8]);
282    let mut cur = pos + 8;
283    let mut take = |f: &mut R, n: usize| -> io::Result<Option<Vec<u8>>> {
284        let r = read_exact_at(f, cur, n)?;
285        cur += n as u64;
286        Ok(r)
287    };
288    let mut xing = Xing {
289        frames: None,
290        bytes: None,
291        lame_delay_padding: None,
292    };
293    if flags & 0x1 != 0 {
294        let Some(b) = take(f, 4)? else {
295            return Ok(None); // "Xing header truncated"
296        };
297        xing.frames = Some(u32be(&b));
298    }
299    if flags & 0x2 != 0 {
300        let Some(b) = take(f, 4)? else {
301            return Ok(None);
302        };
303        xing.bytes = Some(u32be(&b));
304    }
305    if flags & 0x4 != 0 && take(f, 100)?.is_none() {
306        return Ok(None);
307    }
308    if flags & 0x8 != 0 && take(f, 4)?.is_none() {
309        return Ok(None);
310    }
311    // LAME version string, then (for LAME >= 3.90) the 27-byte info tag
312    // starting 9 bytes after the "LAME"/"L3.99" marker. Any parse failure
313    // here is mutagen's LAMEError, swallowed: no delay/padding correction.
314    xing.lame_delay_padding = parse_lame(f, cur)?;
315    Ok(Some(xing))
316}
317
318/// `LAMEHeader.parse_version` + the two fields of `LAMEHeader` the length
319/// needs. `None` wherever mutagen raises `LAMEError` or reports no
320/// extended header.
321fn parse_lame<R: Read + Seek>(f: &mut R, pos: u64) -> io::Result<Option<(u32, u32)>> {
322    let Some(data) = read_exact_at(f, pos, 20)? else {
323        return Ok(None);
324    };
325    if !(data.starts_with(b"LAME") || data.starts_with(b"L3.99")) {
326        return Ok(None);
327    }
328    // data.lstrip(b"EMAL")
329    let mut i = 0;
330    while i < data.len() && matches!(data[i], b'E' | b'M' | b'A' | b'L') {
331        i += 1;
332    }
333    let major = data.get(i).copied();
334    i += 1;
335    // .lstrip(b".")
336    while i < data.len() && data[i] == b'.' {
337        i += 1;
338    }
339    let minor_start = i;
340    while i < data.len() && data[i].is_ascii_digit() {
341        i += 1;
342    }
343    let minor_digits = &data[minor_start..i];
344    let major = match major {
345        Some(c) if c.is_ascii_digit() => u32::from(c - b'0'),
346        _ => return Ok(None),
347    };
348    if minor_digits.is_empty() {
349        return Ok(None);
350    }
351    let minor: u32 = std::str::from_utf8(minor_digits)
352        .ok()
353        .and_then(|s| s.parse().ok())
354        .unwrap_or(0);
355    let rest = &data[i.min(data.len())..];
356
357    // Pre-3.90 LAME (and "LAME3.90 (alpha)") has no extended header.
358    let paren = rest.len() >= 11 && data.get(9) == Some(&b'(');
359    if (major, minor) < (3, 90) || ((major, minor) == (3, 90) && paren) {
360        return Ok(None);
361    }
362    if rest.len() < 11 {
363        return Ok(None); // "Invalid version: too long"
364    }
365    // The extended header starts 11 bytes back from the end of the 20 read.
366    let Some(payload) = read_exact_at(f, pos + 9, 27)? else {
367        return Ok(None); // "Not enough data"
368    };
369    if payload[0] >> 4 != 0 {
370        return Ok(None); // unsupported header revision
371    }
372    let delay = (u32::from(payload[12]) << 4) | (u32::from(payload[13]) >> 4);
373    let padding = ((u32::from(payload[13]) & 0xf) << 8) | u32::from(payload[14]);
374    Ok(Some((delay, padding)))
375}
376
377/// `VBRIHeader`: `(bytes, frames)` when a valid tag sits at `pos`.
378fn parse_vbri<R: Read + Seek>(f: &mut R, pos: u64) -> io::Result<Option<(u32, u32)>> {
379    let Some(d) = read_exact_at(f, pos, 26)? else {
380        return Ok(None);
381    };
382    if !d.starts_with(b"VBRI") || u16be(&d[4..6]) != 1 {
383        return Ok(None);
384    }
385    let bytes = u32be(&d[10..14]);
386    let frames = u32be(&d[14..18]);
387    let toc_entries = u64::from(u16be(&d[18..20]));
388    let toc_entry_size = u16be(&d[22..24]);
389    if toc_entry_size != 2 && toc_entry_size != 4 {
390        return Ok(None);
391    }
392    let toc_size = (toc_entries * u64::from(toc_entry_size)) as usize;
393    if read_exact_at(f, pos + 26, toc_size)?.is_none() {
394        return Ok(None); // "VBRI header truncated"
395    }
396    Ok(Some((bytes, frames)))
397}
398
399/// `MPEGInfo(fileobj)`: sync to the first believable frame run and derive
400/// length and bit rate from it.
401pub fn info_from<R: Read + Seek>(f: &mut R) -> Result<MpegInfo, MpegError> {
402    let start = skip_id3(f, 0)?;
403
404    // iter_sync: every 0xFF 0xEx pair in the first 1 MiB after the tags.
405    const MAX_READ: usize = 1024 * 1024;
406    const MAX_SYNCS: usize = 1500;
407    const ENOUGH_FRAMES: usize = 4;
408    const MIN_FRAMES: usize = 2;
409
410    f.seek(SeekFrom::Start(start))?;
411    let mut region = Vec::with_capacity(MAX_READ.min(1 << 16));
412    f.by_ref().take(MAX_READ as u64).read_to_end(&mut region)?;
413
414    let mut sketchy = true;
415    let mut first_frame: Option<Frame> = None;
416    let mut syncs_left = MAX_SYNCS;
417    let mut frames: Vec<Frame> = Vec::new();
418
419    let mut i = 0;
420    while i + 1 < region.len() {
421        if !(region[i] == 0xff && region[i + 1] & 0xe0 == 0xe0) {
422            i += 1;
423            continue;
424        }
425        let sync_pos = start + i as u64;
426        i += 1;
427
428        syncs_left -= 1;
429        if syncs_left == 0 {
430            break;
431        }
432
433        let mut next = sync_pos;
434        for _ in 0..ENOUGH_FRAMES {
435            let Some(fr) = parse_frame(f, next)? else {
436                break;
437            };
438            next = fr.next_offset;
439            let stop = !fr.sketchy;
440            frames.push(fr);
441            if stop {
442                break;
443            }
444        }
445
446        if frames.len() >= MIN_FRAMES && first_frame.is_none() {
447            first_frame = Some(clone_frame(&frames[0]));
448        }
449        if frames.last().is_some_and(|fr| !fr.sketchy) {
450            first_frame = frames.pop();
451            sketchy = false;
452            break;
453        }
454        if frames.len() >= ENOUGH_FRAMES {
455            first_frame = Some(frames.swap_remove(0));
456            sketchy = false;
457            break;
458        }
459        frames.clear();
460    }
461
462    let Some(first) = first_frame else {
463        return Err(MpegError::HeaderNotFound);
464    };
465
466    let length = match first.length {
467        Some(l) => l,
468        None => {
469            // No length in the stream: estimate from the file size.
470            let end = f.seek(SeekFrom::End(0))?;
471            let content_size = end.saturating_sub(first.frame_offset);
472            8.0 * content_size as f64 / first.bitrate as f64
473        }
474    };
475    Ok(MpegInfo {
476        length,
477        bitrate: first.bitrate,
478        sample_rate: first.sample_rate,
479        channels: first.channels,
480        sketchy,
481    })
482}
483
484fn clone_frame(fr: &Frame) -> Frame {
485    Frame {
486        frame_offset: fr.frame_offset,
487        next_offset: fr.next_offset,
488        bitrate: fr.bitrate,
489        sample_rate: fr.sample_rate,
490        channels: fr.channels,
491        sketchy: fr.sketchy,
492        length: fr.length,
493    }
494}
495
496/// `MP3(path).info`.
497pub fn info(path: &Path) -> Result<MpegInfo, MpegError> {
498    let mut f = File::open(path)?;
499    info_from(&mut f)
500}
501
502/// `_mp3_duration_ms`: `int(info.length * 1000)`.
503pub fn duration_ms(path: &Path) -> Result<i64, MpegError> {
504    Ok((info(path)?.length * 1000.0) as i64)
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::process::Command;
511
512    fn ffmpeg_available() -> bool {
513        crate::ffmpeg::available()
514    }
515
516    fn gen(dir: &Path, name: &str, rate: u32, ch: u32, extra: &[&str]) -> std::path::PathBuf {
517        let out = dir.join(name);
518        let filt = format!("sine=frequency=440:duration=2.3:sample_rate={rate}");
519        let st = Command::new("ffmpeg")
520            .args(["-v", "quiet", "-y", "-f", "lavfi", "-i", &filt, "-ac"])
521            .arg(ch.to_string())
522            .args(extra)
523            .arg(&out)
524            .status()
525            .unwrap();
526        assert!(st.success());
527        out
528    }
529
530    /// Expected values were printed by mutagen 1.47 on files generated with
531    /// exactly these ffmpeg commands.
532    #[test]
533    #[allow(clippy::type_complexity)]
534    fn matches_mutagen_on_lame_encoded_tones() {
535        if !ffmpeg_available() {
536            return;
537        }
538        let tmp = tempfile::tempdir().unwrap();
539        let d = tmp.path();
540        let cases: [(&str, u32, u32, &[&str], f64, u64, bool); 5] = [
541            (
542                "stereo44.mp3",
543                44100,
544                2,
545                &[],
546                2.3510204081632655,
547                127999,
548                false,
549            ),
550            (
551                "mono44.mp3",
552                44100,
553                1,
554                &[],
555                2.3510204081632655,
556                63999,
557                false,
558            ),
559            (
560                "mono22.mp3",
561                22050,
562                1,
563                &[],
564                2.3771428571428572,
565                32001,
566                false,
567            ),
568            (
569                "stereo11.mp3",
570                11025,
571                2,
572                &[],
573                2.455510204081633,
574                32000,
575                false,
576            ),
577            (
578                "noxing.mp3",
579                44100,
580                2,
581                &["-write_xing", "0"],
582                2.351,
583                128000,
584                false,
585            ),
586        ];
587        for (name, rate, ch, extra, len, br, sk) in cases {
588            let p = gen(d, name, rate, ch, extra);
589            let i = info(&p).unwrap();
590            assert_eq!(i.length, len, "{name} length");
591            assert_eq!(i.bitrate, br, "{name} bitrate");
592            assert_eq!(i.sketchy, sk, "{name} sketchy");
593            assert_eq!(i.channels, ch as u16, "{name} channels");
594            assert_eq!(i.sample_rate, rate, "{name} rate");
595        }
596    }
597
598    #[test]
599    fn non_audio_is_header_not_found() {
600        let tmp = tempfile::tempdir().unwrap();
601        for (name, bytes) in [
602            ("empty.mp3", &b""[..]),
603            ("fake.mp3", b"ID3\x03\x00door"),
604            ("text.mp3", b"hello\n"),
605        ] {
606            let p = tmp.path().join(name);
607            std::fs::write(&p, bytes).unwrap();
608            let e = info(&p).unwrap_err();
609            assert_eq!(e.python_name(), "HeaderNotFoundError", "{name}");
610            assert_eq!(e.to_string(), "can't sync to MPEG frame");
611        }
612        let e = info(&tmp.path().join("missing.mp3")).unwrap_err();
613        assert_eq!(e.python_name(), "MutagenError");
614        let e = info(tmp.path()).unwrap_err();
615        assert_eq!(e.python_name(), "MutagenError");
616    }
617
618    #[test]
619    fn duration_ms_truncates_like_int() {
620        if !ffmpeg_available() {
621            return;
622        }
623        let tmp = tempfile::tempdir().unwrap();
624        let p = gen(tmp.path(), "s.mp3", 44100, 2, &[]);
625        assert_eq!(duration_ms(&p).unwrap(), 2351);
626    }
627
628    #[test]
629    fn intround_is_half_to_even() {
630        assert_eq!(intround(2.5), 2);
631        assert_eq!(intround(3.5), 4);
632        assert_eq!(intround(127998.7), 127999);
633    }
634
635    /// On-demand sweep against a real library. Set `XIL_MPEG_PROBE_DIR`
636    /// and it writes `path\tlength\tbitrate` lines to `XIL_MPEG_PROBE_OUT`
637    /// for an offline diff against mutagen.
638    #[test]
639    #[ignore]
640    fn probe_library() {
641        let Ok(dir) = std::env::var("XIL_MPEG_PROBE_DIR") else {
642            return;
643        };
644        let out = std::env::var("XIL_MPEG_PROBE_OUT").unwrap();
645        let mut lines = Vec::new();
646        fn walk(d: &Path, acc: &mut Vec<std::path::PathBuf>) {
647            for e in std::fs::read_dir(d).unwrap().flatten() {
648                let p = e.path();
649                if p.is_dir() {
650                    walk(&p, acc);
651                } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("mp3")) {
652                    acc.push(p);
653                }
654            }
655        }
656        let mut files = Vec::new();
657        walk(Path::new(&dir), &mut files);
658        files.sort();
659        for p in files {
660            let line = match info(&p) {
661                Ok(i) => format!("{}\t{:?}\t{}", p.display(), i.length, i.bitrate),
662                Err(e) => format!("{}\tERR\t{}", p.display(), e.python_name()),
663            };
664            lines.push(line);
665        }
666        std::fs::write(out, lines.join("\n") + "\n").unwrap();
667    }
668}