audiobook-creation-exchange 0.1.0

ACX-compliant audio post-processing: normalisation, limiting, gating, LUFS measurement, and spectral analysis for AI-generated speech audio.
Documentation
//! MP3 bitstream validation: CBR frame consistency and ID3v2 tag completeness.

/// Bitrate table for MPEG1 Layer III (kbps), indexed by bitrate_index (0–15).
const MPEG1_L3_BITRATES: [u16; 16] = [
    0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
];

/// Sample rate table for MPEG1, indexed by sample_rate_index (0–3).
const MPEG1_SAMPLE_RATES: [u32; 4] = [44100, 48000, 32000, 0];

/// Required ID3v2 frame IDs for audiobook metadata completeness.
pub const REQUIRED_ID3_FRAMES: &[&[u8; 4]] = &[
    b"TIT2", // Title
    b"TPE1", // Artist / Narrator
    b"TALB", // Album / Scroll title
    b"TRCK", // Track number
];

/// Result of a CBR consistency check.
#[derive(Debug, Clone)]
pub struct CbrReport {
    /// Whether all frames have the same bitrate.
    pub is_cbr: bool,
    /// The first bitrate seen (kbps). `None` if no valid frames were found.
    pub detected_bitrate_kbps: Option<u16>,
    /// Number of frames that deviated from the first bitrate.
    pub vbr_frame_count: usize,
    /// Total valid MP3 frames parsed.
    pub total_frames: usize,
}

/// Result of an ID3v2 tag completeness check.
#[derive(Debug, Clone)]
pub struct Id3Report {
    /// Whether all required frames are present.
    pub complete: bool,
    /// List of missing frame IDs as ASCII strings.
    pub missing: Vec<String>,
}

/// Scan `mp3_bytes` for MP3 frame headers and verify CBR consistency.
///
/// Walks the byte stream looking for the 12-bit sync word `0xFFE` (or `0xFFA`/`0xFFB`)
/// and checks that every frame's bitrate field matches the first valid frame.
pub fn check_cbr(mp3_bytes: &[u8]) -> CbrReport {
    let mut i = 0usize;
    let mut first_bitrate: Option<u16> = None;
    let mut vbr_count = 0usize;
    let mut total = 0usize;

    while i + 4 <= mp3_bytes.len() {
        // Sync: 0xFF + (0xE0..=0xFF)
        if mp3_bytes[i] != 0xFF || (mp3_bytes[i + 1] & 0xE0) != 0xE0 {
            i += 1;
            continue;
        }

        let h1 = mp3_bytes[i + 1];
        let h2 = mp3_bytes[i + 2];

        // MPEG version: bits 4-3 of h1. 0b11 = MPEG1.
        let mpeg_version = (h1 >> 3) & 0x03;
        // Layer: bits 2-1 of h1. 0b01 = Layer III.
        let layer = (h1 >> 1) & 0x03;

        if mpeg_version != 3 || layer != 1 {
            i += 1;
            continue;
        }

        let bitrate_idx = ((h2 >> 4) & 0x0F) as usize;
        let sample_rate_idx = ((h2 >> 2) & 0x03) as usize;
        let padding = (h2 >> 1) & 0x01;

        let bitrate = MPEG1_L3_BITRATES[bitrate_idx];
        let sample_rate = MPEG1_SAMPLE_RATES[sample_rate_idx];

        if bitrate == 0 || sample_rate == 0 {
            i += 1;
            continue;
        }

        total += 1;

        match first_bitrate {
            None => first_bitrate = Some(bitrate),
            Some(br) if br != bitrate => vbr_count += 1,
            _ => {}
        }

        // Advance by computed frame size
        let frame_size = 144 * bitrate as usize * 1000 / sample_rate as usize + padding as usize;
        i += frame_size.max(1);
    }

    CbrReport {
        is_cbr: vbr_count == 0,
        detected_bitrate_kbps: first_bitrate,
        vbr_frame_count: vbr_count,
        total_frames: total,
    }
}

