188 lines
7.5 KiB
Rust
188 lines
7.5 KiB
Rust
//! Ask the REAL decoder what it can open.
|
|
//!
|
|
//! The app carries several hand-maintained lists of "audio extensions", and a
|
|
//! list is a claim, not a capability — the only thing that settles whether a
|
|
//! file plays is building the same decoder the player builds.
|
|
//!
|
|
//! cargo run -p player --example probe_formats -- <file-or-folder> [more...]
|
|
//!
|
|
//! A folder is walked and one file per extension is probed, which is what makes
|
|
//! this cheap to point at a whole library.
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
if args.is_empty() {
|
|
eprintln!("usage: probe_formats <file-or-folder>...");
|
|
std::process::exit(2);
|
|
}
|
|
|
|
// One representative per extension: the question is "can this FORMAT be
|
|
// decoded", and a thousand MP3s answer it no better than one.
|
|
let mut sample: BTreeMap<String, PathBuf> = BTreeMap::new();
|
|
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
|
for a in &args {
|
|
collect(Path::new(a), &mut sample, &mut counts, 0);
|
|
}
|
|
|
|
println!("{:<8} {:>7} {:<9} {}", "EXT", "FILES", "VERDICT", "DETAIL");
|
|
for (ext, path) in &sample {
|
|
let (verdict, detail) = probe(path);
|
|
println!(
|
|
"{:<8} {:>7} {:<9} {}",
|
|
ext,
|
|
counts.get(ext).copied().unwrap_or(0),
|
|
verdict,
|
|
detail
|
|
);
|
|
}
|
|
}
|
|
|
|
fn collect(
|
|
p: &Path,
|
|
sample: &mut BTreeMap<String, PathBuf>,
|
|
counts: &mut BTreeMap<String, usize>,
|
|
depth: u8,
|
|
) {
|
|
if depth > 8 {
|
|
return;
|
|
}
|
|
if p.is_dir() {
|
|
let Ok(entries) = std::fs::read_dir(p) else { return };
|
|
for e in entries.flatten() {
|
|
collect(&e.path(), sample, counts, depth + 1);
|
|
}
|
|
return;
|
|
}
|
|
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { return };
|
|
let ext = ext.to_ascii_lowercase();
|
|
// Anything that is plausibly audio, INCLUDING formats no list mentions —
|
|
// the point is to discover the gap, not to confirm the lists.
|
|
const PLAUSIBLE: &[&str] = &[
|
|
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "aac", "alac", "ogg", "oga", "opus",
|
|
"wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm", "wma", "ape", "wv", "mpc",
|
|
"tta", "shn", "dsf", "dff", "ra", "au", "amr", "3gp", "spx",
|
|
];
|
|
if !PLAUSIBLE.contains(&ext.as_str()) {
|
|
return;
|
|
}
|
|
*counts.entry(ext.clone()).or_insert(0) += 1;
|
|
sample.entry(ext).or_insert_with(|| p.to_path_buf());
|
|
}
|
|
|
|
/// Build exactly what `player` builds for a local file.
|
|
fn probe(path: &Path) -> (&'static str, String) {
|
|
use rodio::Decoder;
|
|
let file = match std::fs::File::open(path) {
|
|
Ok(f) => f,
|
|
Err(e) => return ("ERROR", format!("open failed: {e}")),
|
|
};
|
|
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
|
let reader = std::io::BufReader::new(file);
|
|
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
|
if len > 0 {
|
|
builder = builder.with_byte_len(len);
|
|
}
|
|
// What did symphonia actually RECOGNISE? A verdict of "plays" is worth
|
|
// little if the container was mistaken for something else, so name the
|
|
// format and codec rather than inferring capability from the extension.
|
|
let detected = detect(path).unwrap_or_else(|| "?".into());
|
|
match builder.build() {
|
|
Ok(d) => {
|
|
// Opening is not playing. A container can parse while its codec is
|
|
// missing, and a truncated decode looks like success if you only
|
|
// pull a few thousand samples — so decode the WHOLE thing and check
|
|
// the duration against the file. A 30 MB lossless track that yields
|
|
// two seconds of audio has not been decoded, it has been guessed at.
|
|
use rodio::Source as _;
|
|
let rate = d.sample_rate();
|
|
let ch = d.channels();
|
|
let mut src = d;
|
|
// Amplitude tells apart "decoded" from "mis-identified". Compressed
|
|
// bytes read as raw PCM are full-scale noise: near-100% of samples
|
|
// land loud, where real music spends most of its time quiet.
|
|
let (mut n, mut sumsq, mut hot, mut bad) = (0u64, 0f64, 0u64, 0u64);
|
|
for s in src.by_ref() {
|
|
let v = s as f64;
|
|
// A non-finite sample is proof on its own: no decoder emits
|
|
// NaN from real audio. It comes from compressed bytes being
|
|
// reinterpreted as floats, and it poisons every mixer and
|
|
// normaliser stage downstream of here.
|
|
if !v.is_finite() {
|
|
bad += 1;
|
|
n += 1;
|
|
continue;
|
|
}
|
|
sumsq += v * v;
|
|
if v.abs() > 0.5 {
|
|
hot += 1;
|
|
}
|
|
n += 1;
|
|
}
|
|
let rms = if n > 0 { (sumsq / n as f64).sqrt() } else { 0.0 };
|
|
let hot_pct = if n > 0 { hot as f64 * 100.0 / n as f64 } else { 0.0 };
|
|
let frames = n as f64 / (ch.max(1) as f64);
|
|
let secs = frames / rate.max(1) as f64;
|
|
let mb = len as f64 / 1_048_576.0;
|
|
if secs < 1.0 {
|
|
return ("NO AUDIO", format!("{rate} Hz, {ch}ch — {secs:.2}s from {mb:.1} MB"));
|
|
}
|
|
// Even the densest lossless runs well under 10 MB per second.
|
|
if mb / secs > 10.0 {
|
|
return (
|
|
"PARTIAL",
|
|
format!("{rate} Hz, {ch}ch — only {secs:.1}s from {mb:.1} MB"),
|
|
);
|
|
}
|
|
// Half the samples above half scale is not music. This is the
|
|
// WavPack case: a RIFF header inside the wrapper makes symphonia
|
|
// read the compressed body as PCM, so it "succeeds" into noise —
|
|
// which is worse than refusing, because nothing reports an error.
|
|
if bad > 0 {
|
|
return (
|
|
"GARBAGE",
|
|
format!(
|
|
"{detected} mis-identified — {} of {n} samples are NaN/Inf",
|
|
bad
|
|
),
|
|
);
|
|
}
|
|
if hot_pct > 40.0 && rms > 0.35 {
|
|
return (
|
|
"NOISE",
|
|
format!("{detected} mis-identified — rms {rms:.2}, {hot_pct:.0}% of samples above half scale"),
|
|
);
|
|
}
|
|
(
|
|
"PLAYS",
|
|
format!("{detected} {rate} Hz, {ch}ch, {secs:.0}s, rms {rms:.2}"),
|
|
)
|
|
}
|
|
Err(e) => ("CANNOT", format!("{detected} {e}")),
|
|
}
|
|
}
|
|
|
|
/// The codec symphonia identifies, straight from the probe — the ground truth
|
|
/// behind a "plays" verdict.
|
|
fn detect(path: &Path) -> Option<String> {
|
|
use symphonia::core::io::MediaSourceStream;
|
|
use symphonia::core::probe::Hint;
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
let mut hint = Hint::new();
|
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
|
hint.with_extension(ext);
|
|
}
|
|
let probed = symphonia::default::get_probe()
|
|
.format(&hint, mss, &Default::default(), &Default::default())
|
|
.ok()?;
|
|
let track = probed.format.default_track()?;
|
|
let codec = track.codec_params.codec;
|
|
let name = symphonia::default::get_codecs()
|
|
.get_codec(codec)
|
|
.map(|d| d.short_name.to_string())
|
|
.unwrap_or_else(|| format!("codec:{codec:?}"));
|
|
Some(name)
|
|
}
|