Skip to main content

rff_codec_mp3/
lib.rs

1//! MP3 (MPEG-1/2/2.5 Audio Layer III) codec, backed by the pure-Rust
2//! [`rusty_mp3`] decoder + encoder (formerly in-tree here, now a standalone
3//! publishable crate — the same adapter pattern as `rff-codec-h264` over
4//! `rusty_h264`).
5//!
6//! This crate is the thin rff-facing layer: `register()` wires the codec into
7//! the registry, the wrapper structs implement the rff [`Decoder`]/[`Encoder`]
8//! traits by delegating to [`rusty_mp3::Mp3Decoder`]/[`rusty_mp3::Mp3Encoder`],
9//! and everything ffmpeg-CLI-shaped stays here: `Dictionary` option parsing
10//! (`-b:a`, `-q:a`), `AudioFrame` ↔ PCM conversion honoring `af.format`, and
11//! `rusty_mp3` → rff error mapping.
12
13#![forbid(unsafe_code)]
14
15use rff_codec::{Codec, CodecRegistry, Decoder, Encoder};
16use rff_core::{
17    AudioFrame, CodecId, Dictionary, Error, Frame, MediaType, Packet, Result, SampleFormat,
18};
19
20/// The underlying pure-Rust MP3 engine, re-exported for direct use.
21pub use rusty_mp3;
22
23/// Re-export the Prometheus telemetry hooks.
24///
25/// This crate FORWARDS the `prometheus-telemetry` feature to `rusty_mp3`
26/// (see Cargo.toml) but stopped re-exporting the module when mp3 was
27/// extracted into a standalone crate — so `rff_codec_mp3::prometheus_telemetry`
28/// vanished while the feature that implies it kept working. Prometheus's
29/// harvester imports exactly that path and no longer compiles.
30///
31/// Forwarding a feature without re-exporting what the feature provides is a
32/// silent break: the manifest still says yes and the module is gone.
33#[cfg(feature = "prometheus-telemetry")]
34pub use rusty_mp3::prometheus_telemetry;
35
36/// Register the MP3 codec (decoder + encoder) into a [`CodecRegistry`].
37pub fn register(registry: &mut CodecRegistry) {
38    registry.register(Codec {
39        id: CodecId::Mp3,
40        name: "mp3",
41        long_name: "MP3 (MPEG-1/2 Audio Layer III)",
42        media_type: MediaType::Audio,
43        decoder: Some(|| Box::new(Mp3Decoder::default())),
44        encoder: Some(|| Box::new(Mp3Encoder::default())),
45    });
46}
47
48/// Map a [`rusty_mp3::Error`] onto the rff error space, variant for variant
49/// (the drain-protocol variants `Again`/`Eof` must survive the mapping — the
50/// pipeline drives codecs by them).
51fn map_err(e: rusty_mp3::Error) -> Error {
52    match e {
53        rusty_mp3::Error::Unimplemented(what) => Error::Unimplemented(what),
54        rusty_mp3::Error::Eof => Error::Eof,
55        rusty_mp3::Error::Again => Error::Again,
56        rusty_mp3::Error::InvalidData(msg) => Error::InvalidData(msg),
57        rusty_mp3::Error::Unsupported(msg) => Error::Unsupported(msg),
58        // `rusty_mp3::Error` is #[non_exhaustive]; anything future-added is a
59        // decode/encode failure from rff's point of view.
60        other => Error::InvalidData(format!("rusty_mp3: {other}")),
61    }
62}
63
64#[derive(Default)]
65struct Mp3Decoder {
66    inner: rusty_mp3::Mp3Decoder,
67}
68
69impl Decoder for Mp3Decoder {
70    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
71        self.inner.push(&packet.data);
72        Ok(())
73    }
74
75    fn receive_frame(&mut self) -> Result<Frame> {
76        let audio = self.inner.next_frame().map_err(map_err)?;
77        let channels = audio.channels.max(1);
78        // One sized pass into an exact-length plane. The per-sample
79        // `extend_from_slice` this replaces re-checked capacity on every
80        // sample, which kept the loop scalar; with the length fixed up front it
81        // is a plain lane-wise copy (on little-endian, byte-for-byte a memcpy).
82        let mut bytes = vec![0u8; audio.samples.len() * 4];
83        for (d, s) in bytes.chunks_exact_mut(4).zip(&audio.samples) {
84            d.copy_from_slice(&s.to_le_bytes());
85        }
86        Ok(Frame::Audio(AudioFrame {
87            sample_rate: audio.sample_rate,
88            channels,
89            format: SampleFormat::F32,
90            samples: audio.samples.len() / channels as usize,
91            planes: vec![bytes],
92            pts: None,
93        }))
94    }
95
96    fn flush(&mut self) {
97        self.inner.flush();
98    }
99}
100
101/// Parse an FFmpeg-style bitrate string ("128k", "192000") into kbps.
102///
103/// The option string is user input. Rust's float-to-int `as` SATURATES (NaN and
104/// negatives give 0, huge values `u32::MAX`), so no string can produce undefined
105/// behaviour, and 0 means "default" downstream; the encoder snaps every other
106/// value to the nearest legal bitrate.
107#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
108fn parse_bitrate(s: &str) -> Option<u32> {
109    let s = s.trim();
110    if let Some(n) = s.strip_suffix(['k', 'K']) {
111        n.trim().parse::<f32>().ok().map(|x| x as u32)
112    } else if let Some(n) = s.strip_suffix(['m', 'M']) {
113        n.trim().parse::<f32>().ok().map(|x| (x * 1000.0) as u32)
114    } else {
115        s.parse::<u32>()
116            .ok()
117            .map(|x| if x >= 1000 { x / 1000 } else { x })
118    }
119}
120
121/// MP3 encoder adapter. Options are collected in [`Encoder::configure`]; the
122/// inner [`rusty_mp3::Mp3Encoder`] is built lazily on the first audio frame
123/// (its header locks on the first PCM push).
124#[derive(Default)]
125struct Mp3Encoder {
126    inner: Option<rusty_mp3::Mp3Encoder>,
127    /// CBR target (kbps); 0 ⇒ default 128.
128    cbr_kbps: u32,
129    /// VBR quality target (peak NMR). `Some` ⇒ VBR, `None` ⇒ CBR.
130    quality: Option<f32>,
131    eof: bool,
132    /// A packet went out before `flush` -- so the leading Info/Xing tag frame
133    /// left as a placeholder, and the completed one follows the audio.
134    streamed: bool,
135    /// That trailing tag packet has been handed out.
136    tag_sent: bool,
137}
138
139impl Mp3Encoder {
140    fn inner(&mut self) -> &mut rusty_mp3::Mp3Encoder {
141        if self.inner.is_none() {
142            self.inner = Some(rusty_mp3::Mp3Encoder::new(rusty_mp3::Mp3EncoderConfig {
143                bitrate_kbps: self.cbr_kbps,
144                vbr_quality: self.quality,
145            }));
146        }
147        // Cannot fail: the branch above assigned `Some` when it was `None`.
148        self.inner.as_mut().unwrap()
149    }
150}
151
152impl Encoder for Mp3Encoder {
153    fn configure(&mut self, options: &Dictionary) -> Result<()> {
154        if let Some(b) = options.get("b").and_then(parse_bitrate) {
155            self.cbr_kbps = b;
156        }
157        // VBR quality via -q:a / -qp:a / -crf:a (0 = best … 9 = smallest). Its
158        // presence switches to VBR; map the index to a peak-NMR target.
159        for key in ["q", "qp", "crf", "qscale"] {
160            if let Some(q) = options.get(key).and_then(|v| v.trim().parse::<f32>().ok()) {
161                self.quality = Some(rusty_mp3::vbr_quality_index(q));
162                break;
163            }
164        }
165        Ok(())
166    }
167
168    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
169        let Frame::Audio(af) = frame else {
170            return Ok(());
171        };
172        let in_ch = af.channels.max(1);
173        // The PCM/WAV demuxer path delivers S16 (not F32), so we MUST honor
174        // `af.format` here — reading s16 bytes with an f32 stride yields
175        // denormal garbage (a silent encode). The unit tests only ever fed F32,
176        // which is why this once slipped; `s16_input_encodes_to_audible_output`
177        // below guards it.
178        let bps = match af.format {
179            SampleFormat::F32 => 4,
180            SampleFormat::S16 => 2,
181            other => {
182                return Err(Error::unsupported(format!(
183                    "mp3 encode: sample format `{}` (need interleaved s16/f32)",
184                    other.name()
185                )))
186            }
187        };
188        // The plane goes to the encoder AS BYTES: it converts and deinterleaves
189        // in one pass into its own frame buffer. This used to decode the whole
190        // plane into a `Vec<f32>` / `Vec<i16>` first (and the s16 entry point
191        // then made a second, `f32`, copy) -- on a WAV, which arrives as ONE
192        // frame, that was two or three file-sized temporaries.
193        //
194        // `af.samples` is a CLAIM made by whoever built the frame; the plane is
195        // the authority. Only the whole sample frames the plane actually holds
196        // are encoded. (A short plane used to be padded with silence up to the
197        // claim, so one frame with an inflated `samples` could force an
198        // arbitrarily large allocation -- and an empty `planes` panicked.)
199        let Some(data) = af.planes.first() else {
200            return Err(Error::invalid(
201                "mp3 encode: audio frame has no sample plane",
202            ));
203        };
204        let frame_bytes = usize::from(in_ch) * bps;
205        let frames = af.samples.min(data.len() / frame_bytes);
206        let plane = &data[..frames * frame_bytes];
207        let sr = af.sample_rate;
208        let inner = self.inner();
209        match bps {
210            4 => inner.push_pcm_f32le(plane, in_ch, sr),
211            _ => inner.push_pcm_s16le(plane, in_ch, sr),
212        }
213        .map_err(map_err)
214    }
215
216    fn receive_packet(&mut self) -> Result<Packet> {
217        // The inner encoder owns the packet queue -- its first packet is the
218        // Info/Xing tag frame -- so pull straight through, mapping the
219        // Again/Eof drain protocol variant for variant.
220        //
221        // If packets were pulled before `flush`, that first one left as a
222        // placeholder (its counts and end padding are only known at the end),
223        // so the COMPLETED tag frame follows the audio as one last packet. The
224        // mp3 muxer writes it back over the placeholder on a seekable output
225        // (and drops it on a pipe); the file is then gapless.
226        match self.inner.as_mut() {
227            Some(inner) => match inner.next_packet() {
228                Ok(bytes) => {
229                    self.streamed |= !self.eof;
230                    Ok(Packet::from_data(0, bytes))
231                }
232                Err(rusty_mp3::Error::Eof) if self.streamed && !self.tag_sent => {
233                    self.tag_sent = true;
234                    match inner.lame_tag_frame() {
235                        Some(tag) => Ok(Packet::from_data(0, tag.to_vec())),
236                        None => Err(Error::Eof),
237                    }
238                }
239                Err(e) => Err(map_err(e)),
240            },
241            None if self.eof => Err(Error::Eof),
242            None => Err(Error::Again),
243        }
244    }
245
246    fn flush(&mut self) {
247        if let Some(inner) = self.inner.as_mut() {
248            inner.finish();
249        }
250        self.eof = true;
251    }
252}
253
254#[cfg(test)]
255#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] // test signal synthesis
256mod tests {
257    use super::*;
258
259    /// Encode interleaved **S16** mono — the sample format the WAV/PCM demuxer
260    /// actually delivers (the `rusty_mp3` native tests otherwise feed f32 slices).
261    fn encode_mono_s16(input: &[i16], sample_rate: u32) -> Vec<u8> {
262        let bytes: Vec<u8> = input.iter().flat_map(|s| s.to_le_bytes()).collect();
263        let mut enc = Mp3Encoder::default();
264        enc.send_frame(&Frame::Audio(AudioFrame {
265            sample_rate,
266            channels: 1,
267            format: SampleFormat::S16,
268            planes: vec![bytes],
269            samples: input.len(),
270            pts: None,
271        }))
272        .unwrap();
273        enc.flush();
274        let mut mp3 = Vec::new();
275        while let Ok(p) = enc.receive_packet() {
276            mp3.extend_from_slice(&p.data);
277        }
278        mp3
279    }
280
281    fn decode_mono(mp3: Vec<u8>) -> Vec<f32> {
282        let mut dec = Mp3Decoder::default();
283        dec.send_packet(&Packet::from_data(0, mp3)).unwrap();
284        dec.flush();
285        let mut out = Vec::new();
286        while let Ok(Frame::Audio(af)) = dec.receive_frame() {
287            for c in af.planes[0].chunks_exact(4) {
288                out.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
289            }
290        }
291        out
292    }
293
294    /// REGRESSION (silent-encode bug): `send_frame` must honor `af.format`. The
295    /// WAV/PCM demuxer delivers S16; reading those bytes with an F32 (4-byte)
296    /// stride over 2-byte samples yields denormal garbage → a full-size but
297    /// SILENT MP3 that every decoder reproduces as silence. Feed S16, require
298    /// audible output. (Brick tests all fed F32, so this class of bug slipped.)
299    #[test]
300    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
301    fn s16_input_encodes_to_audible_output() {
302        let sr = 44100u32;
303        let n = sr as usize; // 1 s
304        let s16: Vec<i16> = (0..n)
305            .map(|i| {
306                let t = i as f32 / sr as f32;
307                (0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 32767.0) as i16
308            })
309            .collect();
310        let out = decode_mono(encode_mono_s16(&s16, sr));
311        let rms = (out.iter().map(|x| x * x).sum::<f32>() / out.len().max(1) as f32).sqrt();
312        assert!(
313            rms > 0.1,
314            "S16 input produced near-silent output (rms={rms}); encoder ignored af.format"
315        );
316    }
317
318    /// A frame's `samples` is a claim: an empty `planes` must be an error, not a
319    /// panic, and an inflated count must neither allocate to it nor encode more
320    /// than the plane holds (it used to pad with silence up to the claim).
321    #[test]
322    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
323    fn hostile_frame_shapes_are_errors_or_bounded() {
324        let mut enc = Mp3Encoder::default();
325        let empty = Frame::Audio(AudioFrame {
326            sample_rate: 44_100,
327            channels: 2,
328            format: SampleFormat::S16,
329            planes: vec![],
330            samples: 1152,
331            pts: None,
332        });
333        assert!(enc.send_frame(&empty).is_err());
334
335        let claim = |samples: usize| {
336            let mut enc = Mp3Encoder::default();
337            enc.send_frame(&Frame::Audio(AudioFrame {
338                sample_rate: 44_100,
339                channels: 1,
340                format: SampleFormat::S16,
341                planes: vec![vec![0x11; 2 * 4608]],
342                samples,
343                pts: None,
344            }))
345            .unwrap();
346            enc.flush();
347            let mut n = 0;
348            while let Ok(p) = enc.receive_packet() {
349                n += p.data.len();
350            }
351            n
352        };
353        // A claim of 2^40 samples over a 4608-sample plane encodes exactly what
354        // the honest claim does.
355        assert_eq!(claim(1 << 40), claim(4608));
356    }
357
358    /// Property: random frame shapes -- every sample format, 0..=8 channels,
359    /// valid and invalid rates, planes shorter or longer than claimed -- never
360    /// panic, whatever `send_frame` returns.
361    #[test]
362    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
363    fn random_frame_shapes_never_panic() {
364        let mut s = 0x9E37_79B9_7F4A_7C15u64;
365        let mut rnd = move || {
366            s ^= s << 13;
367            s ^= s >> 7;
368            s ^= s << 17;
369            s
370        };
371        let formats = [
372            SampleFormat::S16,
373            SampleFormat::F32,
374            SampleFormat::F32Planar,
375        ];
376        let rates = [44_100u32, 22_050, 8_000, 48_000, 0, 7, 96_000];
377        for _ in 0..300 {
378            let mut enc = Mp3Encoder::default();
379            let plane: Vec<u8> = (0..(rnd() % 20_000)).map(|_| rnd() as u8).collect();
380            let frame = Frame::Audio(AudioFrame {
381                sample_rate: rates[(rnd() % 7) as usize],
382                channels: (rnd() % 9) as u16,
383                format: formats[(rnd() % 3) as usize],
384                planes: if rnd() % 10 == 0 { vec![] } else { vec![plane] },
385                samples: if rnd() % 3 == 0 {
386                    (rnd() % (1 << 40)) as usize
387                } else {
388                    (rnd() % 6000) as usize
389                },
390                pts: None,
391            });
392            let _ = enc.send_frame(&frame);
393            enc.flush();
394            while enc.receive_packet().is_ok() {}
395        }
396    }
397
398    /// Option strings are user input: known forms parse, and hostile ones
399    /// saturate instead of panicking (the documented float-to-int behaviour).
400    #[test]
401    fn bitrate_options_parse_or_saturate() {
402        assert_eq!(parse_bitrate("128k"), Some(128));
403        assert_eq!(parse_bitrate("192000"), Some(192));
404        assert_eq!(parse_bitrate("0.32M"), Some(320));
405        assert_eq!(parse_bitrate("-5k"), Some(0));
406        assert_eq!(parse_bitrate("nank"), Some(0));
407        assert_eq!(parse_bitrate("1e30k"), Some(u32::MAX));
408        for s in ["", "k", "M", "abc", "  12 K ", "99999999999999999999", "∞k"] {
409            let _ = parse_bitrate(s);
410        }
411    }
412
413    /// Differential: the adapter's decoded plane is exactly `rusty_mp3`'s PCM
414    /// as little-endian f32 bytes -- on a real stream and on mutated copies.
415    #[test]
416    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
417    fn adapter_decode_matches_rusty_mp3() {
418        let sr = 44_100u32;
419        let s16: Vec<i16> = (0..sr as usize)
420            .map(|i| ((i as f32 * 0.07).sin() * 9000.0) as i16)
421            .collect();
422        let base = encode_mono_s16(&s16, sr);
423        let mut seed = 0x2545_F491_4F6C_DD1Du64;
424        for case in 0..20 {
425            let mut mp3 = base.clone();
426            for _ in 0..case {
427                seed ^= seed << 13;
428                seed ^= seed >> 7;
429                seed ^= seed << 17;
430                let i = (seed % mp3.len() as u64) as usize;
431                mp3[i] ^= 1 << (seed % 8);
432            }
433            let ours = decode_mono(mp3.clone());
434            let mut reference = rusty_mp3::Mp3Decoder::new();
435            reference.push(&mp3);
436            reference.flush();
437            let mut want = Vec::new();
438            while let Ok(f) = reference.next_frame() {
439                want.extend_from_slice(&f.samples);
440            }
441            assert_eq!(ours.len(), want.len(), "case {case}");
442            assert!(
443                ours.iter()
444                    .zip(&want)
445                    .all(|(a, b)| a.to_bits() == b.to_bits()),
446                "case {case}"
447            );
448        }
449    }
450
451    /// Pull packets the way the transcoder does -- after every `send_frame`,
452    /// then after `flush` -- and return them.
453    fn streamed_packets(options: &[(&str, &str)], frames: usize) -> Vec<Vec<u8>> {
454        let mut enc = Mp3Encoder::default();
455        let mut dict = Dictionary::new();
456        for (k, v) in options {
457            dict.set(*k, *v);
458        }
459        enc.configure(&dict).unwrap();
460        let s16: Vec<u8> = (0..1152 * 2)
461            .flat_map(|i| (((i as f32 * 0.05).sin() * 8000.0) as i16).to_le_bytes())
462            .collect();
463        let mut out = Vec::new();
464        for _ in 0..frames {
465            enc.send_frame(&Frame::Audio(AudioFrame {
466                sample_rate: 44_100,
467                channels: 1,
468                format: SampleFormat::S16,
469                planes: vec![s16.clone()],
470                samples: 1152 * 2,
471                pts: None,
472            }))
473            .unwrap();
474            while let Ok(p) = enc.receive_packet() {
475                out.push(p.data);
476            }
477        }
478        enc.flush();
479        while let Ok(p) = enc.receive_packet() {
480            out.push(p.data);
481        }
482        out
483    }
484
485    fn is_tag(frame: &[u8]) -> bool {
486        // MPEG-1 mono: header (4) + side info (17), then the tag.
487        frame.len() > 25 && matches!(&frame[21..25], b"Info" | b"Xing")
488    }
489
490    /// A stream whose packets left before `flush` (VBR emits frames as they
491    /// are encoded) ends with the COMPLETED tag frame, the length of the
492    /// placeholder it began with, for the mp3 muxer to write back over it. The
493    /// buffered CBR path emits nothing before `flush`, so its leading tag is
494    /// already complete and none follows.
495    #[test]
496    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
497    fn streamed_encode_ends_with_the_completed_tag() {
498        let vbr = streamed_packets(&[("q", "2")], 4);
499        let (first, last) = (&vbr[0], &vbr[vbr.len() - 1]);
500        assert!(is_tag(first) && is_tag(last));
501        assert_eq!(first.len(), last.len());
502        assert_ne!(first, last, "the placeholder's counts are filled in");
503        assert_eq!(vbr.iter().filter(|p| is_tag(p)).count(), 2);
504
505        let cbr = streamed_packets(&[("b", "128k")], 4);
506        assert!(is_tag(&cbr[0]));
507        assert_eq!(cbr.iter().filter(|p| is_tag(p)).count(), 1);
508    }
509
510    #[test]
511    fn registers_as_audio_codec() {
512        let mut reg = CodecRegistry::new();
513        register(&mut reg);
514        let codec = reg.by_name("mp3").expect("mp3 registered");
515        assert_eq!(codec.id, CodecId::Mp3);
516        assert_eq!(codec.media_type, MediaType::Audio);
517        assert!(codec.can_decode() && codec.can_encode());
518    }
519}