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}
133
134impl Mp3Encoder {
135    fn inner(&mut self) -> &mut rusty_mp3::Mp3Encoder {
136        if self.inner.is_none() {
137            self.inner = Some(rusty_mp3::Mp3Encoder::new(rusty_mp3::Mp3EncoderConfig {
138                bitrate_kbps: self.cbr_kbps,
139                vbr_quality: self.quality,
140            }));
141        }
142        // Cannot fail: the branch above assigned `Some` when it was `None`.
143        self.inner.as_mut().unwrap()
144    }
145}
146
147impl Encoder for Mp3Encoder {
148    fn configure(&mut self, options: &Dictionary) -> Result<()> {
149        if let Some(b) = options.get("b").and_then(parse_bitrate) {
150            self.cbr_kbps = b;
151        }
152        // VBR quality via -q:a / -qp:a / -crf:a (0 = best … 9 = smallest). Its
153        // presence switches to VBR; map the index to a peak-NMR target.
154        for key in ["q", "qp", "crf", "qscale"] {
155            if let Some(q) = options.get(key).and_then(|v| v.trim().parse::<f32>().ok()) {
156                self.quality = Some(rusty_mp3::vbr_quality_index(q));
157                break;
158            }
159        }
160        Ok(())
161    }
162
163    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
164        let Frame::Audio(af) = frame else {
165            return Ok(());
166        };
167        let in_ch = af.channels.max(1);
168        // The PCM/WAV demuxer path delivers S16 (not F32), so we MUST honor
169        // `af.format` here — reading s16 bytes with an f32 stride yields
170        // denormal garbage (a silent encode). The unit tests only ever fed F32,
171        // which is why this once slipped; `s16_input_encodes_to_audible_output`
172        // below guards it.
173        let bps = match af.format {
174            SampleFormat::F32 => 4,
175            SampleFormat::S16 => 2,
176            other => {
177                return Err(Error::unsupported(format!(
178                    "mp3 encode: sample format `{}` (need interleaved s16/f32)",
179                    other.name()
180                )))
181            }
182        };
183        // The plane goes to the encoder AS BYTES: it converts and deinterleaves
184        // in one pass into its own frame buffer. This used to decode the whole
185        // plane into a `Vec<f32>` / `Vec<i16>` first (and the s16 entry point
186        // then made a second, `f32`, copy) -- on a WAV, which arrives as ONE
187        // frame, that was two or three file-sized temporaries.
188        //
189        // `af.samples` is a CLAIM made by whoever built the frame; the plane is
190        // the authority. Only the whole sample frames the plane actually holds
191        // are encoded. (A short plane used to be padded with silence up to the
192        // claim, so one frame with an inflated `samples` could force an
193        // arbitrarily large allocation -- and an empty `planes` panicked.)
194        let Some(data) = af.planes.first() else {
195            return Err(Error::invalid(
196                "mp3 encode: audio frame has no sample plane",
197            ));
198        };
199        let frame_bytes = usize::from(in_ch) * bps;
200        let frames = af.samples.min(data.len() / frame_bytes);
201        let plane = &data[..frames * frame_bytes];
202        let sr = af.sample_rate;
203        let inner = self.inner();
204        match bps {
205            4 => inner.push_pcm_f32le(plane, in_ch, sr),
206            _ => inner.push_pcm_s16le(plane, in_ch, sr),
207        }
208        .map_err(map_err)
209    }
210
211    fn receive_packet(&mut self) -> Result<Packet> {
212        // The inner encoder owns the packet queue (including the Xing/Info
213        // frame push-fronted at finish) — pull straight through, mapping the
214        // Again/Eof drain protocol variant for variant.
215        match self.inner.as_mut() {
216            Some(inner) => inner
217                .next_packet()
218                .map(|bytes| Packet::from_data(0, bytes))
219                .map_err(map_err),
220            None if self.eof => Err(Error::Eof),
221            None => Err(Error::Again),
222        }
223    }
224
225    fn flush(&mut self) {
226        if let Some(inner) = self.inner.as_mut() {
227            inner.finish();
228        }
229        self.eof = true;
230    }
231}
232
233#[cfg(test)]
234#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] // test signal synthesis
235mod tests {
236    use super::*;
237
238    /// Encode interleaved **S16** mono — the sample format the WAV/PCM demuxer
239    /// actually delivers (the `rusty_mp3` native tests otherwise feed f32 slices).
240    fn encode_mono_s16(input: &[i16], sample_rate: u32) -> Vec<u8> {
241        let bytes: Vec<u8> = input.iter().flat_map(|s| s.to_le_bytes()).collect();
242        let mut enc = Mp3Encoder::default();
243        enc.send_frame(&Frame::Audio(AudioFrame {
244            sample_rate,
245            channels: 1,
246            format: SampleFormat::S16,
247            planes: vec![bytes],
248            samples: input.len(),
249            pts: None,
250        }))
251        .unwrap();
252        enc.flush();
253        let mut mp3 = Vec::new();
254        while let Ok(p) = enc.receive_packet() {
255            mp3.extend_from_slice(&p.data);
256        }
257        mp3
258    }
259
260    fn decode_mono(mp3: Vec<u8>) -> Vec<f32> {
261        let mut dec = Mp3Decoder::default();
262        dec.send_packet(&Packet::from_data(0, mp3)).unwrap();
263        dec.flush();
264        let mut out = Vec::new();
265        while let Ok(Frame::Audio(af)) = dec.receive_frame() {
266            for c in af.planes[0].chunks_exact(4) {
267                out.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
268            }
269        }
270        out
271    }
272
273    /// REGRESSION (silent-encode bug): `send_frame` must honor `af.format`. The
274    /// WAV/PCM demuxer delivers S16; reading those bytes with an F32 (4-byte)
275    /// stride over 2-byte samples yields denormal garbage → a full-size but
276    /// SILENT MP3 that every decoder reproduces as silence. Feed S16, require
277    /// audible output. (Brick tests all fed F32, so this class of bug slipped.)
278    #[test]
279    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
280    fn s16_input_encodes_to_audible_output() {
281        let sr = 44100u32;
282        let n = sr as usize; // 1 s
283        let s16: Vec<i16> = (0..n)
284            .map(|i| {
285                let t = i as f32 / sr as f32;
286                (0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 32767.0) as i16
287            })
288            .collect();
289        let out = decode_mono(encode_mono_s16(&s16, sr));
290        let rms = (out.iter().map(|x| x * x).sum::<f32>() / out.len().max(1) as f32).sqrt();
291        assert!(
292            rms > 0.1,
293            "S16 input produced near-silent output (rms={rms}); encoder ignored af.format"
294        );
295    }
296
297    /// A frame's `samples` is a claim: an empty `planes` must be an error, not a
298    /// panic, and an inflated count must neither allocate to it nor encode more
299    /// than the plane holds (it used to pad with silence up to the claim).
300    #[test]
301    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
302    fn hostile_frame_shapes_are_errors_or_bounded() {
303        let mut enc = Mp3Encoder::default();
304        let empty = Frame::Audio(AudioFrame {
305            sample_rate: 44_100,
306            channels: 2,
307            format: SampleFormat::S16,
308            planes: vec![],
309            samples: 1152,
310            pts: None,
311        });
312        assert!(enc.send_frame(&empty).is_err());
313
314        let claim = |samples: usize| {
315            let mut enc = Mp3Encoder::default();
316            enc.send_frame(&Frame::Audio(AudioFrame {
317                sample_rate: 44_100,
318                channels: 1,
319                format: SampleFormat::S16,
320                planes: vec![vec![0x11; 2 * 4608]],
321                samples,
322                pts: None,
323            }))
324            .unwrap();
325            enc.flush();
326            let mut n = 0;
327            while let Ok(p) = enc.receive_packet() {
328                n += p.data.len();
329            }
330            n
331        };
332        // A claim of 2^40 samples over a 4608-sample plane encodes exactly what
333        // the honest claim does.
334        assert_eq!(claim(1 << 40), claim(4608));
335    }
336
337    /// Property: random frame shapes -- every sample format, 0..=8 channels,
338    /// valid and invalid rates, planes shorter or longer than claimed -- never
339    /// panic, whatever `send_frame` returns.
340    #[test]
341    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
342    fn random_frame_shapes_never_panic() {
343        let mut s = 0x9E37_79B9_7F4A_7C15u64;
344        let mut rnd = move || {
345            s ^= s << 13;
346            s ^= s >> 7;
347            s ^= s << 17;
348            s
349        };
350        let formats = [
351            SampleFormat::S16,
352            SampleFormat::F32,
353            SampleFormat::F32Planar,
354        ];
355        let rates = [44_100u32, 22_050, 8_000, 48_000, 0, 7, 96_000];
356        for _ in 0..300 {
357            let mut enc = Mp3Encoder::default();
358            let plane: Vec<u8> = (0..(rnd() % 20_000)).map(|_| rnd() as u8).collect();
359            let frame = Frame::Audio(AudioFrame {
360                sample_rate: rates[(rnd() % 7) as usize],
361                channels: (rnd() % 9) as u16,
362                format: formats[(rnd() % 3) as usize],
363                planes: if rnd() % 10 == 0 { vec![] } else { vec![plane] },
364                samples: if rnd() % 3 == 0 {
365                    (rnd() % (1 << 40)) as usize
366                } else {
367                    (rnd() % 6000) as usize
368                },
369                pts: None,
370            });
371            let _ = enc.send_frame(&frame);
372            enc.flush();
373            while enc.receive_packet().is_ok() {}
374        }
375    }
376
377    /// Option strings are user input: known forms parse, and hostile ones
378    /// saturate instead of panicking (the documented float-to-int behaviour).
379    #[test]
380    fn bitrate_options_parse_or_saturate() {
381        assert_eq!(parse_bitrate("128k"), Some(128));
382        assert_eq!(parse_bitrate("192000"), Some(192));
383        assert_eq!(parse_bitrate("0.32M"), Some(320));
384        assert_eq!(parse_bitrate("-5k"), Some(0));
385        assert_eq!(parse_bitrate("nank"), Some(0));
386        assert_eq!(parse_bitrate("1e30k"), Some(u32::MAX));
387        for s in ["", "k", "M", "abc", "  12 K ", "99999999999999999999", "∞k"] {
388            let _ = parse_bitrate(s);
389        }
390    }
391
392    /// Differential: the adapter's decoded plane is exactly `rusty_mp3`'s PCM
393    /// as little-endian f32 bytes -- on a real stream and on mutated copies.
394    #[test]
395    #[cfg_attr(miri, ignore = "runs real encodes: too slow to interpret under Miri")]
396    fn adapter_decode_matches_rusty_mp3() {
397        let sr = 44_100u32;
398        let s16: Vec<i16> = (0..sr as usize)
399            .map(|i| ((i as f32 * 0.07).sin() * 9000.0) as i16)
400            .collect();
401        let base = encode_mono_s16(&s16, sr);
402        let mut seed = 0x2545_F491_4F6C_DD1Du64;
403        for case in 0..20 {
404            let mut mp3 = base.clone();
405            for _ in 0..case {
406                seed ^= seed << 13;
407                seed ^= seed >> 7;
408                seed ^= seed << 17;
409                let i = (seed % mp3.len() as u64) as usize;
410                mp3[i] ^= 1 << (seed % 8);
411            }
412            let ours = decode_mono(mp3.clone());
413            let mut reference = rusty_mp3::Mp3Decoder::new();
414            reference.push(&mp3);
415            reference.flush();
416            let mut want = Vec::new();
417            while let Ok(f) = reference.next_frame() {
418                want.extend_from_slice(&f.samples);
419            }
420            assert_eq!(ours.len(), want.len(), "case {case}");
421            assert!(
422                ours.iter()
423                    .zip(&want)
424                    .all(|(a, b)| a.to_bits() == b.to_bits()),
425                "case {case}"
426            );
427        }
428    }
429
430    #[test]
431    fn registers_as_audio_codec() {
432        let mut reg = CodecRegistry::new();
433        register(&mut reg);
434        let codec = reg.by_name("mp3").expect("mp3 registered");
435        assert_eq!(codec.id, CodecId::Mp3);
436        assert_eq!(codec.media_type, MediaType::Audio);
437        assert!(codec.can_decode() && codec.can_encode());
438    }
439}