Files
SoulStream/crates/audio-formats/src/lib.rs
T

167 lines
6.4 KiB
Rust

//! What counts as audio, and what the player can actually decode.
//!
//! These were seven separate hand-kept lists — the library scanner, the share
//! index, the row pruner, the loudness pass, and three in the frontend — and
//! they disagreed. The scanner indexed `.aiff`, the pruner did not recognise it
//! and DELETED the rows on the next startup. The share index knew `.ape`, the
//! scanner did not. Every list named `.opus`, which nothing here can decode.
//!
//! One table now, with a distinction the old lists could not express:
//!
//! - [`PLAYABLE`] — the decoder can turn this into sound. Derived from the
//! codecs symphonia is actually built with (`all-codecs` + `all-formats`:
//! aac, adpcm, alac, flac, mp1/2/3, pcm, vorbis; caf, isomp4, mkv, ogg,
//! aiff, wav) and verified against real files by
//! `cargo run -p player --example probe_formats`.
//! - [`RECOGNISED`] — audio we can index, share, download and show, but cannot
//! play. Naming these is the point: a file the app can see and refuses to
//! play with a clear reason is honest, whereas leaving it out of the tables
//! makes it invisible, and claiming it plays makes the player fail at the
//! moment the user presses play.
//!
//! Adding a codec means adding a decoder, not editing a list.
/// Extensions the player can decode. Verified, not assumed.
pub const PLAYABLE: &[&str] = &[
// FLAC
"flac",
// MPEG audio — the bundle provides layers I, II and III
"mp3", "mp2", "mp1",
// ISO/MP4 container: AAC and ALAC live here
"m4a", "m4b", "mp4", "m4r",
// Raw AAC (ADTS)
"aac",
// Ogg container, Vorbis inside
"ogg", "oga",
// RIFF: PCM and ADPCM
"wav", "wave",
// AIFF / AIFF-C, also via the RIFF reader
"aiff", "aif", "aifc",
// Core Audio Format
"caf",
// Matroska — FLAC/Vorbis/AAC in a different wrapper
"mka", "webm",
];
/// Audio we recognise but cannot decode. Indexed and shared; never played.
///
/// `wv` earns its place here more than any of them. A WavPack file that
/// carries the original RIFF header in its wrapper is probed as raw PCM, so
/// the decoder does not fail — it reads compressed bytes as samples and emits
/// NaN into the mixer. Refusing it by extension is the only thing that stops
/// that, because the decoder itself reports success.
pub const RECOGNISED: &[&str] = &[
"opus", // no Opus decoder is built in
"wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
];
/// Is this an audio file at all — playable or merely recognised?
///
/// The scanner, the share index and the row pruner must all answer this the
/// same way, or a file is indexed by one and deleted by another.
pub fn is_audio_ext(ext: &str) -> bool {
let e = ext.to_ascii_lowercase();
PLAYABLE.contains(&e.as_str()) || RECOGNISED.contains(&e.as_str())
}
/// Can the player turn this into sound?
pub fn is_playable_ext(ext: &str) -> bool {
PLAYABLE.contains(&ext.to_ascii_lowercase().as_str())
}
/// Why this file will not play, in words that can go on screen.
pub fn unplayable_reason(ext: &str) -> Option<&'static str> {
match ext.to_ascii_lowercase().as_str() {
"opus" => Some("Opus isn't supported yet — this build has no Opus decoder"),
"wv" => Some("WavPack isn't supported yet"),
"ape" => Some("Monkey's Audio (APE) isn't supported yet"),
"wma" => Some("Windows Media Audio isn't supported yet"),
"mpc" => Some("Musepack isn't supported yet"),
"tta" => Some("True Audio isn't supported yet"),
"shn" => Some("Shorten isn't supported yet"),
"dsf" | "dff" => Some("DSD isn't supported yet"),
e if RECOGNISED.contains(&e) => Some("this format isn't supported yet"),
_ => None,
}
}
fn ext_of(path: &std::path::Path) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
}
pub fn is_audio_path(path: &std::path::Path) -> bool {
ext_of(path).is_some_and(|e| is_audio_ext(&e))
}
pub fn is_playable_path(path: &std::path::Path) -> bool {
ext_of(path).is_some_and(|e| is_playable_ext(&e))
}
/// Why this path will not play. `None` means "go ahead and try".
pub fn unplayable_path_reason(path: &std::path::Path) -> Option<&'static str> {
unplayable_reason(&ext_of(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
/// The bug the single table exists to prevent: the scanner indexed a
/// format the pruner did not recognise, so importing an AIFF album put it
/// in the library and the next startup silently deleted it.
#[test]
fn everything_indexed_survives_the_pruner() {
for ext in PLAYABLE.iter().chain(RECOGNISED.iter()) {
assert!(is_audio_ext(ext), "{ext} indexed but not recognised as audio");
}
// The extensions that were actually in the divergence.
for ext in ["aiff", "aif", "ape", "wv", "opus"] {
assert!(is_audio_ext(ext), "{ext} must be recognised");
}
}
/// A format cannot be both — every entry has one answer.
#[test]
fn no_extension_is_in_both_tables() {
for e in PLAYABLE {
assert!(!RECOGNISED.contains(e), "{e} is in both tables");
}
}
/// Recognised-but-not-playable must carry a reason, or the UI has nothing
/// to say when it refuses.
#[test]
fn every_unplayable_format_can_explain_itself() {
for e in RECOGNISED {
assert!(unplayable_reason(e).is_some(), "{e} has no reason");
}
for e in PLAYABLE {
assert!(unplayable_reason(e).is_none(), "{e} plays but claims it cannot");
}
}
/// WavPack is the dangerous one: the decoder does NOT fail on it, so only
/// an extension check keeps NaN out of the mixer.
#[test]
fn wavpack_is_refused_before_the_decoder_sees_it() {
assert!(!is_playable_ext("wv"));
assert!(is_audio_ext("wv"));
assert_eq!(
unplayable_path_reason(Path::new("C:\\m\\a.WV")),
Some("WavPack isn't supported yet"),
"case must not decide whether audio plays"
);
}
#[test]
fn case_and_paths() {
assert!(is_playable_ext("FLAC"));
assert!(is_playable_path(Path::new("C:\\m\\01 - Song.Mp3")));
assert!(!is_audio_path(Path::new("C:\\m\\cover.jpg")));
assert!(!is_audio_path(Path::new("C:\\m\\noext")));
}
}