Skip to main content

rusty_mp3/
lib.rs

1//! `rusty_mp3` — an in-house, pure-Rust MP3 (MPEG-1/2/2.5 Audio Layer III)
2//! decoder **and** encoder. Zero dependencies, no C, no FFI.
3//!
4//! Why in-house: the robust Rust MP3 crate (Symphonia) is MPL-2.0, which trips
5//! a no-copyleft license gate; the permissive one (puremp3) is incomplete, and
6//! every existing Rust MP3 *encoder* option is an FFI binding to LAME. MP3's
7//! patents expired in 2017 and Layer III is exhaustively documented, so we
8//! built our own.
9//!
10//! ## Layout (the framework, built brick by brick)
11//!
12//! * shared: [`header`] (frame header), [`frame`] (side-info + types),
13//!   [`bitio`] (MSB-first bit I/O), [`tables`] (ISO constant tables)
14//! * [`decode`]: side-info → reservoir → Huffman → scalefactors → requantize →
15//!   stereo → antialias → IMDCT → synthesis filterbank → PCM
16//! * [`encode`]: analysis filterbank → MDCT → psychoacoustic model → two-loop
17//!   quantizer → Huffman → bitstream
18//!
19//! ## Stream API
20//!
21//! [`Mp3Decoder`] and [`Mp3Encoder`] are the packet-level entry points:
22//! push bytes/PCM in, pull frames/packets out. The pull calls follow FFmpeg's
23//! EAGAIN/EOF drain protocol via [`Error::Again`] / [`Error::Eof`] — see
24//! [`error`]. The frame-level engines ([`Mp3Decode`] / [`Mp3Encode`]) are also
25//! public for callers that do their own framing.
26#![allow(dead_code)] // A few stage helpers are wired for lab/diagnostic use only.
27
28use std::collections::VecDeque;
29
30pub mod bitio;
31pub mod decode;
32pub mod encode;
33pub mod error;
34pub mod frame;
35pub mod header;
36pub mod tables;
37
38/// MP3 encoder experiment harness — brick tracking, corpus, metrics, variant
39/// sweeps. Opt-in behind the `lab` feature.
40#[cfg(feature = "lab")]
41pub mod lab;
42
43/// Prometheus telemetry hooks — samplers for the psychoacoustic model's
44/// signal-independent curves (ATH, spreading, Bark), for offline formula
45/// discovery by the private Prometheus refinery. Opt-in behind the
46/// `prometheus-telemetry` feature; the production build is byte-identical
47/// without it.
48#[cfg(feature = "prometheus-telemetry")]
49pub mod prometheus_telemetry {
50    pub use crate::encode::psychoacoustic::prometheus::*;
51}
52
53pub use decode::Mp3Decode;
54pub use encode::Mp3Encode;
55pub use error::{Error, Result};
56use header::FrameHeader;
57
58/// One decoded chunk of PCM — the output of [`Mp3Decoder::next_frame`].
59/// `samples` is interleaved f32 in `[-1, 1]`, `samples.len()` =
60/// per-channel samples × `channels`.
61#[derive(Debug, Clone)]
62pub struct DecodedAudio {
63    pub sample_rate: u32,
64    pub channels: u16,
65    pub samples: Vec<f32>,
66}
67
68/// Stream-level MP3 decoder: [`push`](Mp3Decoder::push) raw bytes (packets may
69/// split/join frames — the decoder frame-syncs internally, skipping ID3 and
70/// garbage), then drain decoded PCM with [`next_frame`](Mp3Decoder::next_frame).
71#[derive(Default)]
72pub struct Mp3Decoder {
73    state: Mp3Decode,
74    /// Accumulated bytes awaiting frame-sync (packets may split/join frames).
75    buf: Vec<u8>,
76    queue: VecDeque<DecodedAudio>,
77    eof: bool,
78}
79
80impl Mp3Decoder {
81    pub fn new() -> Mp3Decoder {
82        Mp3Decoder::default()
83    }
84
85    /// Feed more compressed bytes; any whole frames they complete are decoded
86    /// and queued for [`next_frame`](Mp3Decoder::next_frame).
87    pub fn push(&mut self, bytes: &[u8]) {
88        self.buf.extend_from_slice(bytes);
89        self.parse_frames();
90    }
91
92    /// Pull the next decoded frame. `Err(Again)` = feed more input;
93    /// `Err(Eof)` = flushed and fully drained.
94    pub fn next_frame(&mut self) -> Result<DecodedAudio> {
95        if let Some(frame) = self.queue.pop_front() {
96            return Ok(frame);
97        }
98        if self.eof {
99            Err(Error::Eof)
100        } else {
101            Err(Error::Again)
102        }
103    }
104
105    /// Signal end of input: once the queue drains, [`next_frame`](Mp3Decoder::next_frame)
106    /// returns `Err(Eof)` instead of `Err(Again)`.
107    pub fn flush(&mut self) {
108        self.eof = true;
109    }
110
111    /// Frame-sync the buffer: for each complete frame, split header / side-info /
112    /// main-data, decode it, and queue a [`DecodedAudio`]. Leaves a trailing partial
113    /// frame in `buf` for the next push.
114    fn parse_frames(&mut self) {
115        let mut pos = 0;
116        while pos + 4 <= self.buf.len() {
117            // Sync = 11 set bits: 0xFF then top 3 bits of the next byte.
118            if self.buf[pos] != 0xFF || self.buf[pos + 1] & 0xE0 != 0xE0 {
119                pos += 1;
120                continue;
121            }
122            let hb = [
123                self.buf[pos],
124                self.buf[pos + 1],
125                self.buf[pos + 2],
126                self.buf[pos + 3],
127            ];
128            let header = match FrameHeader::parse(hb) {
129                Ok(h) => h,
130                Err(_) => {
131                    pos += 1;
132                    continue;
133                }
134            };
135            let frame_size = header.frame_size();
136            if frame_size < 4 {
137                pos += 1;
138                continue;
139            }
140            if pos + frame_size > self.buf.len() {
141                break; // incomplete frame — wait for more data
142            }
143
144            let crc = if header.crc_protected { 2 } else { 0 };
145            let si_start = pos + 4 + crc;
146            let si_len = header.side_info_len();
147            let main_start = si_start + si_len;
148            if main_start > pos + frame_size {
149                pos += 1;
150                continue;
151            }
152            let side_info = self.buf[si_start..main_start].to_vec();
153            let main_data = self.buf[main_start..pos + frame_size].to_vec();
154
155            if let Ok(pcm) = self.state.decode_frame(&header, &side_info, &main_data) {
156                let channels = header.channel_mode.channels().max(1);
157                self.queue.push_back(DecodedAudio {
158                    sample_rate: header.sample_rate,
159                    channels: channels as u16,
160                    samples: pcm,
161                });
162            }
163            pos += frame_size;
164        }
165        self.buf.drain(0..pos);
166    }
167}
168
169/// Configuration for [`Mp3Encoder`].
170#[derive(Debug, Clone, Default)]
171pub struct Mp3EncoderConfig {
172    /// CBR target in kbps, snapped to the nearest valid Layer III value for the
173    /// MPEG version ([`snap_bitrate`]). `0` ⇒ default (128 for MPEG-1, 64 for
174    /// MPEG-2/2.5).
175    pub bitrate_kbps: u32,
176    /// VBR quality target (peak NMR). `Some` ⇒ VBR, `None` ⇒ CBR. To map an
177    /// ffmpeg/LAME-style `-q:a` 0–9 quality index, use [`vbr_quality_index`].
178    pub vbr_quality: Option<f32>,
179}
180
181/// Map an ffmpeg/LAME-style VBR quality index (`-q:a`, 0 = best … 9 = smallest)
182/// to the peak-NMR target [`Mp3EncoderConfig::vbr_quality`] expects.
183pub fn vbr_quality_index(q: f32) -> f32 {
184    10f32.powf((q.clamp(0.0, 9.0) * 2.0) / 10.0)
185}
186
187/// Stream-level MP3 encoder: accumulates per-channel PCM, emits one MP3 frame per
188/// 1152 samples per channel (576 for MPEG-2/2.5). MPEG-1/2/2.5, CBR or VBR,
189/// psychoacoustic noise shaping; mono, stereo, or per-frame mid/side joint
190/// stereo. Configured via [`Mp3EncoderConfig`].
191///
192/// Push PCM with [`push_pcm_f32`](Mp3Encoder::push_pcm_f32) /
193/// [`push_pcm_s16`](Mp3Encoder::push_pcm_s16) (the first push fixes the header
194/// from the sample rate + channel count), drain with
195/// [`next_packet`](Mp3Encoder::next_packet), and call
196/// [`finish`](Mp3Encoder::finish) at end of input (tail padding, reservoir
197/// assembly, Xing/Info header).
198#[derive(Default)]
199pub struct Mp3Encoder {
200    state: Mp3Encode,
201    header: Option<FrameHeader>,
202    /// Accumulated samples per channel awaiting a full frame.
203    pcm: [Vec<f32>; 2],
204    queue: VecDeque<Vec<u8>>,
205    /// Audio frames emitted and their total byte length (for the Info header).
206    total_frames: u32,
207    total_bytes: usize,
208    /// CBR target (kbps); 0 ⇒ default 128.
209    cbr_kbps: u32,
210    /// VBR quality target (peak NMR). `Some` ⇒ VBR, `None` ⇒ CBR.
211    quality: Option<f32>,
212    /// **3R1** — CBR reservoir-RD mode (buffer all frames, allocate bits across them by
213    /// perceptual entropy, assemble with the bit reservoir at flush). Gated by
214    /// `MP3_RESERVOIR`; `resv_gain` (`MP3_RESV_GAIN`, 0 ⇒ flat/byte-identical) is the knob.
215    reservoir: bool,
216    resv_gain: f32,
217    /// **3R1 lookahead** (opt-in, `MP3_RESV_LOOKAHEAD=1`): buffer all frame PCM and allocate
218    /// against the GLOBAL perceptual-entropy distribution (two-pass) instead of the default
219    /// causal running average. Off by default — on representative-length content the causal
220    /// EMA tracks the short-term (≤511 B) reservoir better; the buffer holds `(header, PCM)`.
221    resv_lookahead: bool,
222    resv_frames_pcm: Vec<(FrameHeader, Vec<Vec<f32>>)>,
223    eof: bool,
224}
225
226/// Snap a requested bitrate (kbps) to the nearest valid Layer III value for the
227/// MPEG version (the V1 and V2/2.5 bitrate tables differ).
228pub fn snap_bitrate(version: header::MpegVersion, kbps: u32) -> u32 {
229    let v1 = [
230        32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
231    ];
232    let v2 = [8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
233    let valid: &[u32] = if version == header::MpegVersion::V1 {
234        &v1
235    } else {
236        &v2
237    };
238    *valid
239        .iter()
240        .min_by_key(|&&b| b.abs_diff(kbps))
241        .unwrap_or(&128)
242}
243
244/// Build the frame header from the input's sample rate, channel count, and the
245/// configured CBR bitrate (VBR overrides the bitrate per frame later).
246pub fn encoder_header(sample_rate: u32, channels: u16, cbr_kbps: u32) -> Result<FrameHeader> {
247    let version = match sample_rate {
248        32000 | 44100 | 48000 => header::MpegVersion::V1,
249        16000 | 22050 | 24000 => header::MpegVersion::V2,
250        8000 | 11025 | 12000 => header::MpegVersion::V2_5,
251        _ => {
252            return Err(Error::unsupported(
253                "mp3 encode: unsupported sample rate (need 8–48 kHz MPEG-1/2/2.5)",
254            ))
255        }
256    };
257    let channel_mode = if channels >= 2 {
258        frame::ChannelMode::Stereo
259    } else {
260        frame::ChannelMode::Mono
261    };
262    // V2/2.5 default to a lower nominal bitrate to fit the smaller frame.
263    let default_kbps = if version == header::MpegVersion::V1 {
264        128
265    } else {
266        64
267    };
268    let bitrate_kbps = snap_bitrate(
269        version,
270        if cbr_kbps == 0 {
271            default_kbps
272        } else {
273            cbr_kbps
274        },
275    );
276    Ok(FrameHeader {
277        version,
278        crc_protected: false,
279        bitrate_kbps,
280        sample_rate,
281        padding: false,
282        channel_mode,
283        copyright: false,
284        original: true,
285        emphasis: 0,
286    })
287}
288
289impl Mp3Encoder {
290    pub fn new(config: Mp3EncoderConfig) -> Mp3Encoder {
291        Mp3Encoder {
292            cbr_kbps: config.bitrate_kbps,
293            quality: config.vbr_quality,
294            ..Mp3Encoder::default()
295        }
296    }
297
298    /// Push interleaved f32 PCM in `[-1, 1]`. The first push fixes the frame
299    /// header (MPEG version from `sample_rate`, mono/stereo from `channels`);
300    /// if a later push carries fewer channels than the header, the last input
301    /// channel is replicated. `interleaved.len()` should be a multiple of
302    /// `channels` (a trailing partial sample is ignored).
303    pub fn push_pcm_f32(
304        &mut self,
305        interleaved: &[f32],
306        channels: u16,
307        sample_rate: u32,
308    ) -> Result<()> {
309        if self.header.is_none() {
310            self.header = Some(encoder_header(sample_rate, channels, self.cbr_kbps)?);
311            // 3R1 reservoir RD — DEFAULT ON for CBR ≤ 256 kbps (2026-07-08). Measured
312            // vs LAME on real music (guitar/piano, PEAQ): the bit reservoir closes the
313            // whole quality gap — e.g. guitar@128k −0.59→+0.04 (LAME +0.06), piano@128k
314            // −0.97→−0.13; +0.5–0.85 ODG across 96–256k, at parity with LAME. (It was
315            // gated OFF and mis-judged "does nothing" by prom_qual001 — but that verdict
316            // was measured on the SILENT encoder, before the s16-format fix.)
317            // ⚠ 320 kbps EXCLUDED: the reservoir assembly produces valid-but-garbled
318            // frames at the top bitrate (guitar@320k −0.85→−3.31) and the reservoir
319            // barely helps there anyway (little cross-frame headroom to redistribute).
320            // Known follow-up bug. `MP3_RESERVOIR=0` forces it off for A/B.
321            // ⚠ MPEG-1 (V1) ONLY: the assembler hardcodes the 9-bit `main_data_begin`
322            // (MAX_BEGIN=511); MPEG-2/2.5 use an 8-bit field (max 255) + 1 granule/frame,
323            // so the reservoir would corrupt LSF streams. V2/2.5 keep the fixed path.
324            let is_v1 = self.header.as_ref().unwrap().version == crate::header::MpegVersion::V1;
325            self.reservoir = self.quality.is_none()
326                && is_v1
327                && match std::env::var("MP3_RESERVOIR") {
328                    Ok(v) => v != "0",             // explicit override (still V1/CBR only)
329                    Err(_) => self.cbr_kbps <= 256, // default: on for common bitrates
330                };
331            // gain 0.2 = the swept corpus optimum (mean ODG +0.029 vs flat, no clip
332            // regressed); MP3_RESV_GAIN overrides (0 ⇒ flat, for the neutrality check).
333            self.resv_gain = std::env::var("MP3_RESV_GAIN")
334                .ok()
335                .and_then(|s| s.parse().ok())
336                .unwrap_or(0.2);
337            // CAUSAL by default; lookahead is opt-in (MP3_RESV_LOOKAHEAD=1). On a
338            // representative 24 s corpus the causal lagging-EMA BEAT the global-mean
339            // lookahead on the dynamic clip (vocal@128 −0.156 vs −0.205) — the 511 B
340            // bank is a short-term smoother, so a LOCAL demand signal fits it better;
341            // and causal is streaming-friendly (no full-file PCM buffer). See tune-quality.
342            self.resv_lookahead = std::env::var("MP3_RESV_LOOKAHEAD").is_ok_and(|v| v != "0");
343        }
344        let nch = self.header.as_ref().unwrap().channel_mode.channels();
345        let in_ch = (channels as usize).max(1);
346        // Deinterleave to per-channel f32; if the input has fewer channels than
347        // output, replicate.
348        let samples = interleaved.len() / in_ch;
349        for s in 0..samples {
350            for c in 0..nch {
351                let ic = c.min(in_ch - 1);
352                self.pcm[c].push(interleaved[s * in_ch + ic]);
353            }
354        }
355        self.drain_frames();
356        Ok(())
357    }
358
359    /// Push interleaved signed-16-bit PCM. Converts `i16 / 32768.0` — the same
360    /// convention the decoder uses — then follows
361    /// [`push_pcm_f32`](Mp3Encoder::push_pcm_f32).
362    pub fn push_pcm_s16(
363        &mut self,
364        interleaved: &[i16],
365        channels: u16,
366        sample_rate: u32,
367    ) -> Result<()> {
368        let f32s: Vec<f32> = interleaved.iter().map(|&s| s as f32 / 32768.0).collect();
369        self.push_pcm_f32(&f32s, channels, sample_rate)
370    }
371
372    /// Pull the next encoded MP3 packet (one frame, or the prepended Xing/Info
373    /// frame after [`finish`](Mp3Encoder::finish)). `Err(Again)` = feed more
374    /// PCM; `Err(Eof)` = finished and fully drained.
375    pub fn next_packet(&mut self) -> Result<Vec<u8>> {
376        if let Some(p) = self.queue.pop_front() {
377            return Ok(p);
378        }
379        if self.eof {
380            Err(Error::Eof)
381        } else {
382            Err(Error::Again)
383        }
384    }
385
386    /// End of input: pad the PCM tail to a whole frame, assemble the bit
387    /// reservoir stream (CBR V1), and prepend the Xing/Info header. After this,
388    /// [`next_packet`](Mp3Encoder::next_packet) drains to `Err(Eof)`.
389    pub fn finish(&mut self) {
390        // Pad each channel's tail to a whole frame and encode it.
391        if let Some(header) = self.header.clone() {
392            let nch = header.channel_mode.channels();
393            let spf = header.version.samples_per_frame();
394            if (0..nch).any(|c| !self.pcm[c].is_empty()) {
395                for c in 0..nch {
396                    let padded = self.pcm[c].len().div_ceil(spf) * spf;
397                    self.pcm[c].resize(padded, 0.0);
398                }
399                self.drain_frames();
400            }
401            // 3R1: all frames are banked — assemble the reservoir stream now and split
402            // it back into fixed-size frame packets (B8 output is frame_size-aligned).
403            if self.reservoir {
404                let fsize = header.frame_size();
405                let stream = if self.resv_lookahead {
406                    let frames = std::mem::take(&mut self.resv_frames_pcm);
407                    self.state
408                        .encode_reservoir_lookahead(&frames, self.resv_gain)
409                } else {
410                    self.state.finish_reservoir()
411                };
412                for chunk in stream.chunks(fsize) {
413                    self.queue.push_back(chunk.to_vec());
414                }
415            }
416            // Prepend the Xing/Info header now that the totals are known (counts
417            // include the Info frame itself). Streaming consumers that drain before
418            // finish won't get it first — that case wants two-pass.
419            if self.total_frames > 0 {
420                let fsize = header.frame_size() as u32;
421                let info = encode::bitstream::info_frame(
422                    &header,
423                    self.total_frames + 1,
424                    self.total_bytes as u32 + fsize,
425                    self.quality.is_some(),
426                );
427                self.queue.push_front(info);
428            }
429        }
430        self.eof = true;
431    }
432
433    /// Emit a frame for each full 1152-sample-per-channel block accumulated.
434    fn drain_frames(&mut self) {
435        let Some(header) = self.header.clone() else {
436            return;
437        };
438        let nch = header.channel_mode.channels();
439        let spf = header.version.samples_per_frame();
440        // Consume full frames via an advancing OFFSET, then remove the whole
441        // consumed prefix ONCE at the end. Front-draining `spf` at a time shifts
442        // the entire remaining tail every frame — O(n²) when a demuxer hands us
443        // the whole file in one `send_frame` (the WAV path yields the `data`
444        // chunk as a single packet). The offset walk is O(n). Byte-identical:
445        // the same `[off..off+spf]` samples in the same order.
446        let mut off = 0usize;
447        while self.pcm[0].len() - off >= spf && (nch == 1 || self.pcm[1].len() - off >= spf) {
448            let block: Vec<Vec<f32>> = (0..nch)
449                .map(|c| self.pcm[c][off..off + spf].to_vec())
450                .collect();
451            if self.reservoir && self.resv_lookahead {
452                // Lookahead: buffer PCM; analyse-all + allocate + assemble at flush.
453                self.resv_frames_pcm.push((header.clone(), block));
454                self.total_frames += 1;
455                self.total_bytes += header.frame_size();
456            } else if self.reservoir {
457                // Causal: encode now, bank the raw frame; assemble (B8) at flush.
458                self.state
459                    .encode_frame_reservoir(&header, &block, self.resv_gain);
460                self.total_frames += 1;
461                self.total_bytes += header.frame_size();
462            } else if let Ok(bytes) = self.state.encode_frame(&header, &block, self.quality) {
463                self.total_frames += 1;
464                self.total_bytes += bytes.len();
465                self.queue.push_back(bytes);
466            }
467            off += spf;
468        }
469        // Drop all consumed samples in one shift (O(n) total, not O(n²)); the
470        // sub-frame remainder stays at the front for the next call / flush pad.
471        if off > 0 {
472            for c in 0..nch {
473                self.pcm[c].drain(0..off);
474            }
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn encode_mono(input: &[f32], sample_rate: u32) -> Vec<u8> {
484        let mut enc = Mp3Encoder::default();
485        enc.push_pcm_f32(input, 1, sample_rate).unwrap();
486        enc.finish();
487        let mut mp3 = Vec::new();
488        while let Ok(p) = enc.next_packet() {
489            mp3.extend_from_slice(&p);
490        }
491        mp3
492    }
493
494    /// Encode interleaved **S16** mono via the native s16 entry point.
495    fn encode_mono_s16(input: &[i16], sample_rate: u32) -> Vec<u8> {
496        let mut enc = Mp3Encoder::default();
497        enc.push_pcm_s16(input, 1, sample_rate).unwrap();
498        enc.finish();
499        let mut mp3 = Vec::new();
500        while let Ok(p) = enc.next_packet() {
501            mp3.extend_from_slice(&p);
502        }
503        mp3
504    }
505
506    /// The native S16 path (`push_pcm_s16`) must produce audible output — the
507    /// stream-level counterpart of the adapter's `s16_input_encodes_to_audible_output`
508    /// regression (which guards the rff `af.format` byte-reinterpretation path).
509    #[test]
510    fn s16_push_encodes_to_audible_output() {
511        let sr = 44100u32;
512        let n = sr as usize; // 1 s
513        let s16: Vec<i16> = (0..n)
514            .map(|i| {
515                let t = i as f32 / sr as f32;
516                (0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 32767.0) as i16
517            })
518            .collect();
519        let out = decode_mono(encode_mono_s16(&s16, sr));
520        let rms = (out.iter().map(|x| x * x).sum::<f32>() / out.len().max(1) as f32).sqrt();
521        assert!(
522            rms > 0.1,
523            "S16 input produced near-silent output (rms={rms})"
524        );
525    }
526
527    /// Decode profiling driver (run explicitly): encode ~10 s of dense audio, then
528    /// decode it while the per-stage profiler runs. `cargo test -p rusty_mp3
529    /// --release profile_decode -- --ignored --nocapture`.
530    #[test]
531    #[ignore]
532    fn profile_decode() {
533        let sr = 44100u32;
534        let n = 10 * sr as usize;
535        let mut s = 0x9E37_79B9u32;
536        let mut rng = || {
537            s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
538            (s >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0
539        };
540        // Dense harmonic + noise → busy spectrum, realistic decode work per frame.
541        let input: Vec<f32> = (0..n)
542            .map(|i| {
543                let t = i as f32 / sr as f32;
544                let mut v = 0f32;
545                for k in 1..=24 {
546                    v += (1.0 / k as f32)
547                        * (2.0 * std::f32::consts::PI * 130.0 * k as f32 * t).sin();
548                }
549                (0.2 * v + 0.03 * rng()).clamp(-1.0, 1.0)
550            })
551            .collect();
552        let mp3 = encode_mono(&input, sr);
553        eprintln!("[profile] decoding {} KB of mp3 (~10 s)", mp3.len() / 1024);
554        let out = decode_mono(mp3);
555        eprintln!("[profile] {} PCM samples out", out.len());
556        crate::decode::prof::dump();
557    }
558
559    fn decode_mono(mp3: Vec<u8>) -> Vec<f32> {
560        let mut dec = Mp3Decoder::default();
561        dec.push(&mp3);
562        dec.flush();
563        let mut out = Vec::new();
564        while let Ok(af) = dec.next_frame() {
565            out.extend_from_slice(&af.samples);
566        }
567        out
568    }
569
570    /// Best-aligned reconstruction SNR (dB) of `out` vs `reference`, searching the
571    /// codec delay and skipping warm-up at both ends.
572    fn best_snr(reference: &[f32], out: &[f32]) -> f64 {
573        let (mut best, skip) = (f64::NEG_INFINITY, 2304usize);
574        for delay in 0..3000 {
575            let mut sig = 0f64;
576            let mut err = 0f64;
577            let mut n = 0;
578            let mut i = skip;
579            while i + delay < out.len() && i < reference.len() {
580                let r = reference[i] as f64;
581                sig += r * r;
582                err += (r - out[i + delay] as f64).powi(2);
583                n += 1;
584                i += 1;
585            }
586            if n > 5000 && err > 0.0 {
587                best = best.max(10.0 * (sig / err).log10());
588            }
589        }
590        best
591    }
592
593    /// **R1 — stereo.** Two different tones in L and R survive independently.
594    #[test]
595    fn encode_decode_stereo() {
596        let sr = 44100u32;
597        let frames = 16;
598        let n = frames * 1152;
599        let pi2 = 2.0 * std::f32::consts::PI;
600        // Interleaved L/R: L = 700 Hz, R = 3000 Hz.
601        let mut interleaved = Vec::with_capacity(n * 2);
602        for i in 0..n {
603            let t = i as f32 / sr as f32;
604            interleaved.push(0.35 * (pi2 * 700.0 * t).sin()); // L
605            interleaved.push(0.30 * (pi2 * 3000.0 * t).sin()); // R
606        }
607
608        let mut enc = Mp3Encoder::default();
609        enc.push_pcm_f32(&interleaved, 2, sr).unwrap();
610        enc.finish();
611        let mut mp3 = Vec::new();
612        while let Ok(p) = enc.next_packet() {
613            mp3.extend_from_slice(&p);
614        }
615        assert!(!mp3.is_empty());
616        if let Ok(path) = std::env::var("MP3_ENC_OUT") {
617            std::fs::write(path, &mp3).expect("write MP3_ENC_OUT");
618        }
619
620        // Decode and split the interleaved stereo output back into L and R.
621        let mut dec = Mp3Decoder::default();
622        dec.push(&mp3);
623        dec.flush();
624        let (mut left, mut right) = (Vec::new(), Vec::new());
625        while let Ok(af) = dec.next_frame() {
626            assert_eq!(af.channels, 2, "decoded stream must be stereo");
627            for fr in af.samples.chunks_exact(2) {
628                left.push(fr[0]);
629                right.push(fr[1]);
630            }
631        }
632
633        let ref_l: Vec<f32> = (0..n)
634            .map(|i| 0.35 * (pi2 * 700.0 * i as f32 / sr as f32).sin())
635            .collect();
636        let ref_r: Vec<f32> = (0..n)
637            .map(|i| 0.30 * (pi2 * 3000.0 * i as f32 / sr as f32).sin())
638            .collect();
639        let snr_l = best_snr(&ref_l, &left);
640        let snr_r = best_snr(&ref_r, &right);
641        eprintln!("[R1] stereo SNR L {snr_l:.1} dB  R {snr_r:.1} dB");
642        assert!(
643            snr_l > 20.0 && snr_r > 20.0,
644            "stereo channels too noisy: L {snr_l} R {snr_r}"
645        );
646    }
647
648    /// **R5 — MPEG-2 / 2.5 (LSF).** Each low sample rate encodes as MPEG-2 (≥16 kHz)
649    /// or MPEG-2.5 (<16 kHz) — 1 granule/frame, V2 bitrate + side-info — and
650    /// round-trips through our decoder. FFmpeg validates the band tables out of band
651    /// (`MP3_ENC_DIR`); all six rates decode to >69 dB there.
652    #[test]
653    fn encode_decode_mpeg2() {
654        let dump_dir = std::env::var("MP3_ENC_DIR").ok();
655        for &sr in &[22050u32, 24000, 16000, 12000, 11025, 8000] {
656            let n = 24 * 576; // MPEG-2: 576 samples/frame
657            let input: Vec<f32> = (0..n)
658                .map(|i| 0.4 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin())
659                .collect();
660
661            let mp3 = encode_mono(&input, sr);
662            assert!(!mp3.is_empty());
663            if let Some(dir) = &dump_dir {
664                std::fs::write(format!("{dir}/v2_{sr}.mp3"), &mp3).expect("dump");
665            }
666            let h = header::FrameHeader::parse([mp3[0], mp3[1], mp3[2], mp3[3]]).unwrap();
667            assert_ne!(
668                h.version,
669                header::MpegVersion::V1,
670                "{sr}: must be MPEG-2/2.5"
671            );
672            assert_eq!(h.sample_rate, sr);
673
674            let out = decode_mono(mp3);
675            assert!(out.len() > n / 2);
676            let snr = best_snr(&input, &out);
677            eprintln!("[R5] MPEG-2 {sr} Hz round-trip SNR {snr:.1} dB");
678            assert!(
679                snr > 30.0,
680                "{sr}: MPEG-2 round-trip SNR too low: {snr:.1} dB"
681            );
682        }
683    }
684
685    /// **Q5 — block switching.** A castanet-like transient (silence then a sharp
686    /// burst, repeating) drives the encoder into short blocks; the stream must
687    /// carry window-switched frames and still reconstruct.
688    #[test]
689    fn block_switching_on_transients() {
690        let sr = 44100u32;
691        let frames = 20;
692        let n = frames * 1152;
693        let mut s = 0xBEEF_1234u32;
694        // Repeating impulse-train: a sharp burst every ~1500 samples on silence.
695        let input: Vec<f32> = (0..n)
696            .map(|i| {
697                if i % 1500 < 80 {
698                    s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
699                    0.7 * ((s >> 8) as f32 / (1u32 << 24) as f32 - 0.5)
700                } else {
701                    0.0
702                }
703            })
704            .collect();
705
706        let mp3 = encode_mono(&input, sr);
707        assert!(!mp3.is_empty());
708        if let Ok(path) = std::env::var("MP3_ENC_OUT") {
709            std::fs::write(path, &mp3).expect("write MP3_ENC_OUT");
710        }
711
712        // Count window-switched frames: in the side info, the first granule's
713        // `window_switching` bit. For MPEG-1 mono it's bit 8 of the per-granule
714        // fields — simplest to detect by decoding and checking the stream carries
715        // short blocks via the side-info parser.
716        let si_len = header::FrameHeader::parse([mp3[0], mp3[1], mp3[2], mp3[3]])
717            .map(|h| h.side_info_len())
718            .unwrap_or(17);
719        let mut switched = 0;
720        let mut pos = 0;
721        while pos + 4 <= mp3.len() {
722            if mp3[pos] == 0xFF && mp3[pos + 1] & 0xE0 == 0xE0 {
723                if let Ok(h) =
724                    header::FrameHeader::parse([mp3[pos], mp3[pos + 1], mp3[pos + 2], mp3[pos + 3]])
725                {
726                    let si = &mp3[pos + 4..pos + 4 + si_len];
727                    if let Ok(parsed) = decode::sideinfo::parse(&h, si) {
728                        if (0..h.version.granules())
729                            .any(|gr| parsed.granules[gr][0].window_switching)
730                        {
731                            switched += 1;
732                        }
733                    }
734                    pos += h.frame_size();
735                    continue;
736                }
737            }
738            pos += 1;
739        }
740        eprintln!("[Q5] window-switched frames: {switched}/{frames}");
741        assert!(
742            switched >= 3,
743            "transients must trigger block switching, got {switched}"
744        );
745
746        // And the switched stream still decodes frame-for-frame.
747        let mut dec = Mp3Decoder::default();
748        dec.push(&mp3);
749        dec.flush();
750        let mut decoded = 0;
751        while let Ok(_) = dec.next_frame() {
752            decoded += 1;
753        }
754        assert!(decoded >= frames, "all frames must decode, got {decoded}");
755    }
756
757    /// **R4 — conformance corpus.** A broad set of signals each round-trips through
758    /// encode → our decoder above a per-signal floor. With `MP3_ENC_DIR` set, the
759    /// `.mp3`s are dumped for the out-of-band FFmpeg/LAME cross-check.
760    #[test]
761    fn conformance_corpus_round_trips() {
762        let sr = 44100u32;
763        let frames = 14;
764        let n = frames * 1152;
765        let pi2 = std::f32::consts::TAU;
766        let tone = |f: f32| -> Vec<f32> {
767            (0..n)
768                .map(|i| 0.4 * (pi2 * f * i as f32 / sr as f32).sin())
769                .collect()
770        };
771        let noise = |seed: u32, amp: f32| -> Vec<f32> {
772            let mut s = seed;
773            (0..n)
774                .map(|_| {
775                    s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
776                    amp * ((s >> 8) as f32 / (1u32 << 24) as f32 - 0.5)
777                })
778                .collect()
779        };
780        let sweep: Vec<f32> = {
781            let mut ph = 0f32;
782            (0..n)
783                .map(|i| {
784                    let f = 80.0 + (16000.0 - 80.0) * (i as f32 / n as f32);
785                    ph += pi2 * f / sr as f32;
786                    0.4 * ph.sin()
787                })
788                .collect()
789        };
790        // (name, mono signal, SNR floor dB) — tones reconstruct cleanly, noise
791        // is lossy at 128k so the floor is lower.
792        let corpus: Vec<(&str, Vec<f32>, f64)> = vec![
793            ("tone-250", tone(250.0), 40.0),
794            ("tone-1k", tone(1000.0), 50.0),
795            ("tone-4k", tone(4000.0), 45.0),
796            ("tone-12k", tone(12000.0), 30.0),
797            ("sweep", sweep, 15.0),
798            ("noise", noise(0xC0FFEE, 0.4), 5.0),
799            ("quiet-noise", noise(0x1234, 0.02), 3.0),
800            (
801                "two-tone",
802                {
803                    let a = tone(600.0);
804                    let b = tone(5200.0);
805                    a.iter().zip(&b).map(|(x, y)| 0.5 * (x + y)).collect()
806                },
807                25.0,
808            ),
809        ];
810
811        let dump_dir = std::env::var("MP3_ENC_DIR").ok();
812        for (name, sig, floor) in &corpus {
813            let mp3 = encode_mono(sig, sr);
814            assert!(!mp3.is_empty(), "{name}: no output");
815            if let Some(dir) = &dump_dir {
816                std::fs::write(format!("{dir}/{name}.mp3"), &mp3).expect("dump");
817            }
818            let out = decode_mono(mp3);
819            assert!(out.len() > n / 2, "{name}: too few samples");
820            let snr = best_snr(sig, &out);
821            eprintln!("[R4] {name:<12} SNR {snr:.1} dB (floor {floor})");
822            assert!(snr >= *floor, "{name}: SNR {snr:.1} below floor {floor}");
823        }
824    }
825
826    /// **R2 — VBR.** Quiet-then-loud content makes the per-frame bitrate vary, and
827    /// the stream still decodes (in our decoder and FFmpeg).
828    #[test]
829    fn vbr_varies_bitrate_and_round_trips() {
830        let sr = 44100u32;
831        let pi2 = 2.0 * std::f32::consts::PI;
832        // 10 quiet simple frames, then 10 loud broadband-noise frames.
833        let mut input = Vec::new();
834        let mut s = 0x1234_5678u32;
835        for f in 0..20 {
836            for i in 0..1152 {
837                let t = (f * 1152 + i) as f32 / sr as f32;
838                if f < 10 {
839                    input.push(0.05 * (pi2 * 500.0 * t).sin()); // quiet tone → few bits
840                } else {
841                    s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
842                    input.push(0.6 * ((s >> 8) as f32 / (1u32 << 24) as f32 - 0.5));
843                    // loud noise
844                }
845            }
846        }
847
848        // Quality index 3 (ffmpeg `-q:a 3`) → VBR via the peak-NMR mapping.
849        let mut enc = Mp3Encoder::new(Mp3EncoderConfig {
850            bitrate_kbps: 0,
851            vbr_quality: Some(vbr_quality_index(3.0)),
852        });
853        enc.push_pcm_f32(&input, 1, sr).unwrap();
854        enc.finish();
855        let mut mp3 = Vec::new();
856        while let Ok(p) = enc.next_packet() {
857            mp3.extend_from_slice(&p);
858        }
859        if let Ok(path) = std::env::var("MP3_ENC_OUT") {
860            std::fs::write(path, &mp3).expect("write MP3_ENC_OUT");
861        }
862
863        // Walk the frames and collect their coded bitrates — VBR must use ≥2.
864        let mut bitrates = std::collections::BTreeSet::new();
865        let mut pos = 0;
866        while pos + 4 <= mp3.len() {
867            if mp3[pos] == 0xFF && mp3[pos + 1] & 0xE0 == 0xE0 {
868                if let Ok(h) =
869                    header::FrameHeader::parse([mp3[pos], mp3[pos + 1], mp3[pos + 2], mp3[pos + 3]])
870                {
871                    bitrates.insert(h.bitrate_kbps);
872                    pos += h.frame_size();
873                    continue;
874                }
875            }
876            pos += 1;
877        }
878        eprintln!("[R2] VBR bitrates used: {bitrates:?}");
879        assert!(
880            bitrates.len() >= 2,
881            "VBR must vary the bitrate, got {bitrates:?}"
882        );
883
884        // And it still decodes frame-for-frame.
885        let mut dec = Mp3Decoder::default();
886        dec.push(&mp3);
887        dec.flush();
888        let mut frames = 0;
889        while let Ok(_) = dec.next_frame() {
890            frames += 1;
891        }
892        assert!(frames >= 20, "expected all frames to decode, got {frames}");
893    }
894
895    /// **R1+ — mid/side joint stereo.** Correlated channels (L ≈ R, slightly
896    /// panned) trigger M/S; the stream must still reconstruct L and R.
897    #[test]
898    fn encode_decode_joint_stereo() {
899        let sr = 44100u32;
900        let n = 16 * 1152;
901        let pi2 = 2.0 * std::f32::consts::PI;
902        // Near-mono content with a small inter-channel difference → M/S wins.
903        let mut interleaved = Vec::with_capacity(n * 2);
904        for i in 0..n {
905            let t = i as f32 / sr as f32;
906            let base = 0.4 * (pi2 * 900.0 * t).sin();
907            interleaved.push(base); // L
908            interleaved.push(base * 0.95 + 0.02 * (pi2 * 1500.0 * t).sin()); // R ≈ L
909        }
910
911        let mut enc = Mp3Encoder::default();
912        enc.push_pcm_f32(&interleaved, 2, sr).unwrap();
913        enc.finish();
914        let mut mp3 = Vec::new();
915        while let Ok(p) = enc.next_packet() {
916            mp3.extend_from_slice(&p);
917        }
918        if let Ok(path) = std::env::var("MP3_ENC_OUT") {
919            std::fs::write(path, &mp3).expect("write MP3_ENC_OUT");
920        }
921
922        // At least one frame must have chosen joint stereo (header mode 0b01).
923        let joint = mp3
924            .windows(2)
925            .step_by(1)
926            .any(|w| w[0] == 0xFF && (w[1] & 0xE0) == 0xE0 && (w[1] & 0x06) == 0x02);
927        assert!(joint, "no joint-stereo frame emitted");
928
929        let mut dec = Mp3Decoder::default();
930        dec.push(&mp3);
931        dec.flush();
932        let (mut left, mut right) = (Vec::new(), Vec::new());
933        while let Ok(af) = dec.next_frame() {
934            for fr in af.samples.chunks_exact(2) {
935                left.push(fr[0]);
936                right.push(fr[1]);
937            }
938        }
939        let ref_l: Vec<f32> = (0..n)
940            .map(|i| 0.4 * (pi2 * 900.0 * i as f32 / sr as f32).sin())
941            .collect();
942        let ref_r: Vec<f32> = (0..n)
943            .map(|i| {
944                let t = i as f32 / sr as f32;
945                0.4 * (pi2 * 900.0 * t).sin() * 0.95 + 0.02 * (pi2 * 1500.0 * t).sin()
946            })
947            .collect();
948        let snr_l = best_snr(&ref_l, &left);
949        let snr_r = best_snr(&ref_r, &right);
950        eprintln!("[R1+] joint-stereo SNR L {snr_l:.1} dB  R {snr_r:.1} dB");
951        assert!(
952            snr_l > 20.0 && snr_r > 20.0,
953            "joint stereo too noisy: L {snr_l} R {snr_r}"
954        );
955    }
956
957    /// **C4 — the pipeline gate.** A multi-tone signal (which exercises Q6's
958    /// non-flat scalefactors) round-trips PCM → encoder → decoder → PCM well above
959    /// the noise floor, and the `.mp3` decodes in FFmpeg (checked out-of-band; see
960    /// docs/mp3-encoder-plan.md).
961    #[test]
962    fn encode_decode_pipeline_multitone() {
963        let sr = 44100u32;
964        let n = 16 * 1152;
965        let input: Vec<f32> = (0..n)
966            .map(|i| {
967                let t = i as f32 / sr as f32;
968                let pi2 = 2.0 * std::f32::consts::PI;
969                0.3 * (pi2 * 600.0 * t).sin()
970                    + 0.2 * (pi2 * 2300.0 * t).sin()
971                    + 0.12 * (pi2 * 9000.0 * t).sin()
972            })
973            .collect();
974
975        let mp3 = encode_mono(&input, sr);
976        assert!(!mp3.is_empty(), "encoder produced no data");
977        assert_eq!(mp3[0], 0xFF);
978        assert_eq!(mp3[1] & 0xE0, 0xE0);
979        if let Ok(path) = std::env::var("MP3_ENC_OUT") {
980            std::fs::write(path, &mp3).expect("write MP3_ENC_OUT");
981        }
982
983        let out = decode_mono(mp3);
984        assert!(out.len() > n / 2, "decoder produced too few samples");
985
986        let snr = best_snr(&input, &out);
987        eprintln!("[C4] encode→decode multitone SNR {snr:.1} dB");
988        assert!(snr > 20.0, "round-trip SNR too low: {snr:.1} dB");
989    }
990
991    /// Decode a real MP3 file (path in `MP3_REF`) and report structure. Validates
992    /// frame-sync (skips ID3), header/side-info parsing, and main-data extraction
993    /// on real data. Output is silent until D[]/codebooks are laid; this checks
994    /// the *structure* (frame count, sample count, no panics, finite samples).
995    #[test]
996    fn decode_real_mp3_structure() {
997        let Ok(path) = std::env::var("MP3_REF") else {
998            return; // self-skip when not running the reference harness
999        };
1000        let data = std::fs::read(&path).expect("read MP3_REF");
1001        let mut dec = Mp3Decoder::default();
1002        dec.push(&data);
1003        dec.flush();
1004
1005        let mut frames = 0usize;
1006        let mut samples = 0usize;
1007        let mut pcm: Vec<u8> = Vec::new();
1008        while let Ok(af) = dec.next_frame() {
1009            assert_eq!(af.sample_rate, 44100);
1010            assert_eq!(af.channels, 1);
1011            for s in &af.samples {
1012                pcm.extend_from_slice(&s.to_le_bytes());
1013            }
1014            frames += 1;
1015            samples += af.samples.len() / af.channels as usize;
1016        }
1017        eprintln!("[MP3] decoded frames={frames} samples={samples}");
1018        assert!(frames > 0, "must decode at least one frame from real data");
1019        if let Ok(out) = std::env::var("MP3_OUT") {
1020            std::fs::write(out, &pcm).expect("write MP3_OUT");
1021        }
1022    }
1023
1024    /// Encode a WAV file (`WAV_IN`, f32le or s16le mono) → mp3 at `ENC_OUT`, at `BR` kbps.
1025    /// Honors `MP3_RESERVOIR`/`MP3_RESV_GAIN`. Lets the quality gate run without the CLI.
1026    #[test]
1027    fn encode_wav_env() {
1028        let Ok(inp) = std::env::var("WAV_IN") else {
1029            return;
1030        };
1031        let d = std::fs::read(&inp).expect("read WAV_IN");
1032        // minimal WAV parse: fmt (rate, bits, format) + data chunk
1033        let fmt = d.windows(4).position(|w| w == b"fmt ").unwrap() + 8;
1034        let audio_fmt = u16::from_le_bytes([d[fmt], d[fmt + 1]]);
1035        let rate = u32::from_le_bytes([d[fmt + 4], d[fmt + 5], d[fmt + 6], d[fmt + 7]]);
1036        let bits = u16::from_le_bytes([d[fmt + 14], d[fmt + 15]]);
1037        let data = d.windows(4).position(|w| w == b"data").unwrap() + 8;
1038        let pcm: Vec<f32> = if audio_fmt == 3 || bits == 32 {
1039            d[data..]
1040                .chunks_exact(4)
1041                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1042                .collect()
1043        } else {
1044            d[data..]
1045                .chunks_exact(2)
1046                .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
1047                .collect()
1048        };
1049        // Channel count from the fmt chunk (offset +2..+4); pcm is interleaved as read.
1050        let nch = u16::from_le_bytes([d[fmt + 2], d[fmt + 3]]).max(1);
1051        let mut enc = Mp3Encoder::new(Mp3EncoderConfig {
1052            bitrate_kbps: std::env::var("BR")
1053                .ok()
1054                .and_then(|s| s.parse().ok())
1055                .unwrap_or(128),
1056            vbr_quality: None,
1057        });
1058        enc.push_pcm_f32(&pcm, nch, rate).unwrap();
1059        enc.finish();
1060        let mut mp3 = Vec::new();
1061        while let Ok(p) = enc.next_packet() {
1062            mp3.extend_from_slice(&p);
1063        }
1064        std::fs::write(std::env::var("ENC_OUT").unwrap(), mp3).unwrap();
1065    }
1066
1067    /// Encode a synthetic tone+noise signal at `ENC_RATE` Hz → mp3 at `ENC_OUT`.
1068    /// Used to check our V1/V2/V2.5 encoder output is valid (decodable by a neutral ref).
1069    #[test]
1070    fn encode_at_rate_to_file() {
1071        let Some(rate) = std::env::var("ENC_RATE")
1072            .ok()
1073            .and_then(|s| s.parse::<u32>().ok())
1074        else {
1075            return;
1076        };
1077        let n = rate as usize * 2;
1078        let mut pcm = vec![0f32; n];
1079        let mut s = 0x1234_5u32;
1080        for (i, v) in pcm.iter_mut().enumerate() {
1081            s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1082            let noise = (s >> 8) as f32 / (1u32 << 24) as f32 - 0.5;
1083            let t = i as f32 / rate as f32;
1084            *v = 0.3 * (2.0 * std::f32::consts::PI * 440.0 * t).sin() + 0.05 * noise;
1085        }
1086        let mp3 = encode_mono(&pcm, rate);
1087        std::fs::write(std::env::var("ENC_OUT").unwrap(), mp3).unwrap();
1088    }
1089
1090    /// Rate-agnostic decode of `MP3_REF2` → f32le PCM at `MP3_OUT2` (any rate/channels).
1091    /// Used to verify LSF/MPEG-2.5 decode via the full pipeline without the CLI.
1092    #[test]
1093    fn decode_any_mp3_to_pcm() {
1094        let Ok(path) = std::env::var("MP3_REF2") else {
1095            return;
1096        };
1097        let data = std::fs::read(&path).expect("read MP3_REF2");
1098        let mut dec = Mp3Decoder::default();
1099        dec.push(&data);
1100        dec.flush();
1101        let mut pcm: Vec<u8> = Vec::new();
1102        while let Ok(af) = dec.next_frame() {
1103            for s in &af.samples {
1104                pcm.extend_from_slice(&s.to_le_bytes());
1105            }
1106        }
1107        assert!(!pcm.is_empty());
1108        if let Ok(out) = std::env::var("MP3_OUT2") {
1109            std::fs::write(out, &pcm).expect("write MP3_OUT2");
1110        }
1111    }
1112
1113    #[test]
1114    fn header_parse_roundtrip() {
1115        // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo, no CRC, no padding.
1116        let bytes = [0xFF, 0xFB, 0x90, 0x00];
1117        let h = header::FrameHeader::parse(bytes).unwrap();
1118        assert_eq!(h.version, header::MpegVersion::V1);
1119        assert_eq!(h.bitrate_kbps, 128);
1120        assert_eq!(h.sample_rate, 44100);
1121        assert_eq!(h.channel_mode, frame::ChannelMode::Stereo);
1122        assert!(!h.crc_protected && !h.padding);
1123        assert_eq!(h.frame_size(), 417);
1124        assert_eq!(h.to_bytes(), bytes, "header must round-trip bit-exactly");
1125    }
1126
1127    #[test]
1128    fn header_rejects_non_layer3_and_bad_sync() {
1129        // Layer II (0b10 in the layer field): byte1 = 1111_1101.
1130        assert!(header::FrameHeader::parse([0xFF, 0xFD, 0x90, 0x00]).is_err());
1131        // Broken sync.
1132        assert!(header::FrameHeader::parse([0x00, 0x00, 0x00, 0x00]).is_err());
1133    }
1134
1135    fn hdr(version: header::MpegVersion, mode: frame::ChannelMode) -> header::FrameHeader {
1136        header::FrameHeader {
1137            version,
1138            crc_protected: false,
1139            bitrate_kbps: 128,
1140            sample_rate: 44100,
1141            padding: false,
1142            channel_mode: mode,
1143            copyright: false,
1144            original: true,
1145            emphasis: 0,
1146        }
1147    }
1148
1149    #[test]
1150    fn sideinfo_bit_accounting_all_layouts() {
1151        use frame::ChannelMode::{Mono, Stereo};
1152        use header::MpegVersion::{V1, V2};
1153        // (version, channel mode, expected side-info length in bytes)
1154        for (v, m, len) in [
1155            (V1, Stereo, 32),
1156            (V1, Mono, 17),
1157            (V2, Stereo, 17),
1158            (V2, Mono, 9),
1159        ] {
1160            let h = hdr(v, m);
1161            assert_eq!(h.side_info_len(), len);
1162            // An all-zero block parses cleanly; parse()'s debug_assert verifies
1163            // the field widths sum to exactly len*8 bits for this layout.
1164            let si = decode::sideinfo::parse(&h, &vec![0u8; len]).unwrap();
1165            assert_eq!(si.main_data_begin, 0);
1166        }
1167    }
1168
1169    #[test]
1170    fn frame_size_matches_spec_example() {
1171        // MPEG-1 L3, 128 kbps, 44100 Hz, no padding → 417 bytes.
1172        let h = header::FrameHeader {
1173            version: header::MpegVersion::V1,
1174            crc_protected: false,
1175            bitrate_kbps: 128,
1176            sample_rate: 44100,
1177            padding: false,
1178            channel_mode: frame::ChannelMode::Stereo,
1179            copyright: false,
1180            original: true,
1181            emphasis: 0,
1182        };
1183        assert_eq!(h.frame_size(), 417);
1184        assert_eq!(h.side_info_len(), 32);
1185    }
1186}