/// Check that `mp3_bytes` contains all required ID3v2 tag frames.
///
/// Parses the ID3v2.3/2.4 header (if present) and scans for each required
/// 4-byte frame ID. Does not validate frame content — only presence.
pub fn check_id3_tags(mp3_bytes: &[u8]) -> Id3Report {
    if mp3_bytes.len() < 10 || &mp3_bytes[..3] != b"ID3" {
        let missing = REQUIRED_ID3_FRAMES
            .iter()
            .map(|id| String::from_utf8_lossy(*id).into_owned())
            .collect();
        return Id3Report {
            complete: false,
            missing,
        };
    }

    // Syncsafe integer for tag size (bytes 6–9)
    let tag_size = syncsafe_to_u32(&mp3_bytes[6..10]) as usize + 10;
    let tag_region = &mp3_bytes[..tag_size.min(mp3_bytes.len())];

    let mut missing = Vec::new();
    for &required_id in REQUIRED_ID3_FRAMES {
        if !tag_region.windows(4).any(|w| w == required_id.as_slice()) {
            missing.push(String::from_utf8_lossy(required_id).into_owned());
        }
    }

    Id3Report {
        complete: missing.is_empty(),
        missing,
    }
}

fn syncsafe_to_u32(bytes: &[u8]) -> u32 {
    (bytes[0] as u32) << 21 | (bytes[1] as u32) << 14 | (bytes[2] as u32) << 7 | bytes[3] as u32
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_mp3_reports_no_frames() {
        let report = check_cbr(&[]);
        assert_eq!(report.total_frames, 0);
        assert!(report.is_cbr); // vacuously true
        assert!(report.detected_bitrate_kbps.is_none());
    }

    #[test]
    fn no_id3_tag_reports_all_missing() {
        let report = check_id3_tags(&[0xFF, 0xFB, 0x90, 0x00]);
        assert!(!report.complete);
        assert_eq!(report.missing.len(), REQUIRED_ID3_FRAMES.len());
    }

    #[test]
    fn minimal_id3_tag_with_all_frames_passes() {
        // Build a minimal ID3v2.3 tag that contains all required frame IDs
        let mut tag = b"ID3".to_vec();
        tag.extend_from_slice(&[3, 0, 0]); // version 2.3, no flags

        let mut frames = Vec::new();
        for &frame_id in REQUIRED_ID3_FRAMES {
            frames.extend_from_slice(frame_id.as_slice());
            frames.extend_from_slice(&[0, 0, 0, 2]); // size = 2
            frames.extend_from_slice(&[0, 0]); // flags
            frames.extend_from_slice(&[0, b'X']); // dummy content
        }

        // Syncsafe size
        let size = frames.len() as u32;
        tag.push(((size >> 21) & 0x7F) as u8);
        tag.push(((size >> 14) & 0x7F) as u8);
        tag.push(((size >> 7) & 0x7F) as u8);
        tag.push((size & 0x7F) as u8);
        tag.extend_from_slice(&frames);

        let report = check_id3_tags(&tag);
        assert!(
            report.complete,
            "Expected complete ID3 tag, missing: {:?}",
            report.missing
        );
    }

    #[test]
    fn id3_tag_missing_track_number_reports_it() {
        let mut tag = b"ID3".to_vec();
        tag.extend_from_slice(&[3, 0, 0]);

        let mut frames = Vec::new();
        for &frame_id in &[b"TIT2", b"TPE1", b"TALB"] {
            frames.extend_from_slice(frame_id.as_slice());
            frames.extend_from_slice(&[0, 0, 0, 2]);
            frames.extend_from_slice(&[0, 0, 0, b'X']);
        }
        // TRCK deliberately omitted

        let size = frames.len() as u32;
        tag.push(((size >> 21) & 0x7F) as u8);
        tag.push(((size >> 14) & 0x7F) as u8);
        tag.push(((size >> 7) & 0x7F) as u8);
        tag.push((size & 0x7F) as u8);
        tag.extend_from_slice(&frames);

        let report = check_id3_tags(&tag);
        assert!(!report.complete);
        assert!(report.missing.contains(&"TRCK".to_string()));
    }

    #[test]
    fn syncsafe_integer_decodes_correctly() {
        // 0x00 0x00 0x02 0x01 → (0 << 21) | (0 << 14) | (2 << 7) | 1 = 257
        assert_eq!(syncsafe_to_u32(&[0x00, 0x00, 0x02, 0x01]), 257);
    }
}