Skip to main content

rockbox_metadata/
lib.rs

1//! Audio file metadata / tag parsing extracted from
2//! [Rockbox](https://www.rockbox.org) as a reusable library.
3//!
4//! One call parses tags, duration, bitrate, sample rate, ReplayGain and
5//! embedded album-art / cuesheet positions for 40+ audio formats — MP3
6//! (ID3v1/v2), FLAC, Ogg Vorbis, Opus, Speex, MP4/M4A (AAC & ALAC),
7//! WavPack, Monkey's Audio, Musepack, WMA, AIFF, WAV/AU/VOX, TTA, ADX,
8//! SHN, and the chiptune family (SPC, NSF, SID, VGM, KSS, AY, GBS, HES,
9//! SGC, VTX, MOD, SAP, …).
10//!
11//! ```no_run
12//! let meta = rockbox_metadata::read("song.flac").unwrap();
13//! println!(
14//!     "{} — {} [{}] {:?}",
15//!     meta.artist, meta.title, meta.codec, meta.duration,
16//! );
17//! ```
18//!
19//! The parsers are the battle-tested Rockbox C sources
20//! (`lib/rbcodec/metadata/`), compiled by `build.rs` and bridged through a
21//! small flat struct (`shim/rbmeta.h`). Pairs with the
22//! [`rockbox-dsp`](https://crates.io/crates/rockbox-dsp) crate: the raw
23//! Q7.24 ReplayGain values from [`ReplayGain`] feed directly into its
24//! `set_replaygain_gains_raw()`.
25
26use std::ffi::{CStr, CString};
27use std::os::raw::{c_char, c_int, c_uint};
28use std::path::Path;
29use std::sync::Mutex;
30use std::time::Duration;
31
32const STR: usize = 512;
33const COMMENT: usize = 1024;
34const GENRE: usize = 256;
35const SMALL: usize = 64;
36const CODEC: usize = 32;
37
38/// Mirror of `struct rbmeta_tags` in `shim/rbmeta.h`.
39#[repr(C)]
40struct RawTags {
41    codec: [u8; CODEC],
42    title: [u8; STR],
43    artist: [u8; STR],
44    album: [u8; STR],
45    albumartist: [u8; STR],
46    composer: [u8; STR],
47    grouping: [u8; STR],
48    comment: [u8; COMMENT],
49    genre: [u8; GENRE],
50    year_string: [u8; SMALL],
51    track_string: [u8; SMALL],
52    disc_string: [u8; SMALL],
53    mb_track_id: [u8; SMALL],
54
55    codectype: i32,
56    tracknum: i32,
57    discnum: i32,
58    year: i32,
59    layer: i32,
60    id3version: i32,
61    vbr: i32,
62    bitrate: i32,
63
64    frequency: u32,
65    reserved_: u32,
66
67    filesize: u64,
68    length_ms: u64,
69    samples: u64,
70    frame_count: u64,
71    first_frame_offset: u64,
72
73    track_level: i64,
74    album_level: i64,
75    track_gain: i64,
76    album_gain: i64,
77    track_peak: i64,
78    album_peak: i64,
79
80    has_albumart: i32,
81    albumart_type: i32,
82    albumart_pos: i64,
83    albumart_size: i32,
84
85    has_cuesheet: i32,
86    cuesheet_pos: i64,
87    cuesheet_size: i32,
88    cuesheet_encoding: i32,
89}
90
91extern "C" {
92    fn rbmeta_read(path: *const c_char, out: *mut RawTags) -> c_int;
93    fn rbmeta_codec_label(codectype: c_uint) -> *const c_char;
94    fn probe_file_format(filename: *const c_char) -> c_uint;
95}
96
97/// Some Rockbox parsers keep static scratch state; serialize all calls.
98static META_LOCK: Mutex<()> = Mutex::new(());
99
100/// ReplayGain values parsed from the file's tags.
101///
102/// The `*_db` / `*_peak` fields are user-friendly conversions; the `raw_*`
103/// fields keep Rockbox's native fixed-point encoding — linear scale
104/// factors in Q7.24, exactly what `rockbox-dsp`'s
105/// `set_replaygain_gains_raw()` expects.
106#[derive(Debug, Clone, Copy, Default, PartialEq)]
107pub struct ReplayGain {
108    /// Track gain in dB (e.g. `-6.5`), if the tag is present.
109    pub track_gain_db: Option<f32>,
110    /// Album gain in dB, if the tag is present.
111    pub album_gain_db: Option<f32>,
112    /// Track peak amplitude, 1.0 = full scale.
113    pub track_peak: Option<f32>,
114    /// Album peak amplitude, 1.0 = full scale.
115    pub album_peak: Option<f32>,
116    /// Track gain as a linear Q7.24 scale factor (0 = tag absent).
117    pub raw_track_gain: i64,
118    /// Album gain as a linear Q7.24 scale factor (0 = tag absent).
119    pub raw_album_gain: i64,
120    /// Track peak in Q7.24 (0 = tag absent).
121    pub raw_track_peak: i64,
122    /// Album peak in Q7.24 (0 = tag absent).
123    pub raw_album_peak: i64,
124}
125
126impl ReplayGain {
127    /// True if no ReplayGain information was found at all.
128    pub fn is_empty(&self) -> bool {
129        self.raw_track_gain == 0
130            && self.raw_album_gain == 0
131            && self.raw_track_peak == 0
132            && self.raw_album_peak == 0
133    }
134}
135
136/// Image format of embedded album art.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum AlbumArtKind {
139    Bmp,
140    Png,
141    Jpeg,
142    Unknown,
143}
144
145/// Location of album art embedded in the audio file (ID3 APIC, FLAC
146/// PICTURE, MP4 `covr`). Read `size` bytes at byte `offset` to extract the
147/// image — unless one of the transform flags is set, in which case the
148/// data first needs ID3 de-unsynchronization or base64 decoding.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct AlbumArt {
151    pub kind: AlbumArtKind,
152    /// Byte offset of the image data within the file.
153    pub offset: u64,
154    /// Image data size in bytes.
155    pub size: u32,
156    /// Data is inside an unsynchronized ID3v2 frame (0xFF 0x00 stuffing
157    /// must be removed).
158    pub id3_unsync: bool,
159    /// Data is base64-encoded (Vorbis `METADATA_BLOCK_PICTURE`).
160    pub vorbis_base64: bool,
161}
162
163/// Character encoding of an embedded cuesheet.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum CueEncoding {
166    Latin1,
167    Utf8,
168    Utf16Le,
169    Utf16Be,
170    Unknown,
171}
172
173/// Location of a cuesheet embedded in the file's tags.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct Cuesheet {
176    pub offset: u64,
177    pub size: u32,
178    pub encoding: CueEncoding,
179}
180
181/// Parsed metadata for one audio file.
182#[derive(Debug, Clone, Default)]
183pub struct Metadata {
184    /// Codec label, e.g. `"MP3"`, `"FLAC"`, `"Ogg"`, `"AAC"`.
185    pub codec: String,
186    /// Rockbox `AFMT_*` codec index (stable across releases).
187    pub codec_id: u32,
188
189    /// Tag strings — empty when the tag is absent.
190    pub title: String,
191    pub artist: String,
192    pub album: String,
193    pub albumartist: String,
194    pub composer: String,
195    pub grouping: String,
196    pub comment: String,
197    pub genre: String,
198    /// Raw year / track / disc strings as found in the tags.
199    pub year_string: String,
200    pub track_string: String,
201    pub disc_string: String,
202    /// MusicBrainz track ID — empty when untagged.
203    pub mb_track_id: String,
204
205    pub track_number: Option<u32>,
206    pub disc_number: Option<u32>,
207    pub year: Option<u32>,
208
209    /// Track length.
210    pub duration: Duration,
211    /// Average bitrate in kbit/s.
212    pub bitrate: u32,
213    /// Sample rate in Hz.
214    pub sample_rate: u32,
215    /// Audio payload size in bytes (excluding tag headers).
216    pub filesize: u64,
217    /// Total PCM frames, when the container declares it (0 otherwise).
218    pub samples: u64,
219    /// Whether the stream is variable bitrate.
220    pub vbr: bool,
221    /// Byte offset of the first audio frame (after leading tags).
222    pub first_frame_offset: u64,
223
224    pub replaygain: ReplayGain,
225    pub album_art: Option<AlbumArt>,
226    pub cuesheet: Option<Cuesheet>,
227}
228
229/// Errors returned by [`read`].
230#[derive(Debug)]
231pub enum Error {
232    /// The file could not be opened.
233    Open(std::path::PathBuf),
234    /// No parser recognized the file, or parsing failed.
235    Parse(std::path::PathBuf),
236    /// The path contains an interior NUL byte.
237    InvalidPath,
238    /// Out of memory in the C bridge.
239    OutOfMemory,
240}
241
242impl std::fmt::Display for Error {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Error::Open(p) => write!(f, "cannot open {}", p.display()),
246            Error::Parse(p) => write!(f, "unrecognized or corrupt audio file {}", p.display()),
247            Error::InvalidPath => write!(f, "path contains a NUL byte"),
248            Error::OutOfMemory => write!(f, "out of memory"),
249        }
250    }
251}
252
253impl std::error::Error for Error {}
254
255fn cstr_field(bytes: &[u8]) -> String {
256    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
257    String::from_utf8_lossy(&bytes[..end]).into_owned()
258}
259
260/// Q19.12 dB value → f32 dB.
261fn q12_db(v: i64) -> f32 {
262    v as f32 / 4096.0
263}
264
265/// Q7.24 linear value → f32.
266fn q24(v: i64) -> f32 {
267    v as f32 / (1u32 << 24) as f32
268}
269
270impl Metadata {
271    fn from_raw(raw: &RawTags) -> Self {
272        let opt_u32 = |v: i32| if v > 0 { Some(v as u32) } else { None };
273
274        Metadata {
275            codec: cstr_field(&raw.codec),
276            codec_id: raw.codectype as u32,
277            title: cstr_field(&raw.title),
278            artist: cstr_field(&raw.artist),
279            album: cstr_field(&raw.album),
280            albumartist: cstr_field(&raw.albumartist),
281            composer: cstr_field(&raw.composer),
282            grouping: cstr_field(&raw.grouping),
283            comment: cstr_field(&raw.comment),
284            genre: cstr_field(&raw.genre),
285            year_string: cstr_field(&raw.year_string),
286            track_string: cstr_field(&raw.track_string),
287            disc_string: cstr_field(&raw.disc_string),
288            mb_track_id: cstr_field(&raw.mb_track_id),
289            track_number: opt_u32(raw.tracknum),
290            disc_number: opt_u32(raw.discnum),
291            year: opt_u32(raw.year),
292            duration: Duration::from_millis(raw.length_ms),
293            bitrate: raw.bitrate.max(0) as u32,
294            sample_rate: raw.frequency,
295            filesize: raw.filesize,
296            samples: raw.samples,
297            vbr: raw.vbr != 0,
298            first_frame_offset: raw.first_frame_offset,
299            replaygain: ReplayGain {
300                track_gain_db: (raw.track_gain != 0).then(|| q12_db(raw.track_level)),
301                album_gain_db: (raw.album_gain != 0).then(|| q12_db(raw.album_level)),
302                track_peak: (raw.track_peak != 0).then(|| q24(raw.track_peak)),
303                album_peak: (raw.album_peak != 0).then(|| q24(raw.album_peak)),
304                raw_track_gain: raw.track_gain,
305                raw_album_gain: raw.album_gain,
306                raw_track_peak: raw.track_peak,
307                raw_album_peak: raw.album_peak,
308            },
309            album_art: (raw.has_albumart != 0 && raw.albumart_size > 0).then(|| AlbumArt {
310                kind: match raw.albumart_type & 0xF {
311                    1 => AlbumArtKind::Bmp,
312                    2 => AlbumArtKind::Png,
313                    3 => AlbumArtKind::Jpeg,
314                    _ => AlbumArtKind::Unknown,
315                },
316                offset: raw.albumart_pos.max(0) as u64,
317                size: raw.albumart_size as u32,
318                id3_unsync: raw.albumart_type & (1 << 4) != 0,
319                vorbis_base64: raw.albumart_type & (1 << 5) != 0,
320            }),
321            cuesheet: (raw.has_cuesheet != 0 && raw.cuesheet_size > 0).then(|| Cuesheet {
322                offset: raw.cuesheet_pos.max(0) as u64,
323                size: raw.cuesheet_size as u32,
324                encoding: match raw.cuesheet_encoding {
325                    1 => CueEncoding::Latin1,
326                    2 => CueEncoding::Utf8,
327                    3 => CueEncoding::Utf16Le,
328                    4 => CueEncoding::Utf16Be,
329                    _ => CueEncoding::Unknown,
330                },
331            }),
332        }
333    }
334}
335
336/// Parse the metadata of the audio file at `path`.
337pub fn read<P: AsRef<Path>>(path: P) -> Result<Metadata, Error> {
338    let path = path.as_ref();
339    let c_path = CString::new(path.to_string_lossy().as_bytes()).map_err(|_| Error::InvalidPath)?;
340
341    // ~6 KB — heap-allocate and let the C side zero it.
342    let mut raw: Box<RawTags> = unsafe { Box::new(std::mem::zeroed()) };
343
344    let rc = {
345        let _guard = META_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346        unsafe { rbmeta_read(c_path.as_ptr(), &mut *raw) }
347    };
348
349    match rc {
350        0 => Ok(Metadata::from_raw(&raw)),
351        -1 => Err(Error::Open(path.to_path_buf())),
352        -3 => Err(Error::OutOfMemory),
353        _ => Err(Error::Parse(path.to_path_buf())),
354    }
355}
356
357/// Guess the codec from a filename's extension without opening the file.
358/// Returns the format label (e.g. `"FLAC"`) or `None` if unknown.
359pub fn probe(filename: &str) -> Option<String> {
360    let c = CString::new(filename).ok()?;
361    unsafe {
362        let afmt = probe_file_format(c.as_ptr());
363        let label = rbmeta_codec_label(afmt);
364        if afmt == 0 || label.is_null() {
365            return None;
366        }
367        Some(CStr::from_ptr(label).to_string_lossy().into_owned())
368    }
369}