forked from Keelrleek/SoulStream
playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -0,0 +1,166 @@
|
||||
//! 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")));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user