Another dependency the app should not be asking the platform for:
- audio_decode — MP3 (layer 3, LSF tables, synthesis) and Ogg Vorbis
(codebooks, floor, residue, MDCT) decoders, with tag reading. Both are
checked against oracle fixtures rather than against our own expectations.
- audio_encode — an Ogg Vorbis encoder: MDCT, psychoacoustics, floor and
Huffman coding, setup tables, plus `oggenc` and `audiobench` binaries.
- audio_picture — waveform and spectrogram rendering, and compositing.
- audio_lyrics — word-level lyric alignment (DTW plus a DP snap) and the
baked schema behind karaoke timing.
- audio_sidechannels — the side-channel plumbing between them.
libs/voice grows a CUDA backend and an alignment path beside its CPU decoder,
with a `whisper_parity` binary to keep the two honest.
875 lines
33 KiB
Rust
875 lines
33 KiB
Rust
//! MP3: streams synthesised bit by bit inside the test, a no-panic sweep over
|
|
//! mangled copies of them, and — behind an environment variable — a full
|
|
//! comparison against CoreAudio's decoder.
|
|
//!
|
|
//! No audio file is committed for MP3. Everything the fast tests need is built
|
|
//! here from the standard's own field layout, which has the useful property
|
|
//! that the *expected* output is known exactly rather than measured: a granule
|
|
//! carrying a single quantised spectral line of magnitude 1 at `global_gain`
|
|
//! 210 must come out as that line and nothing else.
|
|
//!
|
|
//! The oracle test needs reference WAVs that `afconvert` produced from the same
|
|
//! MP3s, so it stays opt-in:
|
|
//!
|
|
//! ```text
|
|
//! afconvert -f WAVE -d LEF32 in.mp3 $DIR/in.wav # and cp in.mp3 $DIR/
|
|
//! MAKEPAD_MP3_ORACLE=$DIR cargo test -p makepad-audio-decode --release \
|
|
//! --test mp3_oracle -- --nocapture oracle
|
|
//! ```
|
|
//!
|
|
//! CoreAudio's MP3 decoder emits 16-bit samples (its 24-bit and float outputs
|
|
//! have zero low bytes), so the comparison quantises our float output the same
|
|
//! way before measuring. Without that the metric reports the oracle's headroom
|
|
//! loss on loud masters — most club records decode past full scale — instead of
|
|
//! any difference between the decoders.
|
|
|
|
use makepad_audio_decode::{decode_any, mp3, AudioError, AudioFormat, Limits};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// a bitstream writer, and enough of an encoder to build valid frames
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct BitWriter {
|
|
bytes: Vec<u8>,
|
|
bit: usize,
|
|
}
|
|
|
|
impl BitWriter {
|
|
fn put(&mut self, value: u32, n: u32) {
|
|
for i in (0..n).rev() {
|
|
if self.bit % 8 == 0 {
|
|
self.bytes.push(0);
|
|
}
|
|
if (value >> i) & 1 == 1 {
|
|
let at = self.bytes.len() - 1;
|
|
self.bytes[at] |= 0x80 >> (self.bit % 8);
|
|
}
|
|
self.bit += 1;
|
|
}
|
|
}
|
|
|
|
fn pad_to(&mut self, bytes: usize) {
|
|
while self.bytes.len() < bytes {
|
|
self.bytes.push(0);
|
|
}
|
|
self.bit = self.bytes.len() * 8;
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Version {
|
|
/// Header version bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5.
|
|
bits: u32,
|
|
lsf: bool,
|
|
}
|
|
|
|
const MPEG1: Version = Version { bits: 3, lsf: false };
|
|
const MPEG2: Version = Version { bits: 2, lsf: true };
|
|
const MPEG25: Version = Version { bits: 0, lsf: true };
|
|
|
|
/// One granule/channel's worth of the side info this builder varies.
|
|
#[derive(Clone, Copy, Default)]
|
|
struct Gr {
|
|
part2_3: u32,
|
|
big_values: u32,
|
|
global_gain: u32,
|
|
table0: u32,
|
|
scalefac_compress: u32,
|
|
/// Emit a mixed block (window switching, short blocks, mixed flag) with
|
|
/// these three subblock gains.
|
|
mixed: Option<[u32; 3]>,
|
|
}
|
|
|
|
struct FrameSpec {
|
|
version: Version,
|
|
/// Layer III bitrate index (9 = 128 kbit/s on MPEG-1).
|
|
bitrate_index: u32,
|
|
rate_index: u32,
|
|
/// 0 stereo, 1 joint stereo, 3 mono.
|
|
mode: u32,
|
|
/// Joint-stereo mode extension: bit 0 intensity, bit 1 mid/side.
|
|
mode_ext: u32,
|
|
granules: Vec<Vec<Gr>>,
|
|
/// Main-data payload as (value, bit count) pairs.
|
|
main: Vec<(u32, u32)>,
|
|
}
|
|
|
|
impl FrameSpec {
|
|
fn silence(version: Version, mode: u32) -> Self {
|
|
let channels = if mode == 3 { 1 } else { 2 };
|
|
let granules = if version.lsf { 1 } else { 2 };
|
|
Self {
|
|
version,
|
|
bitrate_index: 9,
|
|
rate_index: 0,
|
|
mode,
|
|
mode_ext: 0,
|
|
granules: vec![vec![Gr::default(); channels]; granules],
|
|
main: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn channels(&self) -> usize {
|
|
if self.mode == 3 {
|
|
1
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
|
|
/// The rate the header encodes: MPEG-2 halves MPEG-1's, MPEG-2.5 quarters it.
|
|
fn sample_rate(&self) -> u32 {
|
|
let base: [u32; 3] = [44_100, 48_000, 32_000];
|
|
let shift = match self.version.bits {
|
|
3 => 0,
|
|
2 => 1,
|
|
_ => 2,
|
|
};
|
|
base[self.rate_index as usize] >> shift
|
|
}
|
|
|
|
fn side_info_bytes(&self) -> usize {
|
|
match (self.version.lsf, self.channels()) {
|
|
(false, 1) => 17,
|
|
(false, _) => 32,
|
|
(true, 1) => 9,
|
|
(true, _) => 17,
|
|
}
|
|
}
|
|
|
|
fn frame_bytes(&self) -> usize {
|
|
let rate = self.sample_rate() as usize;
|
|
let kbps: [usize; 16] = if self.version.lsf {
|
|
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
|
|
} else {
|
|
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]
|
|
};
|
|
let samples = if self.version.lsf { 576 } else { 1152 };
|
|
(samples / 8) * kbps[self.bitrate_index as usize] * 1000 / rate
|
|
}
|
|
|
|
fn build(&self) -> Vec<u8> {
|
|
let mut w = BitWriter::default();
|
|
// Header.
|
|
w.put(0x7ff, 11);
|
|
w.put(self.version.bits, 2);
|
|
w.put(1, 2); // layer III
|
|
w.put(1, 1); // no CRC
|
|
w.put(self.bitrate_index, 4);
|
|
w.put(self.rate_index, 2);
|
|
w.put(0, 1); // padding
|
|
w.put(0, 1); // private
|
|
w.put(self.mode, 2);
|
|
w.put(self.mode_ext, 2);
|
|
w.put(0, 4); // copyright / original / emphasis
|
|
|
|
// Side info.
|
|
let channels = self.channels();
|
|
w.put(0, if self.version.lsf { 8 } else { 9 }); // main_data_begin
|
|
w.put(
|
|
0,
|
|
match (self.version.lsf, channels) {
|
|
(false, 1) => 5,
|
|
(false, _) => 3,
|
|
(true, 1) => 1,
|
|
(true, _) => 2,
|
|
},
|
|
);
|
|
if !self.version.lsf {
|
|
w.put(0, 4 * channels as u32); // scfsi
|
|
}
|
|
for granule in &self.granules {
|
|
for gr in granule.iter().take(channels) {
|
|
w.put(gr.part2_3, 12);
|
|
w.put(gr.big_values, 9);
|
|
w.put(gr.global_gain, 8);
|
|
w.put(gr.scalefac_compress, if self.version.lsf { 9 } else { 4 });
|
|
match gr.mixed {
|
|
Some(gains) => {
|
|
w.put(1, 1); // window_switching
|
|
w.put(2, 2); // block_type: short
|
|
w.put(1, 1); // mixed_block_flag
|
|
w.put(gr.table0, 5);
|
|
w.put(gr.table0, 5); // only two tables when switching
|
|
for gain in gains {
|
|
w.put(gain, 3);
|
|
}
|
|
}
|
|
None => {
|
|
w.put(0, 1); // window_switching
|
|
w.put(gr.table0, 5);
|
|
w.put(0, 5);
|
|
w.put(0, 5); // table_select
|
|
w.put(0, 4); // region0_count
|
|
w.put(0, 3); // region1_count
|
|
}
|
|
}
|
|
if !self.version.lsf {
|
|
w.put(0, 1); // preflag, present with or without switching
|
|
}
|
|
w.put(0, 1); // scalefac_scale
|
|
w.put(0, 1); // count1table_select
|
|
}
|
|
}
|
|
assert_eq!(w.bytes.len(), 4 + self.side_info_bytes(), "side info width");
|
|
|
|
// Main data, then zero padding out to the frame length.
|
|
for &(value, bits) in &self.main {
|
|
w.put(value, bits);
|
|
}
|
|
let total = self.frame_bytes();
|
|
assert!(w.bytes.len() <= total, "main data does not fit the frame");
|
|
w.pad_to(total);
|
|
w.bytes
|
|
}
|
|
}
|
|
|
|
/// A stream of `count` frames built from `spec`.
|
|
fn stream(spec: &FrameSpec, count: usize) -> Vec<u8> {
|
|
let frame = spec.build();
|
|
frame.repeat(count)
|
|
}
|
|
|
|
/// A frame whose first granule, first channel carries exactly one spectral
|
|
/// line: Huffman table 1's codeword for the pair (1, 0) is `01`, followed by
|
|
/// one sign bit. At `global_gain` 210 the requantised value is exactly 1.0.
|
|
fn one_line_frame(global_gain: u32, negative: bool) -> FrameSpec {
|
|
let mut spec = FrameSpec::silence(MPEG1, 0);
|
|
spec.granules[0][0] = Gr { part2_3: 3, big_values: 1, global_gain, table0: 1, ..Gr::default() };
|
|
spec.main = vec![(0b01, 2), (u32::from(negative), 1)];
|
|
spec
|
|
}
|
|
|
|
fn decode(bytes: &[u8], gapless: bool) -> makepad_audio_decode::DecodedAudio {
|
|
if gapless {
|
|
return mp3::decode_all(bytes).expect("decodes");
|
|
}
|
|
let mut decoder = mp3::Mp3Decoder::new(bytes).expect("syncs");
|
|
decoder.set_gapless(false);
|
|
let mut pcm = Vec::new();
|
|
let (mut rate, mut channels) = (0, 0);
|
|
while let Some(frame) = decoder.next_frame().expect("frame") {
|
|
rate = frame.rate;
|
|
channels = frame.channels;
|
|
pcm.extend_from_slice(frame.pcm);
|
|
}
|
|
makepad_audio_decode::DecodedAudio { rate, channels, pcm_interleaved_f32: pcm }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// synthetic streams
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Every version, sample rate and channel mode the standard defines, with the
|
|
/// bitrate index each one is legal at. This matrix was checked sample-for-
|
|
/// sample against CoreAudio (max 1 LSB deviation on all nine rates) using
|
|
/// streams built exactly like these; the committed test keeps the shape of
|
|
/// each variant honest without needing the oracle.
|
|
const VARIANTS: [(Version, u32, u32, u32, u32, &str); 9] = [
|
|
(MPEG1, 0, 3, 9, 44_100, "mpeg1 44100 mono"),
|
|
(MPEG1, 1, 0, 9, 48_000, "mpeg1 48000 stereo"),
|
|
(MPEG1, 2, 1, 5, 32_000, "mpeg1 32000 joint"),
|
|
(MPEG2, 0, 3, 8, 22_050, "mpeg2 22050 mono"),
|
|
(MPEG2, 1, 0, 8, 24_000, "mpeg2 24000 stereo"),
|
|
(MPEG2, 2, 0, 4, 16_000, "mpeg2 16000 stereo"),
|
|
(MPEG25, 0, 3, 4, 11_025, "mpeg2.5 11025 mono"),
|
|
(MPEG25, 1, 0, 6, 12_000, "mpeg2.5 12000 stereo"),
|
|
(MPEG25, 2, 3, 2, 8_000, "mpeg2.5 8000 mono"),
|
|
];
|
|
|
|
#[test]
|
|
fn silence_decodes_to_silence_in_every_variant() {
|
|
for (version, rate_index, mode, bitrate_index, rate, label) in VARIANTS {
|
|
let mut spec = FrameSpec::silence(version, mode);
|
|
spec.rate_index = rate_index;
|
|
spec.bitrate_index = bitrate_index;
|
|
let audio = decode(&stream(&spec, 6), false);
|
|
let expected_channels = if mode == 3 { 1 } else { 2 };
|
|
let per_frame = if version.lsf { 576 } else { 1152 };
|
|
assert_eq!(audio.channels, expected_channels, "{label}");
|
|
assert_eq!(audio.rate, rate, "{label}");
|
|
assert_eq!(audio.frames(), 6 * per_frame, "{label}");
|
|
assert!(audio.pcm_interleaved_f32.iter().all(|v| *v == 0.0), "{label} is not silent");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn coded_content_decodes_in_every_variant() {
|
|
for (version, rate_index, mode, bitrate_index, rate, label) in VARIANTS {
|
|
let mut spec = FrameSpec::silence(version, mode);
|
|
spec.rate_index = rate_index;
|
|
spec.bitrate_index = bitrate_index;
|
|
// One Huffman-coded pair in the first granule of the first channel,
|
|
// the same construction the oracle sweep used.
|
|
spec.granules[0][0] = Gr { part2_3: 3, big_values: 1, global_gain: 210, table0: 1, ..Gr::default() };
|
|
spec.main = vec![(0b01, 2), (0, 1)];
|
|
let audio = decode(&stream(&spec, 6), false);
|
|
assert_eq!(audio.rate, rate, "{label}");
|
|
let left = audio.channel(0);
|
|
assert!(left.iter().all(|v| v.is_finite()), "{label} produced non-finite samples");
|
|
assert!(left.iter().any(|v| v.abs() > 1e-4), "{label} produced no audio");
|
|
// The line sits in subband 0, so the waveform must stay well inside
|
|
// the range a single unit-magnitude coefficient can reach.
|
|
let peak = left.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
|
assert!(peak < 2.0, "{label} peak {peak}");
|
|
if mode != 3 {
|
|
let right = audio.channel(1);
|
|
assert!(right.iter().all(|v| *v == 0.0), "{label} leaked into the other channel");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eight table-1 codewords for the pair (1, 1) -- `000` plus two sign bits --
|
|
/// which is the densest content this builder can express in a fixed width.
|
|
const PAIRS: u32 = 8;
|
|
|
|
fn eight_pairs() -> Vec<(u32, u32)> {
|
|
(0..PAIRS).flat_map(|_| [(0b000, 3), (0, 1), (0, 1)]).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn intensity_stereo_pans_the_left_channel_across() {
|
|
// Joint stereo, intensity on and mid/side off. The right channel codes
|
|
// nothing but its scalefactors, so every band is intensity coded and the
|
|
// pan comes from `is_pos`. With `scalefac_compress` 0 every position is 0,
|
|
// which the standard's table maps to "all the way right": k_left 0,
|
|
// k_right 1. Verified against CoreAudio at max 1 LSB.
|
|
let mut spec = FrameSpec::silence(MPEG1, 1);
|
|
spec.mode_ext = 1;
|
|
let content = Gr {
|
|
part2_3: PAIRS * 5,
|
|
big_values: PAIRS,
|
|
global_gain: 196,
|
|
table0: 1,
|
|
..Gr::default()
|
|
};
|
|
for granule in spec.granules.iter_mut() {
|
|
granule[0] = content;
|
|
granule[1] = Gr { global_gain: 196, ..Gr::default() };
|
|
}
|
|
spec.main = [eight_pairs(), eight_pairs()].concat();
|
|
let audio = decode(&stream(&spec, 6), false);
|
|
let (left, right) = (audio.channel(0), audio.channel(1));
|
|
let energy = |s: &[f32]| s.iter().map(|v| (v * v) as f64).sum::<f64>();
|
|
assert!(energy(&right) > 1e-6, "the pan target is silent");
|
|
assert!(
|
|
energy(&left) < energy(&right) * 1e-6,
|
|
"left {} should have been panned away against right {}",
|
|
energy(&left),
|
|
energy(&right)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lsf_intensity_scale_comes_from_the_right_channel() {
|
|
// MPEG-2 intensity stereo pans in quarter-log2 steps, and the low bit of
|
|
// `scalefac_compress` doubles the step. That bit belongs to the channel
|
|
// that carries the positions -- the *right* one -- and reading it from the
|
|
// left channel instead is wrong by exactly 2^(1/4) per band. CoreAudio was
|
|
// the arbiter here, in both directions; this pins the answer down.
|
|
//
|
|
// The right channel codes only its positions (`big_values` 0), so every
|
|
// band is intensity coded. `scalefac_compress` 144/145 both select an
|
|
// intensity partition whose first field is two bits wide, and every
|
|
// position is written as 1: odd, so k_left is the attenuated one.
|
|
for (compress, expected) in [(144u32, 0.25f32), (145, 0.5)] {
|
|
let mut spec = FrameSpec::silence(MPEG2, 1);
|
|
spec.rate_index = 0;
|
|
spec.bitrate_index = 8;
|
|
spec.mode_ext = 1;
|
|
spec.granules[0][0] = Gr {
|
|
part2_3: PAIRS * 5,
|
|
big_values: PAIRS,
|
|
global_gain: 196,
|
|
table0: 1,
|
|
..Gr::default()
|
|
};
|
|
spec.granules[0][1] = Gr {
|
|
part2_3: 14,
|
|
global_gain: 196,
|
|
scalefac_compress: compress,
|
|
..Gr::default()
|
|
};
|
|
// Left: the coded pairs. Right: seven two-bit positions, all 1.
|
|
spec.main = eight_pairs();
|
|
spec.main.extend(std::iter::repeat_n((1u32, 2u32), 7));
|
|
|
|
let audio = decode(&stream(&spec, 8), false);
|
|
let rms = |s: &[f32]| {
|
|
(s.iter().map(|v| (v * v) as f64).sum::<f64>() / s.len().max(1) as f64).sqrt()
|
|
};
|
|
let left = rms(&audio.channel(0));
|
|
let right = rms(&audio.channel(1));
|
|
assert!(right > 1e-5, "compress {compress}: nothing decoded");
|
|
// k_left / k_right = 2^-expected.
|
|
let ratio = (left / right) as f32;
|
|
let want = (-expected).exp2();
|
|
assert!(
|
|
(ratio - want).abs() < 1e-3,
|
|
"compress {compress}: channel ratio {ratio} should be {want}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_blocks_decode_with_per_window_gain() {
|
|
// A mixed block is long in the lowest two subbands and short above them,
|
|
// and each of the three short windows carries its own gain. Verified
|
|
// against CoreAudio at max 1 LSB.
|
|
let mut spec = FrameSpec::silence(MPEG1, 0);
|
|
let content = Gr {
|
|
part2_3: PAIRS * 5,
|
|
big_values: PAIRS,
|
|
global_gain: 196,
|
|
table0: 1,
|
|
mixed: Some([0, 1, 2]),
|
|
..Gr::default()
|
|
};
|
|
for granule in spec.granules.iter_mut() {
|
|
for channel in granule.iter_mut() {
|
|
*channel = content;
|
|
}
|
|
}
|
|
spec.main = (0..4).flat_map(|_| eight_pairs()).collect();
|
|
let audio = decode(&stream(&spec, 6), false);
|
|
assert!(audio.pcm_interleaved_f32.iter().all(|v| v.is_finite()));
|
|
let peak = audio.pcm_interleaved_f32.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
|
assert!(peak > 1e-3, "mixed block produced no audio (peak {peak})");
|
|
assert!(peak < 4.0, "mixed block peak {peak} is out of range");
|
|
}
|
|
|
|
#[test]
|
|
fn gapless_trims_the_filterbank_delay() {
|
|
let spec = FrameSpec::silence(MPEG1, 0);
|
|
let bytes = stream(&spec, 6);
|
|
// With no LAME tag the only trim is the filterbank's own 529 samples.
|
|
let trimmed = decode(&bytes, true);
|
|
let raw = decode(&bytes, false);
|
|
assert_eq!(raw.frames() - trimmed.frames(), 529);
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_spectral_line_lands_in_the_lowest_subband() {
|
|
let spec = one_line_frame(210, false);
|
|
let audio = decode(&stream(&spec, 4), false);
|
|
assert_eq!(audio.channels, 2);
|
|
let left = audio.channel(0);
|
|
let right = audio.channel(1);
|
|
assert!(right.iter().all(|v| *v == 0.0), "the untouched channel must stay silent");
|
|
assert!(left.iter().any(|v| v.abs() > 1e-4), "the coded line produced nothing");
|
|
assert!(left.iter().all(|v| v.is_finite()));
|
|
|
|
// Subband 0 spans 0..689 Hz at 44.1 kHz, so the energy must sit in the
|
|
// first of 32 equal bands. Measure it as band energy over a full DFT of
|
|
// one granule pair rather than by probing single frequencies: the signal
|
|
// here is periodic with the granule, so probe tones placed at half-bin
|
|
// offsets would be exactly orthogonal to it and read as silence.
|
|
let slice = &left[576..1728];
|
|
let mut band = [0.0f64; 32];
|
|
for k in 0..576 {
|
|
let (mut re, mut im) = (0.0f64, 0.0f64);
|
|
for (i, &v) in slice.iter().enumerate() {
|
|
let phase =
|
|
-2.0 * std::f64::consts::PI * k as f64 * i as f64 / slice.len() as f64;
|
|
re += v as f64 * phase.cos();
|
|
im += v as f64 * phase.sin();
|
|
}
|
|
band[k / 18] += re * re + im * im;
|
|
}
|
|
let peak = band
|
|
.iter()
|
|
.enumerate()
|
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
|
.map(|(i, _)| i);
|
|
assert_eq!(peak, Some(0), "energy is not in subband 0: {band:?}");
|
|
let total: f64 = band.iter().sum();
|
|
assert!(band[0] / total > 0.9, "subband 0 holds only {:.3} of the energy", band[0] / total);
|
|
}
|
|
|
|
#[test]
|
|
fn global_gain_is_a_quarter_power_of_two_per_step() {
|
|
let quiet = decode(&stream(&one_line_frame(210, false), 4), false);
|
|
let loud = decode(&stream(&one_line_frame(214, false), 4), false);
|
|
let peak = |a: &makepad_audio_decode::DecodedAudio| {
|
|
a.channel(0).iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
|
};
|
|
let ratio = peak(&loud) / peak(&quiet);
|
|
assert!((ratio - 2.0).abs() < 1e-3, "four gain steps should double: {ratio}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_sign_bit_inverts_the_line() {
|
|
let positive = decode(&stream(&one_line_frame(210, false), 4), false);
|
|
let negative = decode(&stream(&one_line_frame(210, true), 4), false);
|
|
let (a, b) = (positive.channel(0), negative.channel(0));
|
|
assert_eq!(a.len(), b.len());
|
|
assert!(a.iter().zip(&b).all(|(x, y)| (x + y).abs() < 1e-6), "not an exact negation");
|
|
}
|
|
|
|
#[test]
|
|
fn probe_duration_matches_a_full_decode() {
|
|
let spec = FrameSpec::silence(MPEG1, 0);
|
|
let bytes = stream(&spec, 20);
|
|
let probed = mp3::probe_duration(&bytes).expect("probe");
|
|
let decoded = decode(&bytes, false).duration_secs();
|
|
// The constant-bitrate estimate is exact for a constant-bitrate stream.
|
|
assert!((probed - decoded).abs() < 1e-9, "{probed} vs {decoded}");
|
|
}
|
|
|
|
#[test]
|
|
fn probe_duration_ignores_bytes_that_are_not_frames() {
|
|
// Anything appended after the last frame -- an APEv2 tag, a Lyrics3 block,
|
|
// padding -- would inflate a duration estimated from the file length. The
|
|
// probe walks the frame chain instead, so it does not move.
|
|
let spec = FrameSpec::silence(MPEG1, 0);
|
|
let bytes = stream(&spec, 20);
|
|
let clean = mp3::probe_duration(&bytes).expect("probe");
|
|
let mut padded = bytes.clone();
|
|
padded.extend_from_slice(b"APETAGEX");
|
|
padded.extend(std::iter::repeat_n(0x5au8, 40_000));
|
|
let padded_probe = mp3::probe_duration(&padded).expect("probe");
|
|
assert!(
|
|
(clean - padded_probe).abs() < 1e-9,
|
|
"{clean} became {padded_probe} because of trailing junk"
|
|
);
|
|
assert!((clean - 20.0 * 1152.0 / 44_100.0).abs() < 1e-9, "{clean}");
|
|
// Leading junk before the first frame does not move it either.
|
|
let mut prefixed = vec![0x11u8; 5_000];
|
|
prefixed.extend_from_slice(&bytes);
|
|
assert!((mp3::probe_duration(&prefixed).expect("probe") - clean).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn an_id3v2_tag_does_not_hide_the_stream() {
|
|
let audio_bytes = stream(&FrameSpec::silence(MPEG1, 0), 4);
|
|
let mut file = vec![0u8; 10];
|
|
file[0..3].copy_from_slice(b"ID3");
|
|
file[3] = 3;
|
|
let payload: Vec<u8> = b"TIT2\0\0\0\x05\0\0\0Test".to_vec();
|
|
let size = payload.len();
|
|
file[6] = ((size >> 21) & 0x7f) as u8;
|
|
file[7] = ((size >> 14) & 0x7f) as u8;
|
|
file[8] = ((size >> 7) & 0x7f) as u8;
|
|
file[9] = (size & 0x7f) as u8;
|
|
file.extend_from_slice(&payload);
|
|
file.extend_from_slice(&audio_bytes);
|
|
// ...and an ID3v1 tag on the end.
|
|
file.extend_from_slice(b"TAG");
|
|
file.extend_from_slice(&[0u8; 125]);
|
|
|
|
assert_eq!(makepad_audio_decode::sniff(&file), Some(AudioFormat::Mp3));
|
|
let audio = decode_any(&file).expect("decodes through the entry point");
|
|
assert_eq!(audio.rate, 44_100);
|
|
assert_eq!(mp3::read_tags(&file).title.as_deref(), Some("Test"));
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_frames_concatenate_to_the_whole_decode() {
|
|
let bytes = stream(&one_line_frame(210, false), 8);
|
|
let whole = mp3::decode_all(&bytes).expect("whole");
|
|
let mut decoder = mp3::Mp3Decoder::new(&bytes).expect("syncs");
|
|
let mut streamed = Vec::new();
|
|
while let Some(frame) = decoder.next_frame().expect("frame") {
|
|
streamed.extend_from_slice(frame.pcm);
|
|
}
|
|
// decode_all also applies the tail trim, which this stream does not have.
|
|
assert_eq!(streamed, whole.pcm_interleaved_f32);
|
|
}
|
|
|
|
#[test]
|
|
fn free_format_and_other_layers_are_refused_by_name() {
|
|
// Free format: the frame length is not derivable, so there is nothing to
|
|
// sync to and the file is not recognised as MP3 at all.
|
|
let mut spec = FrameSpec::silence(MPEG1, 0);
|
|
spec.bitrate_index = 9;
|
|
let good = stream(&spec, 4);
|
|
assert!(mp3::decode_all(&good).is_ok());
|
|
let mut free = good.clone();
|
|
for frame in free.chunks_mut(spec.frame_bytes()) {
|
|
frame[2] &= 0x0f; // bitrate index 0
|
|
}
|
|
assert!(matches!(mp3::decode_all(&free), Err(AudioError::Empty)));
|
|
assert!(makepad_audio_decode::sniff(&free).is_none());
|
|
|
|
let mut layer2 = good.clone();
|
|
for frame in layer2.chunks_mut(spec.frame_bytes()) {
|
|
frame[1] = (frame[1] & !0x06) | 0x04; // layer II
|
|
}
|
|
assert!(matches!(mp3::decode_all(&layer2), Err(AudioError::Empty)));
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_frames_keep_the_timeline_exact() {
|
|
// A decoder that drops an undecodable frame shortens the track and, worse,
|
|
// leaves the bit reservoir describing bytes that no longer belong to the
|
|
// frames after it -- so everything downstream decodes from misaligned main
|
|
// data. Damage must cost silence, not time.
|
|
let spec = one_line_frame(196, false);
|
|
let frame_bytes = spec.frame_bytes();
|
|
let clean = stream(&spec, 8);
|
|
let expected = decode(&clean, false).frames();
|
|
assert_eq!(expected, 8 * 1152);
|
|
|
|
// `big_values` past its legal maximum: one flipped bit in the side info.
|
|
let mut broken = clean.clone();
|
|
broken[3 * frame_bytes + 8] = 0xff;
|
|
assert_eq!(decode(&broken, false).frames(), expected, "side info");
|
|
|
|
// An undefined Huffman table (4) selected for region 0.
|
|
let mut undefined = spec;
|
|
undefined.granules[0][0].table0 = 4;
|
|
let stream_with_bad_table = stream(&undefined, 8);
|
|
assert_eq!(decode(&stream_with_bad_table, false).frames(), expected, "table 4");
|
|
|
|
// Truncating a frame's main data mid-granule.
|
|
let mut short = clean.clone();
|
|
short.drain(5 * frame_bytes + 40..5 * frame_bytes + 60);
|
|
let got = decode(&short, false).frames();
|
|
assert!(got <= expected, "truncation grew the stream: {got} vs {expected}");
|
|
}
|
|
|
|
#[test]
|
|
fn limits_are_enforced() {
|
|
let bytes = stream(&FrameSpec::silence(MPEG1, 0), 20);
|
|
let err = makepad_audio_decode::decode_audio_limited(
|
|
&bytes,
|
|
AudioFormat::Mp3,
|
|
Limits::with_max_frames(1000),
|
|
);
|
|
assert!(matches!(err, Err(AudioError::TooLarge(_))), "{err:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// nothing panics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct Lcg(u64);
|
|
|
|
impl Lcg {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
|
self.0 >> 11
|
|
}
|
|
}
|
|
|
|
/// Kept small so the default `cargo test` stays fast; raise it with
|
|
/// `MAKEPAD_MP3_FUZZ=20000` when the decoder changes.
|
|
fn fuzz_iterations() -> usize {
|
|
std::env::var("MAKEPAD_MP3_FUZZ").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
|
|
}
|
|
|
|
/// Every entry point, on bytes that may be nonsense. The only contract is that
|
|
/// it returns.
|
|
fn exercise(bytes: &[u8]) {
|
|
let _ = makepad_audio_decode::sniff(bytes);
|
|
let _ = mp3::probe_duration(bytes);
|
|
let _ = mp3::read_tags(bytes);
|
|
let _ = mp3::decode_all_limited(bytes, Limits::with_max_frames(200_000));
|
|
if let Ok(mut decoder) = mp3::Mp3Decoder::with_limits(bytes, Limits::with_max_frames(200_000)) {
|
|
let mut guard = 0;
|
|
while let Ok(Some(_)) = decoder.next_frame() {
|
|
guard += 1;
|
|
if guard > 5_000 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fuzz_corpus() -> Vec<u8> {
|
|
// A stream with real coded content, so the mangling reaches the Huffman
|
|
// and requantization paths rather than stopping at the header.
|
|
let mut bytes = stream(&one_line_frame(210, false), 12);
|
|
bytes.extend_from_slice(&stream(&FrameSpec::silence(MPEG2, 3), 6));
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_at_every_sixteenth_never_panics() {
|
|
let bytes = fuzz_corpus();
|
|
for i in 0..=16 {
|
|
exercise(&bytes[..bytes.len() * i / 16]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn flipped_bytes_never_panic() {
|
|
let bytes = fuzz_corpus();
|
|
let mut rng = Lcg(0x5eed_1234);
|
|
for _ in 0..fuzz_iterations() {
|
|
let mut copy = bytes.clone();
|
|
let flips = 1 + (rng.next() % 8) as usize;
|
|
for _ in 0..flips {
|
|
let at = (rng.next() as usize) % copy.len();
|
|
copy[at] ^= (rng.next() % 256) as u8;
|
|
}
|
|
exercise(©);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mangled_headers_never_panic() {
|
|
let bytes = fuzz_corpus();
|
|
// Walk a corruption across the first frame's header and side info, where
|
|
// every field feeds a table index or a length.
|
|
for at in 0..64.min(bytes.len()) {
|
|
for value in [0x00u8, 0xff, 0xaa, 0x55] {
|
|
let mut copy = bytes.clone();
|
|
copy[at] = value;
|
|
exercise(©);
|
|
}
|
|
}
|
|
exercise(&[]);
|
|
exercise(&[0xff; 8]);
|
|
exercise(&[0xff, 0xfb, 0x90, 0x04]);
|
|
exercise(&vec![0xff; 4096]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the CoreAudio oracle (opt-in)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn read_wav(bytes: &[u8]) -> (u32, u16, Vec<f32>) {
|
|
assert!(bytes.len() > 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WAVE");
|
|
let (mut rate, mut channels, mut bits, mut format) = (0u32, 0u16, 0u16, 0u16);
|
|
let mut data: &[u8] = &[];
|
|
let mut at = 12usize;
|
|
while at + 8 <= bytes.len() {
|
|
let id = &bytes[at..at + 4];
|
|
let size = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as usize;
|
|
let end = (at + 8 + size).min(bytes.len());
|
|
let body = &bytes[at + 8..end];
|
|
match id {
|
|
b"fmt " if body.len() >= 16 => {
|
|
format = u16::from_le_bytes(body[0..2].try_into().unwrap());
|
|
channels = u16::from_le_bytes(body[2..4].try_into().unwrap());
|
|
rate = u32::from_le_bytes(body[4..8].try_into().unwrap());
|
|
bits = u16::from_le_bytes(body[14..16].try_into().unwrap());
|
|
}
|
|
b"data" => data = body,
|
|
_ => {}
|
|
}
|
|
at = end + (size & 1);
|
|
}
|
|
let pcm = match (format, bits) {
|
|
(3, 32) => data
|
|
.chunks_exact(4)
|
|
.map(|p| f32::from_le_bytes([p[0], p[1], p[2], p[3]]))
|
|
.collect(),
|
|
(1, 16) => data
|
|
.chunks_exact(2)
|
|
.map(|p| i16::from_le_bytes([p[0], p[1]]) as f32 / 32768.0)
|
|
.collect(),
|
|
other => panic!("reference wav format {other:?} is not supported"),
|
|
};
|
|
(rate, channels, pcm)
|
|
}
|
|
|
|
/// Quantise to the 16-bit grid CoreAudio's decoder actually produces.
|
|
fn as_oracle_sees_it(pcm: &[f32]) -> Vec<f32> {
|
|
pcm.iter()
|
|
.map(|v| (v.clamp(-1.0, 32_767.0 / 32_768.0) * 32_768.0).round() / 32_768.0)
|
|
.collect()
|
|
}
|
|
|
|
fn best_offset(reference: &[f32], got: &[f32], channels: usize, span: isize) -> isize {
|
|
let take = 200_000usize.min(reference.len() / channels).min(got.len() / channels);
|
|
let (mut best, mut best_at) = (f64::NEG_INFINITY, 0isize);
|
|
for offset in -span..=span {
|
|
let (mut num, mut da, mut db) = (0.0f64, 0.0f64, 0.0f64);
|
|
for i in 0..take {
|
|
let j = i as isize + offset;
|
|
if j < 0 || j as usize >= take {
|
|
continue;
|
|
}
|
|
let x = reference[i * channels] as f64;
|
|
let y = got[j as usize * channels] as f64;
|
|
num += x * y;
|
|
da += x * x;
|
|
db += y * y;
|
|
}
|
|
if da > 0.0 && db > 0.0 {
|
|
let score = num / (da.sqrt() * db.sqrt());
|
|
if score > best {
|
|
best = score;
|
|
best_at = offset;
|
|
}
|
|
}
|
|
}
|
|
best_at
|
|
}
|
|
|
|
#[test]
|
|
fn oracle_matches_coreaudio() {
|
|
let Ok(dir) = std::env::var("MAKEPAD_MP3_ORACLE") else {
|
|
eprintln!("MAKEPAD_MP3_ORACLE not set; skipping the CoreAudio comparison");
|
|
return;
|
|
};
|
|
let gate: f64 = std::env::var("MAKEPAD_MP3_SNR")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(85.0);
|
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
|
.expect("oracle directory")
|
|
.filter_map(|e| e.ok().map(|e| e.path()))
|
|
.filter(|p| p.extension().is_some_and(|e| e == "mp3"))
|
|
.collect();
|
|
entries.sort();
|
|
assert!(!entries.is_empty(), "no .mp3 files in {dir}");
|
|
|
|
let mut checked = 0usize;
|
|
for path in entries {
|
|
let wav = path.with_extension("wav");
|
|
let Ok(reference) = std::fs::read(&wav) else {
|
|
eprintln!("no reference for {}", path.display());
|
|
continue;
|
|
};
|
|
let bytes = std::fs::read(&path).expect("mp3");
|
|
let got = mp3::decode_all(&bytes).expect("decode");
|
|
let (rate, channels, reference) = read_wav(&reference);
|
|
assert_eq!(rate, got.rate, "{}", path.display());
|
|
assert_eq!(channels, got.channels, "{}", path.display());
|
|
let ch = channels as usize;
|
|
let matched = as_oracle_sees_it(&got.pcm_interleaved_f32);
|
|
let offset = best_offset(&reference, &matched, ch, 8);
|
|
assert_eq!(offset, 0, "{} is not sample-aligned", path.display());
|
|
|
|
let (mut signal, mut noise, mut exact, mut within, mut worst, mut n) =
|
|
(0.0f64, 0.0f64, 0u64, 0u64, 0.0f64, 0u64);
|
|
let frames = (reference.len() / ch).min(matched.len() / ch);
|
|
for i in 0..frames {
|
|
for c in 0..ch {
|
|
let x = reference[i * ch + c] as f64;
|
|
let y = matched[i * ch + c] as f64;
|
|
signal += x * x;
|
|
noise += (x - y) * (x - y);
|
|
let lsb = ((x - y) * 32_768.0).abs();
|
|
worst = worst.max(lsb);
|
|
exact += u64::from(lsb < 0.25);
|
|
within += u64::from(lsb < 1.25);
|
|
n += 1;
|
|
}
|
|
}
|
|
let snr = 10.0 * (signal / noise.max(f64::MIN_POSITIVE)).log10();
|
|
// The oracle's own length: gapless trimming must land on it exactly,
|
|
// give or take the 529 samples it keeps when a stream has no LAME tag.
|
|
let delta = reference.len() as i64 / ch as i64 - got.frames() as i64;
|
|
eprintln!(
|
|
"{:<48} {snr:6.2} dB exact {:5.2}% <=1LSB {:8.5}% worst {worst:3.0} LSB frames {delta:+}",
|
|
path.file_name().unwrap_or_default().to_string_lossy(),
|
|
100.0 * exact as f64 / n as f64,
|
|
100.0 * within as f64 / n as f64,
|
|
);
|
|
assert!(snr >= gate, "{}: SNR {snr:.2} dB below the {gate} dB gate", path.display());
|
|
assert!(worst <= 2.0, "{}: {worst} LSB is more than a rounding step", path.display());
|
|
assert!(
|
|
(0..=529).contains(&delta),
|
|
"{}: frame count differs by {delta}",
|
|
path.display()
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert!(checked > 0, "no mp3/wav pairs were compared");
|
|
eprintln!("compared {checked} files against CoreAudio");
|
|
}
|