diff --git a/CHANGELOG.md b/CHANGELOG.md index f24bdf7..b18fd84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ #Changelog ## bliss 0.5.0 +* Add support for CUE files. * Add `album_artist` and `duration` to `Song`. * Fix a bug in `estimate_tuning` that led to empty chroma errors. * Remove the unusued Library trait, and extract a few useful functions from diff --git a/Cargo.lock b/Cargo.lock index 86e8da4..ed5a570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,7 +102,9 @@ dependencies = [ "ndarray-stats", "noisy_float", "num_cpus", + "pretty_assertions", "rayon", + "rcue", "ripemd160", "rustfft", "serde", @@ -332,6 +334,22 @@ dependencies = [ "lazy_static 1.4.0", ] +[[package]] +name = "ctor" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f877be4f7c9f246b183111634f75baa039715e3f46ce860677d3b19a69fb229c" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" + [[package]] name = "digest" version = "0.8.1" @@ -426,9 +444,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f" +checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" dependencies = [ "cfg-if", "crc32fast", @@ -583,9 +601,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.121" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" +checksum = "cb691a747a7ab48abc15c5b42066eaafde10dc427e3b6ee2a1cf43db04c763bd" [[package]] name = "libloading" @@ -669,12 +687,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.4.4" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" dependencies = [ "adler", - "autocfg", ] [[package]] @@ -831,6 +848,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -892,6 +918,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "pretty_assertions" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89f989ac94207d048d92db058e4f6ec7342b0971fc58d1271ca148b799b3563" +dependencies = [ + "ansi_term", + "ctor", + "diff", + "output_vt100", +] + [[package]] name = "primal-check" version = "0.3.1" @@ -903,9 +941,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" dependencies = [ "unicode-xid", ] @@ -925,9 +963,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632d02bff7f874a36f33ea8bb416cd484b90cc66c1194b1a1110d067a7013f58" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" dependencies = [ "proc-macro2", ] @@ -970,9 +1008,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90" +checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" dependencies = [ "autocfg", "crossbeam-deque", @@ -982,17 +1020,22 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e" +checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" dependencies = [ "crossbeam-channel", "crossbeam-deque", "crossbeam-utils", - "lazy_static 1.4.0", "num_cpus", ] +[[package]] +name = "rcue" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca1481d62f18158646de2ec552dd63f8bdc5be6448389b192ba95c939df997e" + [[package]] name = "regex" version = "0.1.80" @@ -1153,9 +1196,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704df27628939572cd88d33f171cd6f896f4eaca85252c6e0a72d8d8287ee86f" +checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b2e9891..bca6fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ bliss-audio-aubio-rs = "0.2.0" strum = "0.21" strum_macros = "0.21" serde = { version = "1.0", optional = true, features = ["derive"] } +rcue = "0.1.1" [dev-dependencies] mime_guess = "2.0.3" @@ -52,3 +53,4 @@ glob = "0.3.0" anyhow = "1.0.45" serde_json = "1.0.59" clap = "2.33.3" +pretty_assertions = "1.2.1" diff --git a/data/testcue.cue b/data/testcue.cue new file mode 100644 index 0000000..886070d --- /dev/null +++ b/data/testcue.cue @@ -0,0 +1,29 @@ +REM GENRE Random +REM DATE 2022 +PERFORMER "Polochon_street" +TITLE "Album for CUE test" +FILE "testcue.wav" WAVE + TRACK 01 AUDIO + TITLE "Renaissance" + PERFORMER "David TMX" + INDEX 01 0:00:00 + TRACK 02 AUDIO + TITLE "Piano" + PERFORMER "Polochon_street" + INDEX 01 0:11:05 + TRACK 03 AUDIO + TITLE "Tone" + PERFORMER "Polochon_street" + INDEX 01 0:16:69 + +FILE "not-existing.wav" WAVE + TRACK 01 AUDIO + TITLE "Nope" + PERFORMER "Charlie" + INDEX 01 0:00:00 + TRACK 02 AUDIO + TITLE "Nope" + PERFORMER "Charlie" + INDEX 01 0:10:00 + + diff --git a/data/testcue.wav b/data/testcue.wav new file mode 100644 index 0000000..cbd9832 Binary files /dev/null and b/data/testcue.wav differ diff --git a/examples/playlist.rs b/examples/playlist.rs index 2e8a2e3..ec12510 100644 --- a/examples/playlist.rs +++ b/examples/playlist.rs @@ -71,7 +71,7 @@ fn main() -> Result<()> { .iter() .filter(|p| !analyzed_paths.contains(&PathBuf::from(p))) .map(|p| p.to_owned()) - .collect(), + .collect::>(), ); let first_song = Song::from_path(file)?; let mut analyzed_songs = vec![first_song.to_owned()]; diff --git a/src/cue.rs b/src/cue.rs new file mode 100644 index 0000000..cd736e0 --- /dev/null +++ b/src/cue.rs @@ -0,0 +1,321 @@ +//! CUE-handling module. +//! +//! Using [BlissCue::songs_from_path] is most likely what you want. + +use crate::{Analysis, BlissError, BlissResult, Song, FEATURES_VERSION, SAMPLE_RATE}; +use rcue::cue::{Cue, Track}; +use rcue::parser::parse_from_file; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Default, Debug, PartialEq, Clone)] +/// A struct populated when the corresponding [Song] has been extracted from an +/// audio file split with the help of a CUE sheet. +pub struct CueInfo { + /// The path of the original CUE sheet, e.g. `/path/to/album_name.cue`. + pub cue_path: PathBuf, + /// The path of the audio file the song was extracted from, e.g. + /// `/path/to/album_name.wav`. Used because one CUE sheet can refer to + /// several audio files. + pub audio_file_path: PathBuf, +} + +/// A struct to handle CUEs with bliss. +/// 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 { + cue: Cue, + cue_path: PathBuf, +} + +#[allow(missing_docs)] +#[derive(Default, Debug, PartialEq, Clone)] +struct BlissCueFile { + sample_array: Vec, + album: Option, + artist: Option, + genre: Option, + tracks: Vec, + cue_path: PathBuf, + audio_file_path: PathBuf, +} + +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. + pub fn songs_from_path>(path: P) -> BlissResult>> { + let cue = BlissCue::from_path(&path)?; + let cue_files = cue.files(); + let mut songs = Vec::new(); + for cue_file in cue_files.into_iter() { + match cue_file { + Ok(f) => songs.extend_from_slice(&f.get_songs()), + Err(e) => songs.push(Err(e)), + } + } + Ok(songs) + } + + // Extract a BlissCue from a given path. + fn from_path>(path: P) -> BlissResult { + let cue = parse_from_file(&path.as_ref().to_string_lossy(), false).map_err(|e| { + BlissError::DecodingError(format!( + "when opening CUE file '{:?}': {:?}", + path.as_ref(), + e + )) + })?; + Ok(BlissCue { + cue, + cue_path: path.as_ref().to_owned(), + }) + } + + // List all BlissCueFile from a BlissCue. + fn files(&self) -> Vec> { + let mut cue_files = Vec::new(); + for cue_file in self.cue.files.iter() { + let audio_file_path = match &self.cue_path.parent() { + Some(parent) => parent.join(Path::new(&cue_file.file)), + None => PathBuf::from(cue_file.file.to_owned()), + }; + let genre = self + .cue + .comments + .iter() + .find(|(c, _)| c == "GENRE") + .map(|(_, v)| v.to_owned()); + let raw_song = Song::decode(Path::new(&audio_file_path)); + if let Ok(song) = raw_song { + let bliss_cue_file = BlissCueFile { + sample_array: song.sample_array, + genre, + artist: self.cue.performer.to_owned(), + album: self.cue.title.to_owned(), + tracks: cue_file.tracks.to_owned(), + audio_file_path, + cue_path: self.cue_path.to_owned(), + }; + cue_files.push(Ok(bliss_cue_file)) + } else { + cue_files.push(Err(raw_song.unwrap_err())); + } + } + cue_files + } +} + +impl BlissCueFile { + fn create_song( + &self, + analysis: BlissResult, + current_track: &Track, + duration: Duration, + index: usize, + ) -> BlissResult { + if let Ok(a) = analysis { + let song = Song { + path: PathBuf::from(format!( + "{}/CUE_TRACK{:03}", + self.audio_file_path.to_string_lossy(), + index, + )), + album: self.album.to_owned(), + artist: current_track.performer.to_owned(), + album_artist: self.artist.to_owned(), + analysis: a, + duration, + genre: self.genre.to_owned(), + title: current_track.title.to_owned(), + track_number: Some(current_track.no.to_owned()), + features_version: FEATURES_VERSION, + cue_info: Some(CueInfo { + cue_path: self.cue_path.to_owned(), + audio_file_path: self.audio_file_path.to_owned(), + }), + }; + Ok(song) + } else { + Err(analysis.unwrap_err()) + } + } + + // Get all songs from a BlissCueFile, using Song::analyze, each song being + // located using the sample_array and the timestamp delimiter. + fn get_songs(&self) -> Vec> { + let mut songs = Vec::new(); + for (index, tuple) in (&self.tracks[..]).windows(2).enumerate() { + let (current_track, next_track) = (tuple[0].to_owned(), tuple[1].to_owned()); + if let Some((_, start_current)) = current_track.indices.get(0) { + if let Some((_, end_current)) = next_track.indices.get(0) { + let start_current = (start_current.as_secs_f32() * SAMPLE_RATE as f32) as usize; + let end_current = (end_current.as_secs_f32() * SAMPLE_RATE as f32) as usize; + let duration = Duration::from_secs_f32( + (end_current - start_current) as f32 / SAMPLE_RATE as f32, + ); + let analysis = Song::analyze(&self.sample_array[start_current..end_current]); + + let song = self.create_song(analysis, ¤t_track, duration, index + 1); + songs.push(song); + } + } + } + // Take care of the last track, since the windows iterator doesn't. + if let Some(last_track) = self.tracks.last() { + if let Some((_, start_current)) = last_track.indices.get(0) { + let start_current = (start_current.as_secs_f32() * SAMPLE_RATE as f32) as usize; + let duration = Duration::from_secs_f32( + (self.sample_array.len() - start_current) as f32 / SAMPLE_RATE as f32, + ); + let analysis = Song::analyze(&self.sample_array[start_current..]); + let song = self.create_song(analysis, last_track, duration, self.tracks.len()); + songs.push(song); + } + } + songs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_cue_analysis() { + let songs = BlissCue::songs_from_path("data/testcue.cue").unwrap(); + let expected = vec![ + Ok(Song { + path: Path::new("data/testcue.wav/CUE_TRACK001").to_path_buf(), + analysis: Analysis { + internal_analysis: [ + 0.38463724, + -0.85219246, + -0.761946, + -0.8904667, + -0.63892543, + -0.73945934, + -0.8004017, + -0.8237293, + 0.33865356, + 0.32481194, + -0.35692245, + -0.6355889, + -0.29584837, + 0.06431806, + 0.21875131, + -0.58104205, + -0.9466792, + -0.94811195, + -0.9820919, + -0.9596871, + ], + }, + album: Some(String::from("Album for CUE test")), + artist: Some(String::from("David TMX")), + title: Some(String::from("Renaissance")), + genre: Some(String::from("Random")), + track_number: Some(String::from("01")), + features_version: FEATURES_VERSION, + album_artist: Some(String::from("Polochon_street")), + duration: Duration::from_secs_f32(11.066666603), + cue_info: Some(CueInfo { + cue_path: PathBuf::from("data/testcue.cue"), + audio_file_path: PathBuf::from("data/testcue.wav"), + }), + ..Default::default() + }), + Ok(Song { + path: Path::new("data/testcue.wav/CUE_TRACK002").to_path_buf(), + analysis: Analysis { + internal_analysis: [ + 0.18622077, + -0.5989029, + -0.5554645, + -0.6343865, + -0.24163479, + -0.25766593, + -0.40616858, + -0.23334873, + 0.76875293, + 0.7785741, + -0.5075115, + -0.5272629, + -0.56706166, + -0.568486, + -0.5639081, + -0.5706943, + -0.96501005, + -0.96501285, + -0.9649896, + -0.96498996, + ], + }, + features_version: FEATURES_VERSION, + album: Some(String::from("Album for CUE test")), + artist: Some(String::from("Polochon_street")), + title: Some(String::from("Piano")), + genre: Some(String::from("Random")), + track_number: Some(String::from("02")), + album_artist: Some(String::from("Polochon_street")), + duration: Duration::from_secs_f64(5.853333473), + cue_info: Some(CueInfo { + cue_path: PathBuf::from("data/testcue.cue"), + audio_file_path: PathBuf::from("data/testcue.wav"), + }), + ..Default::default() + }), + Ok(Song { + path: Path::new("data/testcue.wav/CUE_TRACK003").to_path_buf(), + analysis: Analysis { + internal_analysis: [ + 0.0024261475, + 0.9874661, + 0.97330654, + -0.9724426, + 0.99678576, + -0.9961549, + -0.9840142, + -0.9269961, + 0.7498772, + 0.22429907, + -0.8355152, + -0.9977258, + -0.9977849, + -0.997785, + -0.99778515, + -0.997785, + -0.99999976, + -0.99999976, + -0.99999976, + -0.99999976, + ], + }, + album: Some(String::from("Album for CUE test")), + artist: Some(String::from("Polochon_street")), + title: Some(String::from("Tone")), + genre: Some(String::from("Random")), + track_number: Some(String::from("03")), + features_version: FEATURES_VERSION, + album_artist: Some(String::from("Polochon_street")), + duration: Duration::from_secs_f32(5.586666584), + cue_info: Some(CueInfo { + cue_path: PathBuf::from("data/testcue.cue"), + audio_file_path: PathBuf::from("data/testcue.wav"), + }), + ..Default::default() + }), + Err(BlissError::DecodingError(String::from( + "while opening format for file 'data/not-existing.wav': \ + ffmpeg::Error(2: No such file or directory).", + ))), + ]; + assert_eq!(expected, songs); + } +} diff --git a/src/lib.rs b/src/lib.rs index cf0951d..71603b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ //! //! bliss is a library for making "smart" audio playlists. //! -//! The core of the library is the `Song` object, which relates to a +//! The core of the library is the [Song] object, which relates to a //! specific analyzed song and contains its path, title, analysis, and //! other metadata fields (album, genre...). //! Analyzing a song is as simple as running `Song::from_path("/path/to/song")`. @@ -17,7 +17,7 @@ //! //! # Examples //! -//! ## Analyze & compute the distance between two songs +//! ### Analyze & compute the distance between two songs //! ```no_run //! use bliss_audio::{BlissResult, Song}; //! @@ -30,17 +30,16 @@ //! } //! ``` //! -//! ### Make a playlist from a song +//! ### Make a playlist from a song, discarding failed songs //! ```no_run -//! use bliss_audio::{BlissResult, Song}; +//! use bliss_audio::{analyze_paths, BlissResult, Song}; //! use noisy_float::prelude::n32; //! //! fn main() -> BlissResult<()> { //! let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"]; -//! let mut songs: Vec = paths -//! .iter() -//! .map(|path| Song::from_path(path)) -//! .collect::>>()?; +//! let mut songs: Vec = analyze_paths(&paths) +//! .filter_map(|(_, s)| s.ok()) +//! .collect(); //! //! // Assuming there is a first song //! let first_song = songs.first().unwrap().to_owned(); @@ -60,6 +59,7 @@ #![warn(missing_docs)] #![warn(rustdoc::missing_doc_code_examples)] mod chroma; +pub mod cue; mod misc; pub mod playlist; mod song; @@ -72,7 +72,9 @@ extern crate num_cpus; #[cfg(feature = "serde")] #[macro_use] extern crate serde; +use crate::cue::BlissCue; use log::info; +use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::thread; use thiserror::Error; @@ -105,19 +107,34 @@ pub enum BlissError { pub type BlissResult = Result; /// Analyze songs in `paths`, and return the analyzed [Song] objects through an -/// [mpsc::IntoIter] +/// [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. /// -/// * Example: +/// # 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) { +/// 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, e), @@ -126,9 +143,11 @@ pub type BlissResult = Result; /// Ok(()) /// } /// ``` -pub fn analyze_paths(paths: Vec) -> mpsc::IntoIter<(String, BlissResult)> { +pub fn analyze_paths, F: IntoIterator>( + paths: F, +) -> mpsc::IntoIter<(String, BlissResult)> { let num_cpus = num_cpus::get(); - + let paths: Vec = paths.into_iter().map(|p| p.into()).collect(); #[allow(clippy::type_complexity)] let (tx, rx): ( mpsc::Sender<(String, BlissResult)>, @@ -142,15 +161,34 @@ pub fn analyze_paths(paths: Vec) -> mpsc::IntoIter<(String, BlissResult< 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); + 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_string_lossy().to_string(), song)) + .unwrap(); + } + } + Err(e) => tx_thread + .send((path.to_string_lossy().to_string(), Err(e))) + .unwrap(), + }; + continue; + } + } let song = Song::from_path(&path); - tx_thread.send((path.to_string(), song)).unwrap(); + tx_thread + .send((path.to_string_lossy().to_string(), song)) + .unwrap(); } }); handles.push(child); @@ -162,6 +200,8 @@ pub fn analyze_paths(paths: Vec) -> mpsc::IntoIter<(String, BlissResult< #[cfg(test)] mod tests { use super::*; + #[cfg(test)] + use pretty_assertions::assert_eq; #[test] fn test_send_song() { @@ -179,24 +219,53 @@ mod tests { fn test_analyze_paths() { let paths = vec![ String::from("./data/s16_mono_22_5kHz.flac"), + String::from("./data/testcue.cue"), String::from("./data/white_noise.flac"), String::from("definitely-not-existing.foo"), String::from("not-existing.foo"), ]; - let mut results = analyze_paths(paths) + let mut results = analyze_paths(&paths) .map(|x| match &x.1 { - Ok(s) => (true, s.path.to_string_lossy().to_string()), - Err(_) => (false, x.0.to_owned()), + Ok(s) => (true, s.path.to_string_lossy().to_string(), None), + Err(e) => (false, x.0.to_owned(), Some(e.to_string())), }) .collect::>(); results.sort(); assert_eq!( results, vec![ - (false, String::from("definitely-not-existing.foo")), - (false, String::from("not-existing.foo")), - (true, String::from("./data/s16_mono_22_5kHz.flac")), - (true, String::from("./data/white_noise.flac")), + ( + false, + String::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, + String::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, + String::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, String::from("./data/s16_mono_22_5kHz.flac"), None), + (true, String::from("./data/testcue.wav/CUE_TRACK001"), None), + (true, String::from("./data/testcue.wav/CUE_TRACK002"), None), + (true, String::from("./data/testcue.wav/CUE_TRACK003"), None), + (true, String::from("./data/white_noise.flac"), None), ], ); } diff --git a/src/song.rs b/src/song.rs index 6f5d7e5..374125b 100644 --- a/src/song.rs +++ b/src/song.rs @@ -13,6 +13,7 @@ extern crate ndarray; extern crate ndarray_npy; use crate::chroma::ChromaDesc; +use crate::cue::CueInfo; use crate::misc::LoudnessDesc; #[cfg(doc)] use crate::playlist; @@ -72,6 +73,12 @@ pub struct Song { /// 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, } #[derive(Debug, EnumIter, EnumCount)] @@ -135,7 +142,7 @@ pub const NUMBER_FEATURES: usize = AnalysisIndex::COUNT; /// on most of the features, except the chroma ones, which are documented /// directly in this code. pub struct Analysis { - internal_analysis: [f32; NUMBER_FEATURES], + pub(crate) internal_analysis: [f32; NUMBER_FEATURES], } impl Index for Analysis { @@ -301,8 +308,9 @@ impl Song { track_number: raw_song.track_number, genre: raw_song.genre, duration: raw_song.duration, - analysis: Song::analyze(raw_song.sample_array)?, + analysis: Song::analyze(&raw_song.sample_array)?, features_version: FEATURES_VERSION, + cue_info: None, }) } @@ -317,7 +325,7 @@ impl Song { * Useful in the rare cases where the full song is not * completely available. **/ - fn analyze(sample_array: Vec) -> BlissResult { + pub(crate) fn analyze(sample_array: &[f32]) -> BlissResult { let largest_window = vec![ BPMDesc::WINDOW_SIZE, ChromaDesc::WINDOW_SIZE, @@ -348,7 +356,7 @@ impl Song { let child_chroma: thread::ScopedJoinHandle<'_, BlissResult>> = s.spawn(|_| { let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); - chroma_desc.do_(&sample_array)?; + chroma_desc.do_(sample_array)?; Ok(chroma_desc.get_values()) }); @@ -372,7 +380,7 @@ impl Song { let child_zcr: thread::ScopedJoinHandle<'_, BlissResult> = s.spawn(|_| { let mut zcr_desc = ZeroCrossingRateDesc::default(); - zcr_desc.do_(&sample_array); + zcr_desc.do_(sample_array); Ok(zcr_desc.get_value()) }); @@ -413,33 +421,55 @@ impl Song { } pub(crate) fn decode(path: &Path) -> BlissResult { - ffmpeg::init() - .map_err(|e| BlissError::DecodingError(format!("ffmpeg init error: {:?}.", e)))?; + 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 format = ffmpeg::format::input(&path) - .map_err(|e| BlissError::DecodingError(format!("while opening format: {:?}.", e)))?; + let mut format = ffmpeg::format::input(&path).map_err(|e| { + BlissError::DecodingError(format!( + "while opening format for file '{}': {:?}.", + path.display(), + e + )) + })?; let (mut codec, stream, expected_sample_number) = { let stream = format .streams() .find(|s| s.parameters().medium() == ffmpeg::media::Type::Audio) - .ok_or_else(|| BlissError::DecodingError(String::from("No audio stream found.")))?; + .ok_or_else(|| { + BlissError::DecodingError(format!( + "No audio stream found for file '{}'.", + path.display() + )) + })?; let mut context = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) .map_err(|e| { - BlissError::DecodingError(format!("Could not load the codec context: {:?}", e)) + BlissError::DecodingError(format!( + "Could not load the codec context for file '{}': {:?}", + path.display(), + e + )) })?; context.set_threading(Config { kind: ThreadingType::Frame, count: 0, safe: true, }); - let codec = context - .decoder() - .audio() - .map_err(|e| BlissError::DecodingError(format!("when finding codec: {:?}.", e)))?; + let codec = context.decoder().audio().map_err(|e| { + BlissError::DecodingError(format!( + "when finding codec 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 @@ -519,17 +549,21 @@ impl Song { match codec.send_packet(&packet) { Ok(_) => (), Err(Error::Other { errno: EINVAL }) => { - return Err(BlissError::DecodingError(String::from( - "wrong codec opened.", + return Err(BlissError::DecodingError(format!( + "wrong codec opened for file '{}.", + path.display(), ))) } Err(Error::Eof) => { - warn!("Premature EOF reached while decoding."); + 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), + Err(e) => warn!("error while decoding file '{}': {}", path.display(), e), }; loop { @@ -538,8 +572,9 @@ impl Song { Ok(_) => { tx.send(decoded).map_err(|e| { BlissError::DecodingError(format!( - "while sending decoded frame to the resampling thread: {:?}", - e + "while sending decoded frame to the resampling thread for file '{}': {:?}", + path.display(), + e, )) })?; } @@ -553,12 +588,16 @@ impl Song { match codec.send_packet(&packet) { Ok(_) => (), Err(Error::Other { errno: EINVAL }) => { - return Err(BlissError::DecodingError(String::from( - "wrong codec opened.", + return Err(BlissError::DecodingError(format!( + "wrong codec opened for file '{}'.", + path.display() ))) } Err(Error::Eof) => { - warn!("Premature EOF reached while decoding."); + warn!( + "Premature EOF reached while decoding file '{}'.", + path.display() + ); drop(tx); song.sample_array = child.join().unwrap()?; return Ok(song); @@ -572,7 +611,8 @@ impl Song { Ok(_) => { tx.send(decoded).map_err(|e| { BlissError::DecodingError(format!( - "while sending decoded frame to the resampling thread: {:?}", + "while sending decoded frame to the resampling thread for file '{}': {:?}", + path.display(), e )) })?; @@ -678,18 +718,19 @@ fn push_to_sample_array(frame: &ffmpeg::frame::Audio, sample_array: &mut Vec