deckcraft/crates/media/tests/decode.rs

328 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Decoding and probing every supported format.
//!
//! WAV and AIFF are written here; the compressed fixtures in `tests/fixtures` are a 0.5 s, 440 Hz
//! sine (amplitude 1/8) and a 6-frame 64×48 colour gradient we synthesised and encoded with
//! `fixtures/make.sh` (CC0, see ATTRIBUTION.md).
use std::sync::Arc;
use std::time::Duration;
use deckcraft_media::{Bytes, Container, MediaError, audio, probe, video};
fn fixture(name: &str) -> Bytes {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);
Arc::new(std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())))
}
fn sine(rate: u32, secs: f64) -> Vec<f32> {
(0..(rate as f64 * secs) as usize).map(|i| (i as f64 * 440.0 * std::f64::consts::TAU / rate as f64).sin() as f32 * 0.125).collect()
}
fn wav(rate: u32, channels: u16, samples: &[f32]) -> Vec<u8> {
let data: Vec<u8> = samples.iter().flat_map(|s| ((s * 32767.0) as i16).to_le_bytes()).collect();
let mut b = Vec::new();
b.extend_from_slice(b"RIFF");
b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
b.extend_from_slice(b"WAVEfmt ");
b.extend_from_slice(&16u32.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&channels.to_le_bytes());
b.extend_from_slice(&rate.to_le_bytes());
b.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes());
b.extend_from_slice(&(channels * 2).to_le_bytes());
b.extend_from_slice(&16u16.to_le_bytes());
b.extend_from_slice(b"data");
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
b.extend_from_slice(&data);
b
}
/// 80-bit IEEE extended sample rate for AIFF's COMM chunk.
fn ext80(v: u32) -> [u8; 10] {
let mut out = [0u8; 10];
let e = 31 - v.leading_zeros();
let exp = (16383 + e) as u16;
out[..2].copy_from_slice(&exp.to_be_bytes());
let mant = (v as u64) << (63 - e);
out[2..].copy_from_slice(&mant.to_be_bytes());
out
}
fn aiff(rate: u32, samples: &[f32]) -> Vec<u8> {
let data: Vec<u8> = samples.iter().flat_map(|s| ((s * 32767.0) as i16).to_be_bytes()).collect();
let mut comm = Vec::new();
comm.extend_from_slice(&1u16.to_be_bytes());
comm.extend_from_slice(&(samples.len() as u32).to_be_bytes());
comm.extend_from_slice(&16u16.to_be_bytes());
comm.extend_from_slice(&ext80(rate));
let mut b = Vec::new();
b.extend_from_slice(b"FORM");
b.extend_from_slice(&((4 + 8 + comm.len() + 8 + 8 + data.len()) as u32).to_be_bytes());
b.extend_from_slice(b"AIFF");
b.extend_from_slice(b"COMM");
b.extend_from_slice(&(comm.len() as u32).to_be_bytes());
b.extend_from_slice(&comm);
b.extend_from_slice(b"SSND");
b.extend_from_slice(&((8 + data.len()) as u32).to_be_bytes());
b.extend_from_slice(&[0; 8]);
b.extend_from_slice(&data);
b
}
/// Frequency of channel 0 from its zero crossings (with hysteresis, so codec noise around silence
/// doesn't count), over the span between the first and last crossing.
fn frequency(p: &audio::Pcm) -> f64 {
let mut sign = 0i8;
let mut crossings = vec![];
for i in 0..p.frames() {
let s = p.at(i, 0);
let now = if s > 0.03 {
1
} else if s < -0.03 {
-1
} else {
0
};
if now != 0 && now != sign {
if sign != 0 {
crossings.push(i);
}
sign = now;
}
}
match (crossings.first(), crossings.last()) {
(Some(a), Some(b)) if b > a => (crossings.len() - 1) as f64 / 2.0 / ((b - a) as f64 / p.rate as f64),
_ => 0.0,
}
}
/// The tone decodes to ~0.5 s of a 440 Hz sine at amplitude 1/8.
fn check_tone(name: &str, bytes: &Bytes, rate: u32, channels: u16) {
let p = audio::decode(bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((p.rate, p.channels), (rate, channels), "{name}");
assert!((p.duration() - 0.5).abs() < 0.06, "{name}: {} s", p.duration());
let f = frequency(&p);
assert!((f - 440.0).abs() < 15.0, "{name}: {f} Hz");
let rms = p.rms();
// 1/8 / √2 (ffmpeg's mono → stereo upmix for the Vorbis fixture lowers it by another √2).
assert!((0.055..0.105).contains(&rms), "{name}: rms {rms}");
let info = probe(bytes).unwrap_or_else(|e| panic!("{name}: probe {e}"));
let a = info.audio.unwrap_or_else(|| panic!("{name}: no audio info"));
assert!(a.decodable, "{name}");
assert_eq!((a.sample_rate, a.channels), (rate, channels), "{name}");
assert!((info.duration_ms as i64 - 500).abs() <= 60, "{name}: {} ms", info.duration_ms);
assert!(info.video.is_none(), "{name}");
}
#[test]
fn wav_written_in_code() {
let b: Bytes = Arc::new(wav(44_100, 1, &sine(44_100, 0.5)));
assert_eq!(Container::sniff(&b), Container::Wav);
check_tone("wav", &b, 44_100, 1);
// Stereo.
let st: Vec<f32> = sine(22_050, 0.5).iter().flat_map(|s| [*s, *s]).collect();
check_tone("wav stereo", &Arc::new(wav(22_050, 2, &st)), 22_050, 2);
}
#[test]
fn aiff_written_in_code() {
let b: Bytes = Arc::new(aiff(48_000, &sine(48_000, 0.5)));
assert_eq!(Container::sniff(&b), Container::Aiff);
check_tone("aiff", &b, 48_000, 1);
}
#[test]
fn mp3() {
let b = fixture("tone.mp3");
assert_eq!(Container::sniff(&b), Container::Mp3);
check_tone("mp3", &b, 44_100, 1);
}
#[test]
fn aac_m4a_and_adts() {
check_tone("m4a", &fixture("tone.m4a"), 44_100, 1);
let adts = fixture("tone.aac");
assert_eq!(Container::sniff(&adts), Container::Adts);
check_tone("aac", &adts, 44_100, 1);
}
#[test]
fn alac() {
check_tone("alac", &fixture("tone-alac.m4a"), 44_100, 1);
}
#[test]
fn flac() {
check_tone("flac", &fixture("tone.flac"), 44_100, 1);
}
#[test]
fn ogg_vorbis() {
check_tone("vorbis", &fixture("tone.ogg"), 44_100, 2);
}
#[test]
fn ogg_opus() {
check_tone("opus", &fixture("tone.opus"), 48_000, 1);
}
#[test]
fn wma_is_probed_but_not_decoded() {
let b = fixture("tone.wma");
assert_eq!(Container::sniff(&b), Container::Asf);
let info = probe(&b).expect("probe");
let a = info.audio.clone().expect("audio");
assert_eq!((a.codec.as_str(), a.sample_rate, a.channels, a.decodable), ("WMA 2", 44_100, 1, false));
assert!((info.duration_ms as i64 - 500).abs() <= 120, "{}", info.duration_ms);
assert!(!info.playable());
assert!(matches!(audio::decode(&b), Err(MediaError::Unsupported(_))));
}
#[test]
fn garbage_is_an_error_not_a_panic() {
let b: Bytes = Arc::new((0..4000u32).map(|i| (i * 7919 % 251) as u8).collect());
assert!(probe(&b).is_err());
assert!(audio::decode(&b).is_err());
assert!(video::VideoDecoder::open(b.clone()).is_err());
// Truncated real files too.
for name in ["tone.mp3", "tone.m4a", "tone.opus", "clip-h264.mp4", "clip-vp9.webm"] {
let full = fixture(name);
let cut: Bytes = Arc::new(full[..full.len() / 3].to_vec());
let _ = probe(&cut);
let _ = audio::decode(&cut);
if let Ok(mut d) = video::VideoDecoder::open(cut) {
while let Ok(Some(_)) = d.next_frame() {}
}
}
}
/// Mean colour of a 16×16 block in the middle of the frame.
fn centre(f: &video::Frame) -> [f64; 3] {
let (w, h) = (f.width as usize, f.height as usize);
let mut s = [0.0; 3];
let mut n = 0.0;
for y in h / 2 - 8..h / 2 + 8 {
for x in w / 2 - 8..w / 2 + 8 {
for (c, v) in s.iter_mut().enumerate() {
*v += f.rgba[(y * w + x) * 4 + c] as f64;
}
n += 1.0;
}
}
s.map(|v| v / n)
}
/// Our gradient's mean over the same block in frame `k`: R = (4x + 20k) mod 256, G = 5y mod 256,
/// B = 40k mod 256.
fn expected_centre(w: usize, h: usize, k: usize) -> [f64; 3] {
let mut s = [0.0; 3];
let mut n = 0.0;
for y in h / 2 - 8..h / 2 + 8 {
for x in w / 2 - 8..w / 2 + 8 {
s[0] += ((x * 4 + k * 20) % 256) as f64;
s[1] += ((y * 5) % 256) as f64;
s[2] += ((k * 40) % 256) as f64;
n += 1.0;
}
}
s.map(|v| v / n)
}
fn check_video(name: &str, w: u32, h: u32, frames: usize, codec: &str) {
let b = fixture(name);
let info = probe(&b).unwrap_or_else(|e| panic!("{name}: {e}"));
let v = info.video.clone().unwrap_or_else(|| panic!("{name}: no video info"));
assert_eq!((v.codec.as_str(), v.width, v.height, v.decodable), (codec, w, h, true), "{name}");
assert!((v.fps - 10.0).abs() < 0.6, "{name}: {} fps", v.fps);
assert!((info.duration_ms as i64 - frames as i64 * 100).abs() <= 120, "{name}: {} ms", info.duration_ms);
let mut d = video::VideoDecoder::open(b.clone()).unwrap_or_else(|e| panic!("{name}: {e}"));
let mut got = vec![];
while let Some(f) = d.next_frame().unwrap_or_else(|e| panic!("{name}: {e}")) {
assert_eq!((f.width, f.height, f.rgba.len()), (w, h, (w * h * 4) as usize), "{name}");
got.push(f);
}
assert_eq!(got.len(), frames, "{name}");
for (k, f) in got.iter().enumerate() {
assert!((f.time - k as f64 * 0.1).abs() < 0.02, "{name}: frame {k} at {}", f.time);
let (c, e) = (centre(f), expected_centre(w as usize, h as usize, k));
for i in 0..3 {
assert!((c[i] - e[i]).abs() < 24.0, "{name}: frame {k} channel {i}: {c:?} vs {e:?}");
}
}
// Seeking lands on the frame shown at that time.
d.seek(0.35);
let f = d.next_frame().ok().flatten().unwrap_or_else(|| panic!("{name}: no frame after seek"));
assert!((f.time - 0.3).abs() < 0.02, "{name}: seek gave {}", f.time);
// Poster frames.
let p = video::poster_frame(&b, 0.25).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!((p.time - 0.2).abs() < 0.02, "{name}: poster at {}", p.time);
let png = video::poster_png(&b, 0.0).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn h264_mp4_with_b_frames_and_aac_audio() {
check_video("clip-h264.mp4", 64, 48, 5, "H.264");
// Its audio track decodes too.
let b = fixture("clip-h264.mp4");
let info = probe(&b).expect("probe");
assert_eq!(info.audio.as_ref().map(|a| (a.codec.as_str(), a.decodable)), Some(("AAC", true)));
let p = audio::decode(&b).expect("audio of the video");
assert!((frequency(&p) - 440.0).abs() < 15.0);
}
#[test]
fn h264_mov() {
check_video("clip-h264.mov", 64, 48, 6, "H.264");
assert_eq!(probe(&fixture("clip-h264.mov")).map(|i| i.container), Ok("mov"));
}
#[test]
fn hevc_mp4() {
check_video("clip-hevc.mp4", 64, 48, 6, "HEVC");
}
#[test]
fn vp9_webm_with_opus_audio() {
check_video("clip-vp9.webm", 64, 48, 5, "VP9");
let b = fixture("clip-vp9.webm");
assert_eq!(probe(&b).ok().and_then(|i| i.audio).map(|a| (a.codec, a.sample_rate)), Some(("Opus".to_string(), 48_000)));
let p = audio::decode(&b).expect("opus in webm");
assert_eq!(p.rate, 48_000);
assert!((frequency(&p) - 440.0).abs() < 15.0);
}
#[test]
fn av1_mkv() {
check_video("clip-av1.mkv", 64, 64, 6, "AV1");
}
#[test]
fn video_feed_follows_the_clock() {
let mut feed = deckcraft_media::VideoFeed::new(fixture("clip-h264.mov")).expect("feed");
assert_eq!(feed.size(), (64, 48));
let t = Duration::from_secs(5);
assert!(feed.frame_at_blocking(0.0, t).is_some_and(|f| f.time == 0.0));
let f = feed.frame_at_blocking(0.25, t).map(|f| f.time);
assert!(f.is_some_and(|v| (v - 0.2).abs() < 0.02), "{f:?}");
// Jumping back (a loop) seeks.
let f = feed.frame_at_blocking(0.05, t).map(|f| f.time);
assert_eq!(f, Some(0.0));
let f = feed.frame_at_blocking(0.55, t).map(|f| f.time);
assert!(f.is_some_and(|v| (v - 0.5).abs() < 0.02), "{f:?}");
}
#[test]
fn player_plays_decoded_audio_through_the_mixer() {
use deckcraft_media::{ClipParams, Player, VoiceSpec};
let b: Bytes = Arc::new(wav(48_000, 1, &sine(48_000, 0.5)));
let mut p = Player::new(None);
p.play(1, 1, &b, VoiceSpec { clip: ClipParams { volume: 0.5, ..Default::default() }, looping: false, duration: 0.5, audio: true }, None);
assert!(p.wait_loaded(1, Duration::from_secs(5)));
let mut buf = vec![0.0f32; 4800 * 2];
p.render(&mut buf, 2, 48_000);
let rms = (buf.iter().map(|s| s * s).sum::<f32>() / buf.len() as f32).sqrt();
assert!((rms - 0.0884 * 0.5).abs() < 0.005, "{rms}");
assert!((p.status(1).map(|s| s.position).unwrap_or(0.0) - 0.1).abs() < 1e-6);
}