mod common;
use common::{have_ffmpeg, skip};
use playr::audio::decode::AudioStream;
use playr::audio::output::remap_channels;
use std::path::Path;
use std::process::Command;
fn encode(path: &Path, rate: u32, args: &[&str]) -> Result<(), String> {
let out = Command::new("ffmpeg")
.args([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
&format!("sine=frequency=440:sample_rate={rate}:duration=2"),
"-ac",
"2",
])
.args(args)
.arg(path)
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(stderr.lines().next().unwrap_or("ffmpeg failed").to_string())
}
fn decode_all(path: &Path) -> Result<(u32, u16, usize, f32), String> {
let mut s = AudioStream::open(path).map_err(|e| e.to_string())?;
let mut frames = 0usize;
let mut peak = 0f32;
loop {
let ch = s.spec().channels.max(1) as usize;
match s.next_chunk() {
Ok(Some(c)) => {
frames += c.len() / ch;
for v in c {
peak = peak.max(v.abs());
}
}
Ok(None) => break,
Err(e) => return Err(e.to_string()),
}
}
let spec = s.spec();
Ok((spec.rate, spec.channels, frames, peak))
}
#[test]
fn decodes_the_formats_this_build_claims_to_support() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let cases: &[(&str, u32, &[&str], usize)] = &[
("t.wav", 44100, &["-c:a", "pcm_s16le"], 0),
("t.flac", 44100, &["-c:a", "flac"], 0),
("hi.flac", 96000, &["-c:a", "flac"], 0),
("t.aiff", 44100, &["-c:a", "pcm_s16be"], 0),
(
"t.ogg",
44100,
&["-c:a", "vorbis", "-strict", "experimental"],
128,
),
("t.mp3", 44100, &["-c:a", "libmp3lame", "-b:a", "192k"], 0),
("t.m4a", 44100, &["-c:a", "aac", "-b:a", "192k"], 2048),
("alac.m4a", 44100, &["-c:a", "alac"], 0),
("t.opus", 48000, &["-c:a", "libopus", "-b:a", "128k"], 0),
(
"opus.webm",
48000,
&["-c:a", "libopus", "-b:a", "128k"],
648,
),
(
"vorbis.webm",
48000,
&["-c:a", "vorbis", "-strict", "experimental"],
1024,
),
];
for (name, rate, args, tolerance) in cases {
if !cfg!(feature = "opus") && name.contains("opus") {
continue;
}
let path = dir.path().join(name);
if let Err(why) = encode(&path, *rate, args) {
skip(
"PLAYR_REQUIRE_FFMPEG",
&format!("cannot encode {name}: {why}"),
);
continue;
}
let (got_rate, ch, frames, peak) =
decode_all(&path).unwrap_or_else(|e| panic!("{name} failed to decode: {e}"));
assert_eq!(got_rate, *rate, "{name}: wrong sample rate");
assert_eq!(ch, 2, "{name}: wrong channel count");
let expected = (*rate as usize) * 2;
let diff = frames.abs_diff(expected);
assert!(
diff <= *tolerance,
"{name}: got {frames} frames, expected {expected} (+/-{tolerance})"
);
assert!(
peak > 0.05 && peak < 0.15,
"{name}: peak {peak} suggests a scaling error"
);
}
}
#[cfg(feature = "opus")]
#[test]
fn opus_pre_skip_is_removed() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.opus");
if let Err(why) = encode(&path, 48000, &["-c:a", "libopus", "-b:a", "128k"]) {
skip(
"PLAYR_REQUIRE_FFMPEG",
&format!("cannot encode Opus: {why}"),
);
return;
}
let (rate, ch, frames, _) = decode_all(&path).expect("Opus failed to decode");
assert_eq!(rate, 48000);
assert_eq!(ch, 2);
assert_eq!(
frames, 96_000,
"expected exactly 2s; pre-skip or padding mishandled"
);
}
#[cfg(feature = "opus")]
#[test]
fn mono_opus_decodes() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mono.opus");
let ok = Command::new("ffmpeg")
.args([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:duration=2",
"-ac",
"1",
"-c:a",
"libopus",
"-b:a",
"96k",
])
.arg(&path)
.output()
.is_ok_and(|o| o.status.success());
if !ok {
skip(
"PLAYR_REQUIRE_FFMPEG",
"this ffmpeg cannot encode mono Opus",
);
return;
}
let (rate, ch, frames, peak) = decode_all(&path).expect("mono Opus failed to decode");
assert_eq!(rate, 48000);
assert_eq!(ch, 1);
assert_eq!(frames, 96_000);
assert!(
peak > 0.05 && peak < 0.15,
"peak {peak} suggests a scaling error"
);
}
#[cfg(feature = "opus")]
#[test]
fn seeking_an_opus_stream_does_not_reapply_pre_skip() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.opus");
if let Err(why) = encode(&path, 48000, &["-c:a", "libopus", "-b:a", "128k"]) {
skip(
"PLAYR_REQUIRE_FFMPEG",
&format!("cannot encode Opus: {why}"),
);
return;
}
let mut s = AudioStream::open(&path).unwrap();
s.seek(std::time::Duration::from_secs(1)).unwrap();
let mut frames = 0usize;
while let Ok(Some(c)) = s.next_chunk() {
frames += c.len() / 2;
}
assert!(
(frames as i64 - 48_000).abs() < 4_800,
"after seeking to 1s, {frames} frames remained (expected ~48000)"
);
}
#[test]
fn a_codec_with_no_decoder_reports_rather_than_panics() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.wma");
if let Err(why) = encode(&path, 44100, &["-c:a", "wmav2", "-b:a", "128k"]) {
skip("PLAYR_REQUIRE_FFMPEG", &format!("cannot encode WMA: {why}"));
return;
}
let err = decode_all(&path).expect_err("WMA unexpectedly decoded; update the docs");
assert!(
err.contains("no decoder") || err.contains("unsupported"),
"unhelpful error: {err}"
);
}
#[test]
fn a_file_that_is_not_audio_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake.flac");
std::fs::write(&path, b"definitely not a flac stream").unwrap();
assert!(AudioStream::open(&path).is_err());
}
#[test]
fn a_missing_file_is_rejected() {
assert!(AudioStream::open(Path::new("/nonexistent/nope.flac")).is_err());
}
#[test]
fn seeking_moves_the_read_position() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.flac");
encode(&path, 44100, &["-c:a", "flac"]).unwrap();
let mut s = AudioStream::open(&path).unwrap();
s.seek(std::time::Duration::from_secs(1)).unwrap();
let mut frames = 0usize;
while let Ok(Some(c)) = s.next_chunk() {
frames += c.len() / 2;
}
assert!(
(frames as i64 - 44100).abs() < 4410,
"after seeking to 1s, {frames} frames remained (expected ~44100)"
);
}
fn samples(s: &mut AudioStream) -> Vec<f32> {
let mut out = Vec::new();
while let Ok(Some(c)) = s.next_chunk() {
out.extend_from_slice(c);
}
out
}
#[test]
fn a_seek_resumes_on_the_exact_sample() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("n.flac");
let status = Command::new("ffmpeg")
.args(["-y", "-v", "error", "-f", "lavfi", "-i"])
.arg("anoisesrc=d=3:c=white:r=44100:seed=7")
.args(["-ac", "2", "-c:a", "flac"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let whole = samples(&mut AudioStream::open(&path).unwrap());
for secs in [0.5, 1.0, 1.7] {
let mut s = AudioStream::open(&path).unwrap();
s.seek(std::time::Duration::from_secs_f64(secs)).unwrap();
let after = samples(&mut s);
let at = (secs * 44100.0).round() as usize * 2;
assert_eq!(
after.len(),
whole.len() - at,
"seek to {secs}s resumed {} frames early",
(after.len() as i64 - (whole.len() - at) as i64) / 2
);
assert!(
after[..4096] == whole[at..at + 4096],
"samples differ after seek to {secs}s"
);
}
}
#[test]
fn duration_is_reported_from_the_container() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.flac");
encode(&path, 44100, &["-c:a", "flac"]).unwrap();
let s = AudioStream::open(&path).unwrap();
let d = s.duration().expect("no duration reported");
assert!(
(d.as_secs_f64() - 2.0).abs() < 0.05,
"duration {d:?} is not ~2s"
);
}
#[test]
fn matching_channel_counts_pass_through_untouched() {
let input = vec![0.1, 0.2, 0.3, 0.4];
let mut out = Vec::new();
remap_channels(&input, 2, 2, &mut out);
assert_eq!(out, input);
}
#[test]
fn mono_fans_out_to_every_output_channel() {
let input = vec![0.5, -0.5];
let mut out = Vec::new();
remap_channels(&input, 1, 2, &mut out);
assert_eq!(out, vec![0.5, 0.5, -0.5, -0.5]);
let mut out4 = Vec::new();
remap_channels(&input, 1, 4, &mut out4);
assert_eq!(out4, vec![0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5]);
}
#[test]
fn extra_source_channels_are_dropped() {
let input = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let mut out = Vec::new();
remap_channels(&input, 6, 2, &mut out);
assert_eq!(out, vec![1.0, 2.0]);
}
#[test]
fn widening_pads_missing_channels_with_silence() {
let input = vec![1.0, 2.0];
let mut out = Vec::new();
remap_channels(&input, 2, 4, &mut out);
assert_eq!(out, vec![1.0, 2.0, 0.0, 0.0]);
}
#[test]
fn remapping_output_is_always_frame_aligned() {
for (src, dst) in [(1usize, 2usize), (2, 2), (2, 1), (6, 2), (2, 6), (3, 5)] {
let input = vec![0.25f32; src * 7];
let mut out = Vec::new();
remap_channels(&input, src, dst, &mut out);
assert_eq!(
out.len(),
dst * 7,
"{src}ch -> {dst}ch produced a ragged buffer"
);
}
}
#[test]
fn a_zero_channel_count_produces_nothing_instead_of_dividing_by_zero() {
let mut out = Vec::new();
remap_channels(&[1.0, 2.0], 0, 2, &mut out);
assert!(out.is_empty());
remap_channels(&[1.0, 2.0], 2, 0, &mut out);
assert!(out.is_empty());
}
#[cfg(not(feature = "opus"))]
#[test]
fn opus_reports_cleanly_when_the_feature_is_off() {
if !have_ffmpeg() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.opus");
if let Err(why) = encode(&path, 48000, &["-c:a", "libopus", "-b:a", "128k"]) {
skip(
"PLAYR_REQUIRE_FFMPEG",
&format!("cannot encode Opus: {why}"),
);
return;
}
let err = decode_all(&path).expect_err("Opus decoded without the opus feature");
assert!(err.contains("no decoder"), "unhelpful error: {err}");
}
fn error_db(after: &[f32], whole: &[f32], at: usize, frames: usize) -> f64 {
let (mut err, mut sig) = (0f64, 0f64);
for i in 0..frames * 2 {
let x = whole[at * 2 + i] as f64;
err += (after[i] as f64 - x).powi(2);
sig += x * x;
}
10.0 * (err / sig).log10()
}
fn assert_seeks_match_a_full_decode(ext: &str, rate: u32, codec: &[&str], frames: usize) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("n.{ext}"));
let ok = Command::new("ffmpeg")
.args(["-y", "-v", "error", "-f", "lavfi", "-i"])
.arg(format!("anoisesrc=d=3:c=white:r={rate}:seed=7"))
.args(["-ac", "2"])
.args(codec)
.arg(&path)
.status()
.is_ok_and(|s| s.success());
if !ok {
return skip(
"PLAYR_REQUIRE_FFMPEG",
&format!("this ffmpeg cannot encode {ext}"),
);
}
let whole = samples(&mut AudioStream::open(&path).unwrap());
for secs in [0.5, 1.7] {
let mut s = AudioStream::open(&path).unwrap();
s.seek(std::time::Duration::from_secs_f64(secs)).unwrap();
let after = samples(&mut s);
let at = (secs * rate as f64).round() as usize;
let db = error_db(&after, &whole, at, frames);
assert!(db < -60.0, "{ext}: seek to {secs}s is off by {db:.1} dB");
}
}
#[cfg(feature = "opus")]
#[test]
fn an_opus_seek_matches_a_full_decode() {
if !have_ffmpeg() {
return;
}
assert_seeks_match_a_full_decode("opus", 48000, &["-c:a", "libopus", "-b:a", "256k"], 4800);
}
#[test]
fn an_aac_seek_matches_a_full_decode() {
if !have_ffmpeg() {
return;
}
assert_seeks_match_a_full_decode("m4a", 44100, &["-c:a", "aac", "-b:a", "256k"], 4410);
}