From eefade0a91673a41a6983397a7536727d1beb476 Mon Sep 17 00:00:00 2001 From: Polochon_street Date: Mon, 1 Apr 2024 21:25:47 +0200 Subject: [PATCH] Continue the optional ffmpeg feature --- .github/workflows/rust.yml | 3 + CHANGELOG.md | 6 + Cargo.toml | 1 + README.md | 12 +- TODO.md | 1 + ci_check.sh | 2 +- examples/analyze.rs | 5 +- examples/distance.rs | 8 +- examples/library.rs | 7 +- examples/library_extra_info.rs | 7 +- examples/playlist.rs | 8 +- src/chroma.rs | 22 +- src/cue.rs | 29 +- src/lib.rs | 221 +------- src/library.rs | 147 ++++-- src/misc.rs | 6 +- src/song.rs | 918 --------------------------------- src/song/decoder.rs | 769 +++++++++++++++++++++++++++ src/song/mod.rs | 395 ++++++++++++++ src/temporal.rs | 8 +- src/timbral.rs | 18 +- src/utils.rs | 12 +- 22 files changed, 1372 insertions(+), 1233 deletions(-) delete mode 100644 src/song.rs create mode 100644 src/song/decoder.rs create mode 100644 src/song/mod.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0de5402..aa1dcad 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -40,6 +40,9 @@ jobs: run: cargo +nightly-2024-01-16 bench --verbose --features=bench --no-run - name: Build examples run: cargo build --examples --verbose --features=serde,library + - name: Build without ffmpeg + run: cargo build --verbose --no-default-features + build-test-lint-windows: name: Windows - build, test and lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 030e222..4a6475d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ * Remove circular dependency between playlist and song by removing distances from the `Song` struct (Thanks @SimonTeixidor!) +## bliss 0.7.0 +* Decoupled bliss from ffmpeg, by making a default but optional feature. + Existing code will need to be updated by replacing `Song::from_path` by + `song::decoder::bliss_ffmpeg::FFmpeg::song_from_path`, and the other + corresponding functions. + ## bliss 0.6.11 * Bump rust-ffmpeg to 6.1.1 to fix build for raspberry pis. diff --git a/Cargo.toml b/Cargo.toml index 7ee6f5f..8107592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ default = ["ffmpeg", "aubio-static"] # 99% of cases, disable it at your own risk! It would be useful if you # want to implement the decoding of the tracks yourself and just feed # them to bliss, so you don't depend on ffmpeg. +# TODO make ffmpeg a test-dep ffmpeg = ["dep:ffmpeg-next", "dep:ffmpeg-sys-next"] aubio-static = ["bliss-audio-aubio-rs/static"] # Build ffmpeg instead of using the host's. diff --git a/README.md b/README.md index 776c4e3..ff683dc 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,13 @@ Ready to use code examples: ### Compute the distance between two songs ``` -use bliss_audio::{BlissError, Song}; +use bliss_audio::decoder::bliss_ffmpeg::FFmpeg as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; +use bliss_audio::BlissError; fn main() -> Result<(), BlissError> { - let song1 = Song::from_path("/path/to/song1")?; - let song2 = Song::from_path("/path/to/song2")?; + let song1 = Decoder::from_path("/path/to/song1")?; + let song2 = Decoder::from_path("/path/to/song2")?; println!("Distance between song1 and song2 is {}", song1.distance(&song2)); Ok(()) @@ -61,6 +63,8 @@ fn main() -> Result<(), BlissError> { ### Make a playlist from a song ``` +use bliss_audio::decoder::bliss_ffmpeg::FFmpeg as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; use bliss_audio::{BlissError, Song}; use noisy_float::prelude::n32; @@ -68,7 +72,7 @@ fn main() -> Result<(), BlissError> { let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"]; let mut songs: Vec = paths .iter() - .map(|path| Song::from_path(path)) + .map(|path| Decoder::song_from_path(path)) .collect::, BlissError>>()?; // Assuming there is a first song diff --git a/TODO.md b/TODO.md index 2632c3b..facca22 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,7 @@ ask questions if you want to tackle an item. - Split out ffmpeg (see https://github.com/Polochon-street/bliss-rs/issues/63 and https://users.rust-lang.org/t/proper-way-to-abstract-a-third-party-provider/107076/8) - Make ffmpeg an optional (but default) feature + - The library trait must be Decoder-agnostic, and not depend on FFmpeg - Make the tests that don't need it not dependent of ffmpeg - Make the Song::from_path a trait that is by default implemented with the ffmpeg feature (so you can theoretically implement the library trait without ffmpeg) diff --git a/ci_check.sh b/ci_check.sh index 5b0011f..d251095 100755 --- a/ci_check.sh +++ b/ci_check.sh @@ -1 +1 @@ -cargo fmt -- --check && cargo clippy --examples --features=serde -- -D warnings && cargo build --verbose && cargo test --verbose && cargo test --verbose --examples && cargo +nightly-2023-02-16 bench --verbose --features=bench --no-run && cargo build --examples --verbose --features=serde +cargo fmt -- --check && cargo clippy --examples --features=serde -- -D warnings && cargo build --verbose && cargo test --verbose && cargo test --verbose --examples && cargo +nightly-2024-01-16 bench --verbose --features=bench --no-run && cargo build --examples --verbose --features=serde && cargo build --no-default-features diff --git a/examples/analyze.rs b/examples/analyze.rs index 2ffab60..7ae26a6 100644 --- a/examples/analyze.rs +++ b/examples/analyze.rs @@ -1,4 +1,5 @@ -use bliss_audio::Song; +use bliss_audio::decoder::ffmpeg::FFmpeg as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; use std::env; /** @@ -9,7 +10,7 @@ use std::env; fn main() { let args: Vec = env::args().skip(1).collect(); for path in &args { - match Song::from_path(path) { + match Decoder::song_from_path(path) { Ok(song) => println!("{}: {:?}", path, song.analysis), Err(e) => println!("{path}: {e}"), } diff --git a/examples/distance.rs b/examples/distance.rs index 870eacd..76e80cc 100644 --- a/examples/distance.rs +++ b/examples/distance.rs @@ -1,4 +1,6 @@ -use bliss_audio::{playlist::euclidean_distance, Song}; +use bliss_audio::decoder::ffmpeg::FFmpeg as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; +use bliss_audio::playlist::euclidean_distance; use std::env; /** @@ -13,8 +15,8 @@ fn main() -> Result<(), String> { let first_path = paths.next().ok_or("Help: ./distance ")?; let second_path = paths.next().ok_or("Help: ./distance ")?; - let song1 = Song::from_path(first_path).map_err(|x| x.to_string())?; - let song2 = Song::from_path(second_path).map_err(|x| x.to_string())?; + let song1 = Decoder::song_from_path(first_path).map_err(|x| x.to_string())?; + let song2 = Decoder::song_from_path(second_path).map_err(|x| x.to_string())?; println!( "d({:?}, {:?}) = {}", diff --git a/examples/library.rs b/examples/library.rs index 1cabb09..06e7ea5 100644 --- a/examples/library.rs +++ b/examples/library.rs @@ -4,6 +4,7 @@ /// For simplicity's sake, this example recursively gets songs from a folder /// to emulate an audio player library, without handling CUE files. use anyhow::Result; +use bliss_audio::decoder::ffmpeg::FFmpeg as Decoder; use bliss_audio::library::{AppConfigTrait, BaseConfig, Library}; use clap::{App, Arg, SubCommand}; use glob::glob; @@ -68,7 +69,7 @@ trait CustomLibrary { fn song_paths(&self) -> Result>; } -impl CustomLibrary for Library { +impl CustomLibrary for Library { /// Get all songs in the player library fn song_paths(&self) -> Result> { let music_path = &self.config.music_library_path; @@ -180,7 +181,7 @@ fn main() -> Result<()> { library.analyze_paths(library.song_paths()?, true)?; } else if let Some(sub_m) = matches.subcommand_matches("update") { let config_path = sub_m.value_of("config-path").map(PathBuf::from); - let mut library: Library = Library::from_config_path(config_path)?; + let mut library: Library = Library::from_config_path(config_path)?; library.update_library(library.song_paths()?, true, true)?; } else if let Some(sub_m) = matches.subcommand_matches("playlist") { let song_path = sub_m.value_of("SONG_PATH").unwrap(); @@ -189,7 +190,7 @@ fn main() -> Result<()> { .value_of("playlist-length") .unwrap_or("20") .parse::()?; - let library: Library = Library::from_config_path(config_path)?; + let library: Library = Library::from_config_path(config_path)?; let songs = library.playlist_from::<()>(&[song_path], playlist_length)?; let song_paths = songs .into_iter() diff --git a/examples/library_extra_info.rs b/examples/library_extra_info.rs index a0d3167..39facc7 100644 --- a/examples/library_extra_info.rs +++ b/examples/library_extra_info.rs @@ -5,6 +5,7 @@ /// For simplicity's sake, this example recursively gets songs from a folder /// to emulate an audio player library, without handling CUE files. use anyhow::Result; +use bliss_audio::decoder::ffmpeg::FFmpeg as Decoder; use bliss_audio::library::{AppConfigTrait, BaseConfig, Library}; use clap::{App, Arg, SubCommand}; use glob::glob; @@ -69,7 +70,7 @@ trait CustomLibrary { fn song_paths_info(&self) -> Result>; } -impl CustomLibrary for Library { +impl CustomLibrary for Library { /// Get all songs in the player library, along with the extra info /// one would want to store along with each song. fn song_paths_info(&self) -> Result> { @@ -198,7 +199,7 @@ fn main() -> Result<()> { library.analyze_paths_extra_info(library.song_paths_info()?, true)?; } else if let Some(sub_m) = matches.subcommand_matches("update") { let config_path = sub_m.value_of("config-path").map(PathBuf::from); - let mut library: Library = Library::from_config_path(config_path)?; + let mut library: Library = Library::from_config_path(config_path)?; library.update_library_extra_info(library.song_paths_info()?, true, true)?; } else if let Some(sub_m) = matches.subcommand_matches("playlist") { let song_path = sub_m.value_of("SONG_PATH").unwrap(); @@ -207,7 +208,7 @@ fn main() -> Result<()> { .value_of("playlist-length") .unwrap_or("20") .parse::()?; - let library: Library = Library::from_config_path(config_path)?; + let library: Library = Library::from_config_path(config_path)?; let songs = library.playlist_from::(&[song_path], playlist_length)?; let playlist = songs .into_iter() diff --git a/examples/playlist.rs b/examples/playlist.rs index c106937..e141783 100644 --- a/examples/playlist.rs +++ b/examples/playlist.rs @@ -1,6 +1,8 @@ use anyhow::Result; +use bliss_audio::decoder::ffmpeg::FFmpeg as Decoder; +use bliss_audio::decoder::Decoder as DecoderTrait; use bliss_audio::playlist::{closest_to_songs, dedup_playlist, euclidean_distance}; -use bliss_audio::{analyze_paths, Song}; +use bliss_audio::Song; use clap::{App, Arg}; use glob::glob; use std::env; @@ -56,14 +58,14 @@ fn main() -> Result<()> { .map(|x| x.to_string_lossy().to_string()) .collect::>(); - let song_iterator = analyze_paths( + let song_iterator = Decoder::analyze_paths( paths .iter() .filter(|p| !analyzed_paths.contains(&PathBuf::from(p))) .map(|p| p.to_owned()) .collect::>(), ); - let first_song = Song::from_path(file)?; + let first_song = Decoder::song_from_path(file)?; let mut analyzed_songs = vec![first_song.to_owned()]; for (path, result) in song_iterator { match result { diff --git a/src/chroma.rs b/src/chroma.rs index 37045ee..1ae2351 100644 --- a/src/chroma.rs +++ b/src/chroma.rs @@ -365,9 +365,13 @@ fn chroma_stft( mod test { use super::*; #[cfg(feature = "ffmpeg")] + use crate::song::decoder::ffmpeg::FFmpeg as Decoder; + #[cfg(feature = "ffmpeg")] + use crate::song::decoder::Decoder as DecoderTrait; + #[cfg(feature = "ffmpeg")] use crate::utils::stft; #[cfg(feature = "ffmpeg")] - use crate::{Song, SAMPLE_RATE}; + use crate::SAMPLE_RATE; use ndarray::{arr1, arr2, Array2}; use ndarray_npy::ReadNpyExt; use std::fs::File; @@ -439,7 +443,7 @@ mod test { #[test] #[cfg(feature = "ffmpeg")] fn test_chroma_desc() { - let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); + let song = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); chroma_desc.do_(&song.sample_array).unwrap(); let expected_values = vec![ @@ -462,7 +466,7 @@ mod test { #[test] #[cfg(feature = "ffmpeg")] fn test_chroma_stft_decode() { - let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")) + let signal = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")) .unwrap() .sample_array; let mut stft = stft(&signal, 8192, 2205); @@ -496,7 +500,7 @@ mod test { #[test] #[cfg(feature = "ffmpeg")] fn test_estimate_tuning_decode() { - let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")) + let signal = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")) .unwrap() .sample_array; let stft = stft(&signal, 8192, 2205); @@ -559,8 +563,10 @@ mod test { mod bench { extern crate test; use super::*; + use crate::song::decoder::ffmpeg::FFmpeg as Decoder; + use crate::song::decoder::Decoder as DecoderTrait; use crate::utils::stft; - use crate::{Song, SAMPLE_RATE}; + use crate::SAMPLE_RATE; use ndarray::{arr2, Array1, Array2}; use ndarray_npy::ReadNpyExt; use std::fs::File; @@ -614,7 +620,7 @@ mod bench { #[bench] #[cfg(feature = "ffmpeg")] fn bench_chroma_desc(b: &mut Bencher) { - let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); + let song = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); let signal = song.sample_array; b.iter(|| { @@ -626,7 +632,7 @@ mod bench { #[bench] #[cfg(feature = "ffmpeg")] fn bench_chroma_stft(b: &mut Bencher) { - let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); + let song = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); let signal = song.sample_array; b.iter(|| { @@ -638,7 +644,7 @@ mod bench { #[bench] #[cfg(feature = "ffmpeg")] fn bench_chroma_stft_decode(b: &mut Bencher) { - let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")) + let signal = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")) .unwrap() .sample_array; let mut stft = stft(&signal, 8192, 2205); diff --git a/src/cue.rs b/src/cue.rs index 63ced3b..6b1a889 100644 --- a/src/cue.rs +++ b/src/cue.rs @@ -7,16 +7,13 @@ //! and [CueInfo], which is a struct stored in [Song] to keep track of the CUE information //! the song was extracted from. -#[cfg(feature = "ffmpeg")] +use crate::song::decoder::Decoder as DecoderTrait; use crate::{Analysis, BlissError, BlissResult, Song, FEATURES_VERSION, SAMPLE_RATE}; use rcue::cue::{Cue, Track}; -#[cfg(feature = "ffmpeg")] use rcue::parser::parse_from_file; -#[cfg(feature = "ffmpeg")] use std::path::Path; -use std::path::PathBuf; -#[cfg(feature = "ffmpeg")] use std::time::Duration; +use std::{marker::PhantomData, path::PathBuf}; #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Default, Debug, PartialEq, Eq, Clone)] @@ -37,9 +34,10 @@ pub struct CueInfo { /// Use either [analyze_paths](crate::analyze_paths) with CUE files or /// [songs_from_path](BlissCue::songs_from_path) to return a list of [Song]s /// from CUE files. -pub struct BlissCue { +pub struct BlissCue { cue: Cue, cue_path: PathBuf, + decoder: PhantomData, } #[allow(missing_docs)] @@ -54,16 +52,15 @@ struct BlissCueFile { audio_file_path: PathBuf, } -impl BlissCue { +impl BlissCue { /// Analyze songs from a CUE file, extracting individual [Song] objects /// for each individual song. /// /// Each returned [Song] has a populated [cue_info](Song::cue_info) object, that can be /// be used to retrieve which CUE sheet was used to extract it, as well /// as the corresponding audio file. - #[cfg(feature = "ffmpeg")] pub fn songs_from_path>(path: P) -> BlissResult>> { - let cue = BlissCue::from_path(&path)?; + let cue: BlissCue = BlissCue::from_path(&path)?; let cue_files = cue.files(); let mut songs = Vec::new(); for cue_file in cue_files.into_iter() { @@ -84,7 +81,6 @@ impl BlissCue { } // Extract a BlissCue from a given path. - #[cfg(feature = "ffmpeg")] fn from_path>(path: P) -> BlissResult { let cue = parse_from_file(&path.as_ref().to_string_lossy(), false).map_err(|e| { BlissError::DecodingError(format!( @@ -93,14 +89,14 @@ impl BlissCue { e )) })?; - Ok(BlissCue { + Ok(Self { cue, cue_path: path.as_ref().to_owned(), + decoder: PhantomData, }) } // List all BlissCueFile from a BlissCue. - #[cfg(feature = "ffmpeg")] fn files(&self) -> Vec> { let mut cue_files = Vec::new(); for cue_file in self.cue.files.iter() { @@ -114,7 +110,7 @@ impl BlissCue { .iter() .find(|(c, _)| c == "GENRE") .map(|(_, v)| v.to_owned()); - let raw_song = Song::decode(Path::new(&audio_file_path)); + let raw_song = D::decode(Path::new(&audio_file_path)); if let Ok(song) = raw_song { let bliss_cue_file = BlissCueFile { sample_array: song.sample_array, @@ -134,7 +130,6 @@ impl BlissCue { } } -#[cfg(feature = "ffmpeg")] impl BlissCueFile { fn create_song( &self, @@ -211,12 +206,14 @@ mod tests { #[cfg(feature = "ffmpeg")] use super::*; #[cfg(feature = "ffmpeg")] + use crate::decoder::ffmpeg::FFmpeg; + #[cfg(feature = "ffmpeg")] use pretty_assertions::assert_eq; #[test] #[cfg(feature = "ffmpeg")] fn test_empty_cue() { - let songs = BlissCue::songs_from_path("data/empty.cue").unwrap(); + let songs = BlissCue::::songs_from_path("data/empty.cue").unwrap(); let error = songs[0].to_owned().unwrap_err(); assert_eq!( error, @@ -227,7 +224,7 @@ mod tests { #[test] #[cfg(feature = "ffmpeg")] fn test_cue_analysis() { - let songs = BlissCue::songs_from_path("data/testcue.cue").unwrap(); + let songs = BlissCue::::songs_from_path("data/testcue.cue").unwrap(); let expected = vec![ Ok(Song { path: Path::new("data/testcue.cue/CUE_TRACK001").to_path_buf(), diff --git a/src/lib.rs b/src/lib.rs index fcb63fc..bb406df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,23 +87,11 @@ mod utils; #[cfg(feature = "serde")] #[macro_use] extern crate serde; -#[cfg(feature = "ffmpeg")] -use crate::cue::BlissCue; -#[cfg(feature = "ffmpeg")] -use log::info; -#[cfg(feature = "ffmpeg")] -use std::num::NonZeroUsize; -#[cfg(feature = "ffmpeg")] -use std::path::{Path, PathBuf}; -#[cfg(feature = "ffmpeg")] -use std::sync::mpsc; -#[cfg(feature = "ffmpeg")] -use std::thread; + use thiserror::Error; -pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES}; +pub use song::{decoder, Analysis, AnalysisIndex, Song, NUMBER_FEATURES}; -#[cfg(feature = "ffmpeg")] const CHANNELS: u16 = 1; const SAMPLE_RATE: u32 = 22050; /// Stores the current version of bliss-rs' features. @@ -129,150 +117,9 @@ pub enum BlissError { /// bliss error type pub type BlissResult = Result; -/// Analyze songs in `paths`, and return the analyzed [Song] objects through an -/// [mpsc::IntoIter]. -/// -/// Returns an iterator, whose items are a tuple made of -/// the song path (to display to the user in case the analysis failed), -/// and a `Result`. -/// -/// # Note -/// -/// This function also works with CUE files - it finds the audio files -/// mentionned in the CUE sheet, and then runs the analysis on each song -/// defined by it, returning a proper [Song] object for each one of them. -/// -/// Make sure that you don't submit both the audio file along with the CUE -/// sheet if your library uses them, otherwise the audio file will be -/// analyzed as one, single, long song. For instance, with a CUE sheet named -/// `cue-file.cue` with the corresponding audio files `album-1.wav` and -/// `album-2.wav` defined in the CUE sheet, you would just pass `cue-file.cue` -/// to `analyze_paths`, and it will return [Song]s from both files, with -/// more information about which file it is extracted from in the -/// [cue info field](Song::cue_info). -/// -/// # Example: -/// ```no_run -/// use bliss_audio::{analyze_paths, BlissResult}; -/// -/// fn main() -> BlissResult<()> { -/// let paths = vec![String::from("/path/to/song1"), String::from("/path/to/song2")]; -/// for (path, result) in analyze_paths(&paths) { -/// match result { -/// Ok(song) => println!("Do something with analyzed song {} with title {:?}", song.path.display(), song.title), -/// Err(e) => println!("Song at {} could not be analyzed. Failed with: {}", path.display(), e), -/// } -/// } -/// Ok(()) -/// } -/// ``` -#[cfg(feature = "ffmpeg")] -pub fn analyze_paths, F: IntoIterator>( - paths: F, -) -> mpsc::IntoIter<(PathBuf, BlissResult)> { - let cores = thread::available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap()); - analyze_paths_with_cores(paths, cores) -} - -/// Analyze songs in `paths`, and return the analyzed [Song] objects through an -/// [mpsc::IntoIter]. `number_cores` sets the number of cores the analysis -/// will use, capped by your system's capacity. Most of the time, you want to -/// use the simpler `analyze_paths` functions, which autodetects the number -/// of cores in your system. -/// -/// Return an iterator, whose items are a tuple made of -/// the song path (to display to the user in case the analysis failed), -/// and a `Result`. -/// -/// # Note -/// -/// This function also works with CUE files - it finds the audio files -/// mentionned in the CUE sheet, and then runs the analysis on each song -/// defined by it, returning a proper [Song] object for each one of them. -/// -/// Make sure that you don't submit both the audio file along with the CUE -/// sheet if your library uses them, otherwise the audio file will be -/// analyzed as one, single, long song. For instance, with a CUE sheet named -/// `cue-file.cue` with the corresponding audio files `album-1.wav` and -/// `album-2.wav` defined in the CUE sheet, you would just pass `cue-file.cue` -/// to `analyze_paths`, and it will return [Song]s from both files, with -/// more information about which file it is extracted from in the -/// [cue info field](Song::cue_info). -/// -/// # Example: -/// ```no_run -/// use bliss_audio::{analyze_paths, BlissResult}; -/// -/// fn main() -> BlissResult<()> { -/// let paths = vec![String::from("/path/to/song1"), String::from("/path/to/song2")]; -/// for (path, result) in analyze_paths(&paths) { -/// match result { -/// Ok(song) => println!("Do something with analyzed song {} with title {:?}", song.path.display(), song.title), -/// Err(e) => println!("Song at {} could not be analyzed. Failed with: {}", path.display(), e), -/// } -/// } -/// Ok(()) -/// } -/// ``` -#[cfg(feature = "ffmpeg")] -pub fn analyze_paths_with_cores, F: IntoIterator>( - paths: F, - number_cores: NonZeroUsize, -) -> mpsc::IntoIter<(PathBuf, BlissResult)> { - let mut cores = thread::available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap()); - if cores > number_cores { - cores = number_cores; - } - let paths: Vec = paths.into_iter().map(|p| p.into()).collect(); - #[allow(clippy::type_complexity)] - let (tx, rx): ( - mpsc::Sender<(PathBuf, BlissResult)>, - mpsc::Receiver<(PathBuf, BlissResult)>, - ) = mpsc::channel(); - if paths.is_empty() { - return rx.into_iter(); - } - let mut handles = Vec::new(); - let mut chunk_length = paths.len() / cores; - if chunk_length == 0 { - chunk_length = paths.len(); - } - for chunk in paths.chunks(chunk_length) { - let tx_thread = tx.clone(); - let owned_chunk = chunk.to_owned(); - let child = thread::spawn(move || { - for path in owned_chunk { - info!("Analyzing file '{:?}'", path); - if let Some(extension) = Path::new(&path).extension() { - let extension = extension.to_string_lossy().to_lowercase(); - if extension == "cue" { - match BlissCue::songs_from_path(&path) { - Ok(songs) => { - for song in songs { - tx_thread.send((path.to_owned(), song)).unwrap(); - } - } - Err(e) => tx_thread.send((path.to_owned(), Err(e))).unwrap(), - }; - continue; - } - } - let song = Song::from_path(&path); - tx_thread.send((path.to_owned(), song)).unwrap(); - } - }); - handles.push(child); - } - - rx.into_iter() -} - #[cfg(test)] mod tests { use super::*; - #[cfg(test)] - #[cfg(feature = "ffmpeg")] - use pretty_assertions::assert_eq; #[test] fn test_send_song() { @@ -285,68 +132,4 @@ mod tests { fn assert_sync() {} assert_sync::(); } - - #[test] - #[cfg(feature = "ffmpeg")] - fn test_analyze_paths() { - let paths = vec![ - "./data/s16_mono_22_5kHz.flac", - "./data/testcue.cue", - "./data/white_noise.mp3", - "definitely-not-existing.foo", - "not-existing.foo", - ]; - let mut results = analyze_paths(&paths) - .map(|x| match &x.1 { - Ok(s) => (true, s.path.to_owned(), None), - Err(e) => (false, x.0.to_owned(), Some(e.to_string())), - }) - .collect::>(); - results.sort(); - let expected_results = vec![ - ( - false, - PathBuf::from("./data/testcue.cue"), - Some(String::from( - "error happened while decoding file – while \ - opening format for file './data/not-existing.wav': \ - ffmpeg::Error(2: No such file or directory).", - )), - ), - ( - false, - PathBuf::from("definitely-not-existing.foo"), - Some(String::from( - "error happened while decoding file – while \ - opening format for file 'definitely-not-existing\ - .foo': ffmpeg::Error(2: No such file or directory).", - )), - ), - ( - false, - PathBuf::from("not-existing.foo"), - Some(String::from( - "error happened while decoding file – \ - while opening format for file 'not-existing.foo': \ - ffmpeg::Error(2: No such file or directory).", - )), - ), - (true, PathBuf::from("./data/s16_mono_22_5kHz.flac"), None), - (true, PathBuf::from("./data/testcue.cue/CUE_TRACK001"), None), - (true, PathBuf::from("./data/testcue.cue/CUE_TRACK002"), None), - (true, PathBuf::from("./data/testcue.cue/CUE_TRACK003"), None), - (true, PathBuf::from("./data/white_noise.mp3"), None), - ]; - - assert_eq!(results, expected_results); - - let mut results = analyze_paths_with_cores(&paths, NonZeroUsize::new(1).unwrap()) - .map(|x| match &x.1 { - Ok(s) => (true, s.path.to_owned(), None), - Err(e) => (false, x.0.to_owned(), Some(e.to_string())), - }) - .collect::>(); - results.sort(); - assert_eq!(results, expected_results); - } } diff --git a/src/library.rs b/src/library.rs index bd644e3..942ed3c 100644 --- a/src/library.rs +++ b/src/library.rs @@ -65,20 +65,26 @@ //! } //! } //! ``` +//! //! * The second part is the actual [Library] structure, that makes the //! bulk of the plug-in. To initialize a library once with a given config, -//! you can do (here with a base configuration): -//! ```no_run -//! use anyhow::{Error, Result}; -//! use bliss_audio::library::{BaseConfig, Library}; -//! use std::path::PathBuf; -//! -//! let config_path = Some(PathBuf::from("path/to/config/config.json")); -//! let database_path = Some(PathBuf::from("path/to/config/bliss.db")); -//! let config = BaseConfig::new(config_path, database_path, None)?; -//! let library: Library = Library::new(config)?; -//! # Ok::<(), Error>(()) -//! ``` +//! you can do (here with a base configuration, requiring ffmpeg): +#![cfg_attr( + feature = "ffmpeg", + doc = r##" +```no_run + use anyhow::{Error, Result}; + use bliss_audio::library::{BaseConfig, Library}; + use bliss_audio::decoder::ffmpeg::FFmpeg; + use std::path::PathBuf; + + let config_path = Some(PathBuf::from("path/to/config/config.json")); + let database_path = Some(PathBuf::from("path/to/config/bliss.db")); + let config = BaseConfig::new(config_path, database_path, None)?; + let library: Library = Library::new(config)?; + # Ok::<(), Error>(()) +```"## +)] //! Once this is done, you can simply load the library by doing //! `Library::from_config_path(config_path);` //! * The third part is using the [Library] itself: it provides you with @@ -108,7 +114,6 @@ //! "real-life" example, the //! [blissify](https://github.com/Polochon-street/blissify-rs)'s code is using //! [Library] to implement bliss for a MPD player. -use crate::analyze_paths_with_cores; use crate::cue::CueInfo; use crate::playlist::closest_album_to_group; use crate::playlist::closest_to_songs; @@ -132,12 +137,14 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::fs::create_dir_all; +use std::marker::PhantomData; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; use std::thread; +use crate::decoder::Decoder as DecoderTrait; use crate::Song; use crate::FEATURES_VERSION; use crate::{Analysis, BlissError, NUMBER_FEATURES}; @@ -305,12 +312,13 @@ impl AppConfigTrait for BaseConfig { /// Provide it either the `BaseConfig`, or a `Config` extending /// `BaseConfig`. /// TODO code example -pub struct Library { +pub struct Library { /// The configuration struct, containing both information /// from `BaseConfig` as well as user-defined values. pub config: Config, /// SQL connection to the database. pub sqlite_conn: Arc>, + decoder: PhantomData, } /// Struct holding both a Bliss song, as well as any extra info @@ -355,7 +363,7 @@ impl AsRef for LibrarySong { // TODO should it really use anyhow errors? // TODO make sure that the path to string is consistent // TODO make a function that returns a list of all analyzed songs in the db -impl Library { +impl Library { /// Create a new [Library] object from the given Config struct that /// implements the [AppConfigTrait]. /// writing the configuration to the file given in @@ -419,9 +427,10 @@ impl Library { [], )?; config.write()?; - Ok(Library { + Ok(Self { config, sqlite_conn: Arc::new(Mutex::new(sqlite_conn)), + decoder: PhantomData, }) } @@ -436,9 +445,10 @@ impl Library { let data = fs::read_to_string(config_path)?; let config = Config::deserialize_config(&data)?; let sqlite_conn = Connection::open(&config.base_config().database_path)?; - let mut library = Library { + let mut library = Self { config, sqlite_conn: Arc::new(Mutex::new(sqlite_conn)), + decoder: PhantomData, }; if !library.version_sanity_check()? { warn!( @@ -811,7 +821,7 @@ impl Library { .collect(); let mut cue_extra_info: HashMap = HashMap::new(); - let results = analyze_paths_with_cores( + let results = D::analyze_paths_with_cores( paths_extra_info.keys(), self.config.base_config().number_cores, ); @@ -1314,13 +1324,26 @@ fn data_local_dir() -> Option { // TODO test with invalid UTF-8 mod test { use super::*; - use crate::{Analysis, NUMBER_FEATURES}; + use crate::{decoder::PreAnalyzedSong, Analysis, NUMBER_FEATURES}; use ndarray::Array1; use pretty_assertions::assert_eq; use serde::{de::DeserializeOwned, Deserialize}; use std::{convert::TryInto, fmt::Debug, sync::MutexGuard, time::Duration}; use tempdir::TempDir; + #[cfg(feature = "ffmpeg")] + use crate::song::decoder::ffmpeg::FFmpeg as Decoder; + use crate::song::decoder::Decoder as DecoderTrait; + + struct DummyDecoder; + + // Here to test an ffmpeg-agnostic library + impl DecoderTrait for DummyDecoder { + fn decode(_: &Path) -> crate::BlissResult { + Ok(PreAnalyzedSong::default()) + } + } + #[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)] struct ExtraInfo { ignore: bool, @@ -1354,8 +1377,9 @@ mod test { // // Setup a test library made of 3 analyzed songs, with every field being different, // as well as an unanalyzed song and a song analyzed with a previous version. + #[cfg(feature = "ffmpeg")] fn setup_test_library() -> ( - Library, + Library, TempDir, ( LibrarySong, @@ -1370,9 +1394,12 @@ mod test { let config_dir = TempDir::new("coucou").unwrap(); let config_file = config_dir.path().join("config.json"); let database_file = config_dir.path().join("bliss.db"); - let library = - Library::::new_from_base(Some(config_file), Some(database_file), None) - .unwrap(); + let library = Library::::new_from_base( + Some(config_file), + Some(database_file), + None, + ) + .unwrap(); let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) .map(|x| x as f32 / 10.) @@ -1890,6 +1917,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_playlist_song_not_existing() { let (library, _temp_dir, _) = setup_test_library(); assert!(library @@ -1898,6 +1926,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_playlist_crop() { let (library, _temp_dir, _) = setup_test_library(); let songs: Vec> = @@ -1906,6 +1935,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_simple_playlist() { let (library, _temp_dir, _) = setup_test_library(); let songs: Vec> = @@ -1928,6 +1958,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_custom_playlist_distance() { let (library, _temp_dir, _) = setup_test_library(); let songs: Vec> = library @@ -1965,6 +1996,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_custom_playlist_sort() { let (library, _temp_dir, _) = setup_test_library(); let songs: Vec> = library @@ -1994,6 +2026,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_custom_playlist_dedup() { let (library, _temp_dir, _) = setup_test_library(); @@ -2045,6 +2078,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_album_playlist() { let (library, _temp_dir, _) = setup_test_library(); let album: Vec> = library @@ -2072,6 +2106,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_album_playlist_crop() { let (library, _temp_dir, _) = setup_test_library(); let album: Vec> = library @@ -2094,6 +2129,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_songs_from_album() { let (library, _temp_dir, _) = setup_test_library(); let album: Vec> = library.songs_from_album("An Album1001").unwrap(); @@ -2110,6 +2146,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_songs_from_album_proper_features_version() { let (library, _temp_dir, _) = setup_test_library(); let album: Vec> = library.songs_from_album("An Album1001").unwrap(); @@ -2126,6 +2163,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_songs_from_album_not_existing() { let (library, _temp_dir, _) = setup_test_library(); assert!(library @@ -2134,6 +2172,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_delete_path_non_existing() { let (mut library, _temp_dir, _) = setup_test_library(); { @@ -2159,6 +2198,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_delete_path() { let (mut library, _temp_dir, _) = setup_test_library(); { @@ -2205,6 +2245,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_delete_paths() { let (mut library, _temp_dir, _) = setup_test_library(); { @@ -2262,18 +2303,21 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_delete_paths_empty() { let (mut library, _temp_dir, _) = setup_test_library(); assert_eq!(library.delete_paths::([]).unwrap(), 0); } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_delete_paths_non_existing() { let (mut library, _temp_dir, _) = setup_test_library(); assert_eq!(library.delete_paths(["not-existing"]).unwrap(), 0); } #[test] + #[cfg(feature = "ffmpeg")] fn test_analyze_paths_cue() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2323,6 +2367,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_analyze_paths() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2344,7 +2389,7 @@ mod test { .iter() .zip(vec![(), ()].into_iter()) .map(|(path, expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2356,6 +2401,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_analyze_paths_convert_extra_info() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2399,7 +2445,7 @@ mod test { .into_iter(), ) .map(|((path, _extra_info), expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2411,6 +2457,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_analyze_paths_extra_info() { let (mut library, _temp_dir, _) = setup_test_library(); @@ -2463,7 +2510,7 @@ mod test { .into_iter(), ) .map(|((path, _extra_info), expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2471,6 +2518,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] // Check that a song already in the database is not // analyzed again on updates. fn test_update_skip_analyzed() { @@ -2498,7 +2546,7 @@ mod test { }; let expected_song = { LibrarySong { - bliss_song: Song::from_path("./data/s16_mono_22_5kHz.flac").unwrap(), + bliss_song: Decoder::song_from_path("./data/s16_mono_22_5kHz.flac").unwrap(), extra_info: ExtraInfo { ignore: true, metadata_bliss_does_not_have: String::from("coucou"), @@ -2528,6 +2576,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_update_library_override_old_features() { let (mut library, _temp_dir, _) = setup_test_library(); let path: String = "./data/s16_stereo_22_5kHz.flac".into(); @@ -2579,11 +2628,12 @@ mod test { .try_into() .unwrap(), }; - let expected_analysis_vector = Song::from_path(path).unwrap().analysis; + let expected_analysis_vector = Decoder::song_from_path(path).unwrap().analysis; assert_eq!(analysis_vector, expected_analysis_vector); } #[test] + #[cfg(feature = "ffmpeg")] fn test_update_library() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2618,7 +2668,7 @@ mod test { .iter() .zip(vec![(), ()].into_iter()) .map(|(path, expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2636,6 +2686,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_update_extra_info() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2666,7 +2717,7 @@ mod test { .iter() .zip(vec![true, false].into_iter()) .map(|((path, _extra_info), expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2683,6 +2734,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_update_convert_extra_info() { let (mut library, _temp_dir, _) = setup_test_library(); library.config.base_config_mut().features_version = 0; @@ -2733,7 +2785,7 @@ mod test { .into_iter(), ) .map(|((path, _extra_info), expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2758,6 +2810,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] // TODO maybe we can merge / DRY this and the function ⬆ fn test_update_convert_extra_info_do_not_delete() { let (mut library, _temp_dir, _) = setup_test_library(); @@ -2811,7 +2864,7 @@ mod test { .into_iter(), ) .map(|((path, _extra_info), expected_extra_info)| LibrarySong { - bliss_song: Song::from_path(path).unwrap(), + bliss_song: Decoder::song_from_path(path).unwrap(), extra_info: expected_extra_info, }) .collect::>>(); @@ -2833,6 +2886,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_song_from_path() { let (library, _temp_dir, _) = setup_test_library(); let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) @@ -2871,6 +2925,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_store_failed_song() { let (mut library, _temp_dir, _) = setup_test_library(); library @@ -2913,6 +2968,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_songs_from_library() { let (library, _temp_dir, expected_library_songs) = setup_test_library(); @@ -2933,6 +2989,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_songs_from_library_screwed_db() { let (library, _temp_dir, _) = setup_test_library(); { @@ -2960,6 +3017,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_song_from_path_not_analyzed() { let (library, _temp_dir, _) = setup_test_library(); let error = library.song_from_path::("/path/to/song4001"); @@ -2967,6 +3025,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_song_from_path_not_found() { let (library, _temp_dir, _) = setup_test_library(); let error = library.song_from_path::("/path/to/song4001"); @@ -2989,6 +3048,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_new_default_write() { let (library, _temp_dir, _) = setup_test_library(); let config_content = fs::read_to_string(&library.config.base_config().config_path) @@ -3008,6 +3068,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_new_create_database() { let (library, _temp_dir, _) = setup_test_library(); let sqlite_conn = Connection::open(&library.config.base_config().database_path).unwrap(); @@ -3041,6 +3102,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_store_song() { let (mut library, _temp_dir, _) = setup_test_library(); let song = _generate_basic_song(None); @@ -3055,6 +3117,7 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_extra_info() { let (mut library, _temp_dir, _) = setup_test_library(); let song = _generate_library_song(None); @@ -3068,7 +3131,10 @@ mod test { #[test] fn test_from_config_path_non_existing() { assert!( - Library::::from_config_path(Some(PathBuf::from("non-existing"))).is_err() + Library::::from_config_path(Some(PathBuf::from( + "non-existing" + ))) + .is_err() ); } @@ -3097,11 +3163,12 @@ mod test { // get the stored song. let song = _generate_library_song(None); { - let mut library = Library::new(config.to_owned()).unwrap(); + let mut library = Library::<_, DummyDecoder>::new(config.to_owned()).unwrap(); library.store_song(&song).unwrap(); } - let library: Library = Library::from_config_path(Some(config_file)).unwrap(); + let library: Library = + Library::from_config_path(Some(config_file)).unwrap(); let connection = library.sqlite_conn.lock().unwrap(); let returned_song = _library_song_from_database(connection, &song.bliss_song.path.to_string_lossy()); @@ -3139,12 +3206,14 @@ mod test { } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_sanity_check_fail() { let (mut library, _temp_dir, _) = setup_test_library(); assert!(!library.version_sanity_check().unwrap()); } #[test] + #[cfg(feature = "ffmpeg")] fn test_library_sanity_check_ok() { let (mut library, _temp_dir, _) = setup_test_library(); { @@ -3203,8 +3272,12 @@ mod test { assert!(!config_dir.is_dir()); let config_file = config_dir.join("config.json"); let database_file = config_dir.join("bliss.db"); - Library::::new_from_base(Some(config_file), Some(database_file), Some(nzus(1))) - .unwrap(); + Library::::new_from_base( + Some(config_file), + Some(database_file), + Some(nzus(1)), + ) + .unwrap(); assert!(config_dir.is_dir()); } } diff --git a/src/misc.rs b/src/misc.rs index d448016..8400213 100644 --- a/src/misc.rs +++ b/src/misc.rs @@ -64,14 +64,16 @@ impl Normalize for LoudnessDesc { mod tests { use super::*; #[cfg(feature = "ffmpeg")] - use crate::Song; + use crate::song::decoder::ffmpeg::FFmpeg as Decoder; + #[cfg(feature = "ffmpeg")] + use crate::song::decoder::Decoder as DecoderTrait; #[cfg(feature = "ffmpeg")] use std::path::Path; #[test] #[cfg(feature = "ffmpeg")] fn test_loudness() { - let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); + let song = Decoder::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap(); let mut loudness_desc = LoudnessDesc::default(); for chunk in song.sample_array.chunks_exact(LoudnessDesc::WINDOW_SIZE) { loudness_desc.do_(&chunk); diff --git a/src/song.rs b/src/song.rs deleted file mode 100644 index b1efb5a..0000000 --- a/src/song.rs +++ /dev/null @@ -1,918 +0,0 @@ -//! Song decoding / analysis module. -//! -//! Use decoding, and features-extraction functions from other modules -//! e.g. tempo features, spectral features, etc to build a Song and its -//! corresponding Analysis. -//! -//! For implementation of plug-ins for already existing audio players, -//! a look at Library is instead recommended. - -#[cfg(feature = "ffmpeg")] -extern crate ffmpeg_next as ffmpeg; -extern crate ndarray; - -use crate::chroma::ChromaDesc; -use crate::cue::CueInfo; -use crate::misc::LoudnessDesc; -use crate::temporal::BPMDesc; -use crate::timbral::{SpectralDesc, ZeroCrossingRateDesc}; -use crate::{BlissError, BlissResult, SAMPLE_RATE}; -#[cfg(feature = "ffmpeg")] -use crate::{CHANNELS, FEATURES_VERSION}; -#[cfg(feature = "ffmpeg")] -use ::log::warn; -use core::ops::Index; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::codec::threading::{Config, Type as ThreadingType}; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::channel_layout::ChannelLayout; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::error::Error; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::error::EINVAL; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::format::sample::{Sample, Type}; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::frame::audio::Audio; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::log; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::util::log::level::Level; -#[cfg(feature = "ffmpeg")] -use ffmpeg_next::{media, util}; -use ndarray::{arr1, Array1}; -use std::convert::TryInto; -use std::fmt; -#[cfg(feature = "ffmpeg")] -use std::path::Path; -use std::path::PathBuf; -#[cfg(feature = "ffmpeg")] -use std::sync::mpsc; -#[cfg(feature = "ffmpeg")] -use std::sync::mpsc::Receiver; -use std::thread; -use std::time::Duration; -use strum::{EnumCount, IntoEnumIterator}; -use strum_macros::{EnumCount, EnumIter}; - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Default, Debug, PartialEq, Clone)] -/// Simple object used to represent a Song, with its path, analysis, and -/// other metadata (artist, genre...) -pub struct Song { - /// Song's provided file path - pub path: PathBuf, - /// Song's artist, read from the metadata - pub artist: Option, - /// Song's title, read from the metadata - pub title: Option, - /// Song's album name, read from the metadata - pub album: Option, - /// Song's album's artist name, read from the metadata - pub album_artist: Option, - /// Song's tracked number, read from the metadata - /// TODO normalize this into an integer - pub track_number: Option, - /// Song's genre, read from the metadata (`""` if empty) - pub genre: Option, - /// bliss analysis results - pub analysis: Analysis, - /// The song's duration - pub duration: Duration, - /// Version of the features the song was analyzed with. - /// A simple integer that is bumped every time a breaking change - /// is introduced in the features. - pub features_version: u16, - /// Populated only if the song was extracted from a larger audio file, - /// through the use of a CUE sheet. - /// By default, such a song's path would be - /// `path/to/cue_file.wav/CUE_TRACK00`. Using this field, - /// you can change `song.path` to fit your needs. - pub cue_info: Option, -} - -impl AsRef for Song { - fn as_ref(&self) -> &Song { - self - } -} - -#[derive(Debug, EnumIter, EnumCount)] -/// Indexes different fields of an [Analysis](Song::analysis). -/// -#[cfg_attr( - feature = "ffmpeg", - doc = r##" -/// * Example: -/// ```no_run -/// use bliss_audio::{AnalysisIndex, BlissResult, Song}; -/// -/// fn main() -> BlissResult<()> { -/// let song = Song::from_path("path/to/song")?; -/// println!("{}", song.analysis[AnalysisIndex::Tempo]); -/// Ok(()) -/// } -/// ``` -"## -)] -/// Prints the tempo value of an analysis. -/// -/// Note that this should mostly be used for debugging / distance metric -/// customization purposes. -#[allow(missing_docs)] -pub enum AnalysisIndex { - Tempo, - Zcr, - MeanSpectralCentroid, - StdDeviationSpectralCentroid, - MeanSpectralRolloff, - StdDeviationSpectralRolloff, - MeanSpectralFlatness, - StdDeviationSpectralFlatness, - MeanLoudness, - StdDeviationLoudness, - Chroma1, - Chroma2, - Chroma3, - Chroma4, - Chroma5, - Chroma6, - Chroma7, - Chroma8, - Chroma9, - Chroma10, -} -/// The number of features used in `Analysis` -pub const NUMBER_FEATURES: usize = AnalysisIndex::COUNT; - -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Default, PartialEq, Clone, Copy)] -/// Object holding the results of the song's analysis. -/// -/// Only use it if you want to have an in-depth look of what is -/// happening behind the scene, or make a distance metric yourself. -/// -/// Under the hood, it is just an array of f32 holding different numeric -/// features. -/// -/// For more info on the different features, build the -/// documentation with private items included using -/// `cargo doc --document-private-items`, and / or read up -/// [this document](https://lelele.io/thesis.pdf), that contains a description -/// on most of the features, except the chroma ones, which are documented -/// directly in this code. -pub struct Analysis { - pub(crate) internal_analysis: [f32; NUMBER_FEATURES], -} - -impl Index for Analysis { - type Output = f32; - - fn index(&self, index: AnalysisIndex) -> &f32 { - &self.internal_analysis[index as usize] - } -} - -impl fmt::Debug for Analysis { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug_struct = f.debug_struct("Analysis"); - for feature in AnalysisIndex::iter() { - debug_struct.field(&format!("{feature:?}"), &self[feature]); - } - debug_struct.finish()?; - f.write_str(&format!(" /* {:?} */", &self.as_vec())) - } -} - -impl Analysis { - /// Create a new Analysis object. - /// - /// Usually not needed, unless you have already computed and stored - /// features somewhere, and need to recreate a Song with an already - /// existing Analysis yourself. - pub fn new(analysis: [f32; NUMBER_FEATURES]) -> Analysis { - Analysis { - internal_analysis: analysis, - } - } - - /// Return an ndarray `Array1` representing the analysis' features. - /// - /// Particularly useful if you want to make a custom distance metric. - pub fn as_arr1(&self) -> Array1 { - arr1(&self.internal_analysis) - } - - /// Return a `Vec` representing the analysis' features. - /// - /// Particularly useful if you want iterate through the values to store - /// them somewhere. - pub fn as_vec(&self) -> Vec { - self.internal_analysis.to_vec() - } -} - -impl Song { - /// Returns a decoded [Song] given a file path, or an error if the song - /// could not be analyzed for some reason. - /// - /// # Arguments - /// - /// * `path` - A [Path] holding a valid file path to a valid audio file. - /// - /// # Errors - /// - /// This function will return an error if the file path is invalid, if - /// the file path points to a file containing no or corrupted audio stream, - /// or if the analysis could not be conducted to the end for some reason. - /// - /// The error type returned should give a hint as to whether it was a - /// decoding ([DecodingError](BlissError::DecodingError)) or an analysis - /// ([AnalysisError](BlissError::AnalysisError)) error. - #[cfg(feature = "ffmpeg")] - pub fn from_path>(path: P) -> BlissResult { - let raw_song = Song::decode(path.as_ref())?; - - Ok(Song { - path: raw_song.path, - artist: raw_song.artist, - album_artist: raw_song.album_artist, - title: raw_song.title, - album: raw_song.album, - track_number: raw_song.track_number, - genre: raw_song.genre, - duration: raw_song.duration, - analysis: Song::analyze(&raw_song.sample_array)?, - features_version: FEATURES_VERSION, - cue_info: None, - }) - } - - /** - * Analyze a song decoded in `sample_array`. This function should NOT - * be used manually, unless you want to explore analyzing a sample array you - * already decoded yourself. Most people will want to use - * [Song::from_path](Song::from_path) instead to just analyze a file from - * its path. - * - * The current implementation doesn't make use of it, - * but the song can also be streamed wrt. - * each descriptor (with the exception of the chroma descriptor which - * yields worse results when streamed). - * - * Useful in the rare cases where the full song is not - * completely available. - * - * If you *do* want to use this with a song already decoded by yourself, - * the sample format of `sample_array` should be f32le, one channel, and - * the sampling rate 22050 Hz. Anything other than that will yield aberrant - * results. - * To double-check that your sample array has the right format, you could run - * `ffmpeg -i path_to_your_song.flac -ar 22050 -ac 1 -c:a pcm_f32le -f hash -hash addler32 -`, - * which will give you the addler32 checksum of the sample array if the song - * has been decoded properly. You can then compute the addler32 checksum of your sample - * array (see `_test_decode` in the tests) and make sure both are the same. - * - * (Running `ffmpeg -i path_to_your_song.flac -ar 22050 -ac 1 -c:a pcm_f32le` will simply give - * you the raw sample array as it should look like, if you're not into computing checksums) - **/ - pub fn analyze(sample_array: &[f32]) -> BlissResult { - let largest_window = vec![ - BPMDesc::WINDOW_SIZE, - ChromaDesc::WINDOW_SIZE, - SpectralDesc::WINDOW_SIZE, - LoudnessDesc::WINDOW_SIZE, - ] - .into_iter() - .max() - .unwrap(); - if sample_array.len() < largest_window { - return Err(BlissError::AnalysisError(String::from( - "empty or too short song.", - ))); - } - - thread::scope(|s| -> BlissResult { - let child_tempo = s.spawn(|| { - let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?; - let windows = sample_array - .windows(BPMDesc::WINDOW_SIZE) - .step_by(BPMDesc::HOP_SIZE); - - for window in windows { - tempo_desc.do_(window)?; - } - Ok(tempo_desc.get_value()) - }); - - let child_chroma = s.spawn(|| { - let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); - chroma_desc.do_(sample_array)?; - Ok(chroma_desc.get_values()) - }); - - #[allow(clippy::type_complexity)] - let child_timbral = s.spawn(|| { - let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE)?; - let windows = sample_array - .windows(SpectralDesc::WINDOW_SIZE) - .step_by(SpectralDesc::HOP_SIZE); - for window in windows { - spectral_desc.do_(window)?; - } - let centroid = spectral_desc.get_centroid(); - let rolloff = spectral_desc.get_rolloff(); - let flatness = spectral_desc.get_flatness(); - Ok((centroid, rolloff, flatness)) - }); - - let child_zcr = s.spawn(|| { - let mut zcr_desc = ZeroCrossingRateDesc::default(); - zcr_desc.do_(sample_array); - Ok(zcr_desc.get_value()) - }); - - let child_loudness = s.spawn(|| { - let mut loudness_desc = LoudnessDesc::default(); - let windows = sample_array.chunks(LoudnessDesc::WINDOW_SIZE); - - for window in windows { - loudness_desc.do_(window); - } - Ok(loudness_desc.get_value()) - }); - - // Non-streaming approach for that one - let tempo = child_tempo.join().unwrap()?; - let chroma = child_chroma.join().unwrap()?; - let (centroid, rolloff, flatness) = child_timbral.join().unwrap()?; - let loudness = child_loudness.join().unwrap()?; - let zcr = child_zcr.join().unwrap()?; - - let mut result = vec![tempo, zcr]; - result.extend_from_slice(¢roid); - result.extend_from_slice(&rolloff); - result.extend_from_slice(&flatness); - result.extend_from_slice(&loudness); - result.extend_from_slice(&chroma); - let array: [f32; NUMBER_FEATURES] = result.try_into().map_err(|_| { - BlissError::AnalysisError( - "Too many or too little features were provided at the end of - the analysis." - .to_string(), - ) - })?; - Ok(Analysis::new(array)) - }) - } - - #[cfg(feature = "ffmpeg")] - pub(crate) fn decode(path: &Path) -> BlissResult { - ffmpeg::init().map_err(|e| { - BlissError::DecodingError(format!( - "ffmpeg init error while decoding file '{}': {:?}.", - path.display(), - e - )) - })?; - log::set_level(Level::Quiet); - let mut song = InternalSong { - path: path.into(), - ..Default::default() - }; - let mut ictx = ffmpeg::format::input(&path).map_err(|e| { - BlissError::DecodingError(format!( - "while opening format for file '{}': {:?}.", - path.display(), - e - )) - })?; - let (mut decoder, stream, expected_sample_number) = { - let input = ictx.streams().best(media::Type::Audio).ok_or_else(|| { - BlissError::DecodingError(format!( - "No audio stream found for file '{}'.", - path.display() - )) - })?; - let mut context = ffmpeg::codec::context::Context::from_parameters(input.parameters()) - .map_err(|e| { - BlissError::DecodingError(format!( - "Could not load the codec context for file '{}': {:?}", - path.display(), - e - )) - })?; - context.set_threading(Config { - kind: ThreadingType::Frame, - count: 0, - #[cfg(not(feature = "ffmpeg_6_0"))] - safe: true, - }); - let decoder = context.decoder().audio().map_err(|e| { - BlissError::DecodingError(format!( - "when finding decoder for file '{}': {:?}.", - path.display(), - e - )) - })?; - - // Add SAMPLE_RATE to have one second margin to avoid reallocating if - // the duration is slightly more than estimated - // TODO>1.0 another way to get the exact number of samples is to decode - // everything once, compute the real number of samples from that, - // allocate the array with that number, and decode again. Check - // what's faster between reallocating, and just have one second - // leeway. - let expected_sample_number = (SAMPLE_RATE as f32 * input.duration() as f32 - / input.time_base().denominator() as f32) - .ceil() - + SAMPLE_RATE as f32; - (decoder, input.index(), expected_sample_number) - }; - let sample_array: Vec = Vec::with_capacity(expected_sample_number as usize); - if let Some(title) = ictx.metadata().get("title") { - song.title = match title { - "" => None, - t => Some(t.to_string()), - }; - }; - if let Some(artist) = ictx.metadata().get("artist") { - song.artist = match artist { - "" => None, - a => Some(a.to_string()), - }; - }; - if let Some(album) = ictx.metadata().get("album") { - song.album = match album { - "" => None, - a => Some(a.to_string()), - }; - }; - if let Some(genre) = ictx.metadata().get("genre") { - song.genre = match genre { - "" => None, - g => Some(g.to_string()), - }; - }; - if let Some(track_number) = ictx.metadata().get("track") { - song.track_number = match track_number { - "" => None, - t => Some(t.to_string()), - }; - }; - if let Some(album_artist) = ictx.metadata().get("album_artist") { - song.album_artist = match album_artist { - "" => None, - t => Some(t.to_string()), - }; - }; - let (empty_in_channel_layout, in_channel_layout) = { - if decoder.channel_layout() == ChannelLayout::empty() { - (true, ChannelLayout::default(decoder.channels().into())) - } else { - (false, decoder.channel_layout()) - } - }; - decoder.set_channel_layout(in_channel_layout); - - let (tx, rx) = mpsc::channel(); - let in_codec_format = decoder.format(); - let in_codec_rate = decoder.rate(); - let child = thread::spawn(move || { - resample_frame( - rx, - in_codec_format, - in_channel_layout, - in_codec_rate, - sample_array, - empty_in_channel_layout, - ) - }); - for (s, packet) in ictx.packets() { - if s.index() != stream { - continue; - } - match decoder.send_packet(&packet) { - Ok(_) => (), - Err(Error::Other { errno: EINVAL }) => { - return Err(BlissError::DecodingError(format!( - "wrong codec opened for file '{}.", - path.display(), - ))) - } - Err(Error::Eof) => { - warn!( - "Premature EOF reached while decoding file '{}'.", - path.display() - ); - drop(tx); - song.sample_array = child.join().unwrap()?; - return Ok(song); - } - Err(e) => warn!("error while decoding file '{}': {}", path.display(), e), - }; - - loop { - let mut decoded = ffmpeg::frame::Audio::empty(); - match decoder.receive_frame(&mut decoded) { - Ok(_) => { - tx.send(decoded).map_err(|e| { - BlissError::DecodingError(format!( - "while sending decoded frame to the resampling thread for file '{}': {:?}", - path.display(), - e, - )) - })?; - } - Err(_) => break, - } - } - } - - // Flush the stream - let packet = ffmpeg::codec::packet::Packet::empty(); - match decoder.send_packet(&packet) { - Ok(_) => (), - Err(Error::Other { errno: EINVAL }) => { - return Err(BlissError::DecodingError(format!( - "wrong codec opened for file '{}'.", - path.display() - ))) - } - Err(Error::Eof) => { - warn!( - "Premature EOF reached while decoding file '{}'.", - path.display() - ); - drop(tx); - song.sample_array = child.join().unwrap()?; - return Ok(song); - } - Err(e) => warn!("error while decoding {}: {}", path.display(), e), - }; - - loop { - let mut decoded = ffmpeg::frame::Audio::empty(); - match decoder.receive_frame(&mut decoded) { - Ok(_) => { - tx.send(decoded).map_err(|e| { - BlissError::DecodingError(format!( - "while sending decoded frame to the resampling thread for file '{}': {:?}", - path.display(), - e - )) - })?; - } - Err(_) => break, - } - } - - drop(tx); - song.sample_array = child.join().unwrap()?; - let duration_seconds = song.sample_array.len() as f32 / SAMPLE_RATE as f32; - song.duration = Duration::from_nanos((duration_seconds * 1e9_f32).round() as u64); - Ok(song) - } -} - -#[derive(Default, Debug)] -#[cfg(feature = "ffmpeg")] -pub(crate) struct InternalSong { - pub path: PathBuf, - pub artist: Option, - pub album_artist: Option, - pub title: Option, - pub album: Option, - pub track_number: Option, - pub genre: Option, - pub duration: Duration, - pub sample_array: Vec, -} - -#[cfg(feature = "ffmpeg")] -fn resample_frame( - rx: Receiver