Make analyze_paths
use CUE support.
This commit is contained in:
parent
0227c9596a
commit
7498cf4620
8 changed files with 557 additions and 322 deletions
|
@ -1,6 +1,7 @@
|
||||||
#Changelog
|
#Changelog
|
||||||
|
|
||||||
## bliss 0.5.0
|
## bliss 0.5.0
|
||||||
|
* Add support for CUE files.
|
||||||
* Add `album_artist` and `duration` to `Song`.
|
* Add `album_artist` and `duration` to `Song`.
|
||||||
* Fix a bug in `estimate_tuning` that led to empty chroma errors.
|
* Fix a bug in `estimate_tuning` that led to empty chroma errors.
|
||||||
* Remove the unusued Library trait, and extract a few useful functions from
|
* Remove the unusued Library trait, and extract a few useful functions from
|
||||||
|
|
76
Cargo.lock
generated
76
Cargo.lock
generated
|
@ -102,6 +102,7 @@ dependencies = [
|
||||||
"ndarray-stats",
|
"ndarray-stats",
|
||||||
"noisy_float",
|
"noisy_float",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
|
"pretty_assertions",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rcue",
|
"rcue",
|
||||||
"ripemd160",
|
"ripemd160",
|
||||||
|
@ -333,6 +334,22 @@ dependencies = [
|
||||||
"lazy_static 1.4.0",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
@ -427,9 +444,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.0.22"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f"
|
checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
@ -584,9 +601,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.121"
|
version = "0.2.123"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f"
|
checksum = "cb691a747a7ab48abc15c5b42066eaafde10dc427e3b6ee2a1cf43db04c763bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
|
@ -670,12 +687,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.4.4"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b"
|
checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adler",
|
"adler",
|
||||||
"autocfg",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -832,6 +848,15 @@ version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
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]]
|
[[package]]
|
||||||
name = "peeking_take_while"
|
name = "peeking_take_while"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
@ -893,6 +918,18 @@ version = "0.2.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
|
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]]
|
[[package]]
|
||||||
name = "primal-check"
|
name = "primal-check"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
@ -904,9 +941,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.36"
|
version = "1.0.37"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
|
checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
@ -926,9 +963,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.17"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "632d02bff7f874a36f33ea8bb416cd484b90cc66c1194b1a1110d067a7013f58"
|
checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
@ -971,9 +1008,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.5.1"
|
version = "1.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90"
|
checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
"crossbeam-deque",
|
"crossbeam-deque",
|
||||||
|
@ -983,22 +1020,21 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon-core"
|
name = "rayon-core"
|
||||||
version = "1.9.1"
|
version = "1.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e"
|
checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"crossbeam-deque",
|
"crossbeam-deque",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
"lazy_static 1.4.0",
|
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rcue"
|
name = "rcue"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe64124d8a42e46e01d60b00707c9c741fe7efb65267cac6c8e5bfaf390e7af5"
|
checksum = "fca1481d62f18158646de2ec552dd63f8bdc5be6448389b192ba95c939df997e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
|
@ -1160,9 +1196,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.90"
|
version = "1.0.91"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "704df27628939572cd88d33f171cd6f896f4eaca85252c6e0a72d8d8287ee86f"
|
checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
|
@ -53,3 +53,4 @@ glob = "0.3.0"
|
||||||
anyhow = "1.0.45"
|
anyhow = "1.0.45"
|
||||||
serde_json = "1.0.59"
|
serde_json = "1.0.59"
|
||||||
clap = "2.33.3"
|
clap = "2.33.3"
|
||||||
|
pretty_assertions = "1.2.1"
|
||||||
|
|
|
@ -15,3 +15,15 @@ FILE "testcue.wav" WAVE
|
||||||
TITLE "Tone"
|
TITLE "Tone"
|
||||||
PERFORMER "Polochon_street"
|
PERFORMER "Polochon_street"
|
||||||
INDEX 01 0:16:69
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -71,7 +71,7 @@ fn main() -> Result<()> {
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| !analyzed_paths.contains(&PathBuf::from(p)))
|
.filter(|p| !analyzed_paths.contains(&PathBuf::from(p)))
|
||||||
.map(|p| p.to_owned())
|
.map(|p| p.to_owned())
|
||||||
.collect(),
|
.collect::<Vec<String>>(),
|
||||||
);
|
);
|
||||||
let first_song = Song::from_path(file)?;
|
let first_song = Song::from_path(file)?;
|
||||||
let mut analyzed_songs = vec![first_song.to_owned()];
|
let mut analyzed_songs = vec![first_song.to_owned()];
|
||||||
|
|
321
src/cue.rs
Normal file
321
src/cue.rs
Normal file
|
@ -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<f32>,
|
||||||
|
album: Option<String>,
|
||||||
|
artist: Option<String>,
|
||||||
|
genre: Option<String>,
|
||||||
|
tracks: Vec<Track>,
|
||||||
|
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<P: AsRef<Path>>(path: P) -> BlissResult<Vec<BlissResult<Song>>> {
|
||||||
|
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<P: AsRef<Path>>(path: P) -> BlissResult<Self> {
|
||||||
|
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<BlissResult<BlissCueFile>> {
|
||||||
|
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<Analysis>,
|
||||||
|
current_track: &Track,
|
||||||
|
duration: Duration,
|
||||||
|
index: usize,
|
||||||
|
) -> BlissResult<Song> {
|
||||||
|
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<BlissResult<Song>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
117
src/lib.rs
117
src/lib.rs
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! bliss is a library for making "smart" audio playlists.
|
//! 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
|
//! specific analyzed song and contains its path, title, analysis, and
|
||||||
//! other metadata fields (album, genre...).
|
//! other metadata fields (album, genre...).
|
||||||
//! Analyzing a song is as simple as running `Song::from_path("/path/to/song")`.
|
//! Analyzing a song is as simple as running `Song::from_path("/path/to/song")`.
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
//!
|
//!
|
||||||
//! # Examples
|
//! # Examples
|
||||||
//!
|
//!
|
||||||
//! ## Analyze & compute the distance between two songs
|
//! ### Analyze & compute the distance between two songs
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use bliss_audio::{BlissResult, Song};
|
//! 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
|
//! ```no_run
|
||||||
//! use bliss_audio::{BlissResult, Song};
|
//! use bliss_audio::{analyze_paths, BlissResult, Song};
|
||||||
//! use noisy_float::prelude::n32;
|
//! use noisy_float::prelude::n32;
|
||||||
//!
|
//!
|
||||||
//! fn main() -> BlissResult<()> {
|
//! fn main() -> BlissResult<()> {
|
||||||
//! let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"];
|
//! let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"];
|
||||||
//! let mut songs: Vec<Song> = paths
|
//! let mut songs: Vec<Song> = analyze_paths(&paths)
|
||||||
//! .iter()
|
//! .filter_map(|(_, s)| s.ok())
|
||||||
//! .map(|path| Song::from_path(path))
|
//! .collect();
|
||||||
//! .collect::<BlissResult<Vec<Song>>>()?;
|
|
||||||
//!
|
//!
|
||||||
//! // Assuming there is a first song
|
//! // Assuming there is a first song
|
||||||
//! let first_song = songs.first().unwrap().to_owned();
|
//! let first_song = songs.first().unwrap().to_owned();
|
||||||
|
@ -60,6 +59,7 @@
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![warn(rustdoc::missing_doc_code_examples)]
|
#![warn(rustdoc::missing_doc_code_examples)]
|
||||||
mod chroma;
|
mod chroma;
|
||||||
|
pub mod cue;
|
||||||
mod misc;
|
mod misc;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
mod song;
|
mod song;
|
||||||
|
@ -72,12 +72,14 @@ extern crate num_cpus;
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate serde;
|
extern crate serde;
|
||||||
|
use crate::cue::BlissCue;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use song::{Analysis, AnalysisIndex, BlissCue, BlissCueFile, Song, NUMBER_FEATURES};
|
pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES};
|
||||||
|
|
||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
const SAMPLE_RATE: u32 = 22050;
|
const SAMPLE_RATE: u32 = 22050;
|
||||||
|
@ -105,19 +107,34 @@ pub enum BlissError {
|
||||||
pub type BlissResult<T> = Result<T, BlissError>;
|
pub type BlissResult<T> = Result<T, BlissError>;
|
||||||
|
|
||||||
/// Analyze songs in `paths`, and return the analyzed [Song] objects through an
|
/// 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
|
/// Returns an iterator, whose items are a tuple made of
|
||||||
/// the song path (to display to the user in case the analysis failed),
|
/// the song path (to display to the user in case the analysis failed),
|
||||||
/// and a Result<Song>.
|
/// and a Result<Song>.
|
||||||
///
|
///
|
||||||
/// * 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
|
/// ```no_run
|
||||||
/// use bliss_audio::{analyze_paths, BlissResult};
|
/// use bliss_audio::{analyze_paths, BlissResult};
|
||||||
///
|
///
|
||||||
/// fn main() -> BlissResult<()> {
|
/// fn main() -> BlissResult<()> {
|
||||||
/// let paths = vec![String::from("/path/to/song1"), String::from("/path/to/song2")];
|
/// 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 {
|
/// match result {
|
||||||
/// Ok(song) => println!("Do something with analyzed song {} with title {:?}", song.path.display(), song.title),
|
/// 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),
|
/// Err(e) => println!("Song at {} could not be analyzed. Failed with: {}", path, e),
|
||||||
|
@ -126,9 +143,11 @@ pub type BlissResult<T> = Result<T, BlissError>;
|
||||||
/// Ok(())
|
/// Ok(())
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn analyze_paths(paths: Vec<String>) -> mpsc::IntoIter<(String, BlissResult<Song>)> {
|
pub fn analyze_paths<P: Into<PathBuf>, F: IntoIterator<Item = P>>(
|
||||||
|
paths: F,
|
||||||
|
) -> mpsc::IntoIter<(String, BlissResult<Song>)> {
|
||||||
let num_cpus = num_cpus::get();
|
let num_cpus = num_cpus::get();
|
||||||
|
let paths: Vec<PathBuf> = paths.into_iter().map(|p| p.into()).collect();
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
let (tx, rx): (
|
let (tx, rx): (
|
||||||
mpsc::Sender<(String, BlissResult<Song>)>,
|
mpsc::Sender<(String, BlissResult<Song>)>,
|
||||||
|
@ -142,15 +161,34 @@ pub fn analyze_paths(paths: Vec<String>) -> mpsc::IntoIter<(String, BlissResult<
|
||||||
if chunk_length == 0 {
|
if chunk_length == 0 {
|
||||||
chunk_length = paths.len();
|
chunk_length = paths.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
for chunk in paths.chunks(chunk_length) {
|
for chunk in paths.chunks(chunk_length) {
|
||||||
let tx_thread = tx.clone();
|
let tx_thread = tx.clone();
|
||||||
let owned_chunk = chunk.to_owned();
|
let owned_chunk = chunk.to_owned();
|
||||||
let child = thread::spawn(move || {
|
let child = thread::spawn(move || {
|
||||||
for path in owned_chunk {
|
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);
|
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);
|
handles.push(child);
|
||||||
|
@ -162,6 +200,8 @@ pub fn analyze_paths(paths: Vec<String>) -> mpsc::IntoIter<(String, BlissResult<
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(test)]
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_send_song() {
|
fn test_send_song() {
|
||||||
|
@ -179,24 +219,53 @@ mod tests {
|
||||||
fn test_analyze_paths() {
|
fn test_analyze_paths() {
|
||||||
let paths = vec![
|
let paths = vec![
|
||||||
String::from("./data/s16_mono_22_5kHz.flac"),
|
String::from("./data/s16_mono_22_5kHz.flac"),
|
||||||
|
String::from("./data/testcue.cue"),
|
||||||
String::from("./data/white_noise.flac"),
|
String::from("./data/white_noise.flac"),
|
||||||
String::from("definitely-not-existing.foo"),
|
String::from("definitely-not-existing.foo"),
|
||||||
String::from("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 {
|
.map(|x| match &x.1 {
|
||||||
Ok(s) => (true, s.path.to_string_lossy().to_string()),
|
Ok(s) => (true, s.path.to_string_lossy().to_string(), None),
|
||||||
Err(_) => (false, x.0.to_owned()),
|
Err(e) => (false, x.0.to_owned(), Some(e.to_string())),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
results.sort();
|
results.sort();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
results,
|
results,
|
||||||
vec![
|
vec![
|
||||||
(false, String::from("definitely-not-existing.foo")),
|
(
|
||||||
(false, String::from("not-existing.foo")),
|
false,
|
||||||
(true, String::from("./data/s16_mono_22_5kHz.flac")),
|
String::from("./data/testcue.cue"),
|
||||||
(true, String::from("./data/white_noise.flac")),
|
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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
349
src/song.rs
349
src/song.rs
|
@ -13,6 +13,7 @@ extern crate ndarray;
|
||||||
extern crate ndarray_npy;
|
extern crate ndarray_npy;
|
||||||
|
|
||||||
use crate::chroma::ChromaDesc;
|
use crate::chroma::ChromaDesc;
|
||||||
|
use crate::cue::CueInfo;
|
||||||
use crate::misc::LoudnessDesc;
|
use crate::misc::LoudnessDesc;
|
||||||
#[cfg(doc)]
|
#[cfg(doc)]
|
||||||
use crate::playlist;
|
use crate::playlist;
|
||||||
|
@ -34,8 +35,6 @@ use ffmpeg_next::util::frame::audio::Audio;
|
||||||
use ffmpeg_next::util::log;
|
use ffmpeg_next::util::log;
|
||||||
use ffmpeg_next::util::log::level::Level;
|
use ffmpeg_next::util::log::level::Level;
|
||||||
use ndarray::{arr1, Array1};
|
use ndarray::{arr1, Array1};
|
||||||
use rcue::cue::{Cue, Track};
|
|
||||||
use rcue::parser::parse_from_file;
|
|
||||||
use std::convert::TryInto;
|
use std::convert::TryInto;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
@ -74,6 +73,12 @@ pub struct Song {
|
||||||
/// A simple integer that is bumped every time a breaking change
|
/// A simple integer that is bumped every time a breaking change
|
||||||
/// is introduced in the features.
|
/// is introduced in the features.
|
||||||
pub features_version: u16,
|
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<track_number>`. Using this field,
|
||||||
|
/// you can change `song.path` to fit your needs.
|
||||||
|
pub cue_info: Option<CueInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, EnumIter, EnumCount)]
|
#[derive(Debug, EnumIter, EnumCount)]
|
||||||
|
@ -137,7 +142,7 @@ pub const NUMBER_FEATURES: usize = AnalysisIndex::COUNT;
|
||||||
/// on most of the features, except the chroma ones, which are documented
|
/// on most of the features, except the chroma ones, which are documented
|
||||||
/// directly in this code.
|
/// directly in this code.
|
||||||
pub struct Analysis {
|
pub struct Analysis {
|
||||||
internal_analysis: [f32; NUMBER_FEATURES],
|
pub(crate) internal_analysis: [f32; NUMBER_FEATURES],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Index<AnalysisIndex> for Analysis {
|
impl Index<AnalysisIndex> for Analysis {
|
||||||
|
@ -204,133 +209,6 @@ impl Analysis {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct BlissCue {
|
|
||||||
cue: Cue,
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct BlissCueFile {
|
|
||||||
sample_array: Vec<f32>,
|
|
||||||
album: Option<String>,
|
|
||||||
artist: Option<String>,
|
|
||||||
genre: Option<String>,
|
|
||||||
tracks: Vec<Track>,
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BlissCue {
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub fn from_path<P: AsRef<Path> + std::fmt::Debug>(path: P) -> BlissResult<Self> {
|
|
||||||
let cue = parse_from_file(&path.as_ref().to_string_lossy(), false).map_err(|e| {
|
|
||||||
BlissError::DecodingError(format!("when opening CUE file '{:?}': {:?}", path, e))
|
|
||||||
})?;
|
|
||||||
Ok(BlissCue {
|
|
||||||
cue,
|
|
||||||
path: path.as_ref().to_string_lossy().to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub fn files(&self) -> BlissResult<Vec<BlissCueFile>> {
|
|
||||||
let mut cue_files = Vec::new();
|
|
||||||
for cue_file in self.cue.files.iter() {
|
|
||||||
let cue_path = match Path::new(&self.path).parent() {
|
|
||||||
Some(parent) => format!("{}/{}", parent.to_string_lossy(), cue_file.file),
|
|
||||||
None => cue_file.file.to_string(),
|
|
||||||
};
|
|
||||||
let genre = self
|
|
||||||
.cue
|
|
||||||
.comments
|
|
||||||
.iter()
|
|
||||||
.find(|(c, _)| c == "GENRE")
|
|
||||||
.map(|(_, v)| v.to_owned());
|
|
||||||
let sample_array = Song::decode(Path::new(&cue_path))?.sample_array;
|
|
||||||
let bliss_cue_file = BlissCueFile {
|
|
||||||
sample_array,
|
|
||||||
genre,
|
|
||||||
artist: self.cue.performer.to_owned(),
|
|
||||||
album: self.cue.title.to_owned(),
|
|
||||||
tracks: cue_file.tracks.to_owned(),
|
|
||||||
path: cue_path,
|
|
||||||
};
|
|
||||||
cue_files.push(bliss_cue_file)
|
|
||||||
}
|
|
||||||
Ok(cue_files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BlissCueFile {
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub fn get_songs(&self) -> Vec<BlissResult<Song>> {
|
|
||||||
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) {
|
|
||||||
// TODO remove unwraps here
|
|
||||||
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;
|
|
||||||
// TODO to_vec, really?
|
|
||||||
let analysis =
|
|
||||||
Song::analyze(self.sample_array[start_current..end_current].to_vec());
|
|
||||||
if let Ok(a) = analysis {
|
|
||||||
let song = Song {
|
|
||||||
path: PathBuf::from(format!("{}/CUE_TRACK{:03}", self.path, index + 1)),
|
|
||||||
album: self.album.to_owned(),
|
|
||||||
artist: current_track.performer.to_owned(),
|
|
||||||
album_artist: self.artist.to_owned(),
|
|
||||||
analysis: a,
|
|
||||||
duration: Duration::from_secs_f32(
|
|
||||||
(end_current - start_current) as f32 / SAMPLE_RATE as f32,
|
|
||||||
),
|
|
||||||
genre: self.genre.to_owned(),
|
|
||||||
title: current_track.title,
|
|
||||||
track_number: Some(current_track.no),
|
|
||||||
features_version: FEATURES_VERSION,
|
|
||||||
};
|
|
||||||
songs.push(Ok(song));
|
|
||||||
} else {
|
|
||||||
songs.push(Err(analysis.unwrap_err()))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO put into a function
|
|
||||||
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 analysis = Song::analyze(self.sample_array[start_current..].to_vec());
|
|
||||||
if let Ok(a) = analysis {
|
|
||||||
let song = Song {
|
|
||||||
path: PathBuf::from(format!(
|
|
||||||
"{}/CUE_TRACK{:03}",
|
|
||||||
self.path,
|
|
||||||
self.tracks.len()
|
|
||||||
)),
|
|
||||||
album: self.album.to_owned(),
|
|
||||||
artist: self.artist.to_owned(),
|
|
||||||
album_artist: self.artist.to_owned(),
|
|
||||||
analysis: a,
|
|
||||||
duration: Duration::from_secs_f32(
|
|
||||||
(self.sample_array.len() - start_current) as f32 / SAMPLE_RATE as f32,
|
|
||||||
),
|
|
||||||
genre: self.genre.to_owned(),
|
|
||||||
title: last_track.title.to_owned(),
|
|
||||||
track_number: Some(last_track.no.to_owned()),
|
|
||||||
features_version: FEATURES_VERSION,
|
|
||||||
};
|
|
||||||
songs.push(Ok(song));
|
|
||||||
} else {
|
|
||||||
songs.push(Err(analysis.unwrap_err()))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
songs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Song {
|
impl Song {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
/// Compute the distance between the current song and any given
|
/// Compute the distance between the current song and any given
|
||||||
|
@ -430,8 +308,9 @@ impl Song {
|
||||||
track_number: raw_song.track_number,
|
track_number: raw_song.track_number,
|
||||||
genre: raw_song.genre,
|
genre: raw_song.genre,
|
||||||
duration: raw_song.duration,
|
duration: raw_song.duration,
|
||||||
analysis: Song::analyze(raw_song.sample_array)?,
|
analysis: Song::analyze(&raw_song.sample_array)?,
|
||||||
features_version: FEATURES_VERSION,
|
features_version: FEATURES_VERSION,
|
||||||
|
cue_info: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -446,7 +325,7 @@ impl Song {
|
||||||
* Useful in the rare cases where the full song is not
|
* Useful in the rare cases where the full song is not
|
||||||
* completely available.
|
* completely available.
|
||||||
**/
|
**/
|
||||||
fn analyze(sample_array: Vec<f32>) -> BlissResult<Analysis> {
|
pub(crate) fn analyze(sample_array: &[f32]) -> BlissResult<Analysis> {
|
||||||
let largest_window = vec![
|
let largest_window = vec![
|
||||||
BPMDesc::WINDOW_SIZE,
|
BPMDesc::WINDOW_SIZE,
|
||||||
ChromaDesc::WINDOW_SIZE,
|
ChromaDesc::WINDOW_SIZE,
|
||||||
|
@ -477,7 +356,7 @@ impl Song {
|
||||||
|
|
||||||
let child_chroma: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> = s.spawn(|_| {
|
let child_chroma: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> = s.spawn(|_| {
|
||||||
let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
|
let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
|
||||||
chroma_desc.do_(&sample_array)?;
|
chroma_desc.do_(sample_array)?;
|
||||||
Ok(chroma_desc.get_values())
|
Ok(chroma_desc.get_values())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -501,7 +380,7 @@ impl Song {
|
||||||
|
|
||||||
let child_zcr: thread::ScopedJoinHandle<'_, BlissResult<f32>> = s.spawn(|_| {
|
let child_zcr: thread::ScopedJoinHandle<'_, BlissResult<f32>> = s.spawn(|_| {
|
||||||
let mut zcr_desc = ZeroCrossingRateDesc::default();
|
let mut zcr_desc = ZeroCrossingRateDesc::default();
|
||||||
zcr_desc.do_(&sample_array);
|
zcr_desc.do_(sample_array);
|
||||||
Ok(zcr_desc.get_value())
|
Ok(zcr_desc.get_value())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -542,33 +421,55 @@ impl Song {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decode(path: &Path) -> BlissResult<InternalSong> {
|
pub(crate) fn decode(path: &Path) -> BlissResult<InternalSong> {
|
||||||
ffmpeg::init()
|
ffmpeg::init().map_err(|e| {
|
||||||
.map_err(|e| BlissError::DecodingError(format!("ffmpeg init error: {:?}.", e)))?;
|
BlissError::DecodingError(format!(
|
||||||
|
"ffmpeg init error while decoding file '{}': {:?}.",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
log::set_level(Level::Quiet);
|
log::set_level(Level::Quiet);
|
||||||
let mut song = InternalSong {
|
let mut song = InternalSong {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut format = ffmpeg::format::input(&path)
|
let mut format = ffmpeg::format::input(&path).map_err(|e| {
|
||||||
.map_err(|e| BlissError::DecodingError(format!("while opening format: {:?}.", e)))?;
|
BlissError::DecodingError(format!(
|
||||||
|
"while opening format for file '{}': {:?}.",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let (mut codec, stream, expected_sample_number) = {
|
let (mut codec, stream, expected_sample_number) = {
|
||||||
let stream = format
|
let stream = format
|
||||||
.streams()
|
.streams()
|
||||||
.find(|s| s.parameters().medium() == ffmpeg::media::Type::Audio)
|
.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())
|
let mut context = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
|
||||||
.map_err(|e| {
|
.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 {
|
context.set_threading(Config {
|
||||||
kind: ThreadingType::Frame,
|
kind: ThreadingType::Frame,
|
||||||
count: 0,
|
count: 0,
|
||||||
safe: true,
|
safe: true,
|
||||||
});
|
});
|
||||||
let codec = context
|
let codec = context.decoder().audio().map_err(|e| {
|
||||||
.decoder()
|
BlissError::DecodingError(format!(
|
||||||
.audio()
|
"when finding codec for file '{}': {:?}.",
|
||||||
.map_err(|e| BlissError::DecodingError(format!("when finding codec: {:?}.", e)))?;
|
path.display(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
// Add SAMPLE_RATE to have one second margin to avoid reallocating if
|
// Add SAMPLE_RATE to have one second margin to avoid reallocating if
|
||||||
// the duration is slightly more than estimated
|
// the duration is slightly more than estimated
|
||||||
// TODO>1.0 another way to get the exact number of samples is to decode
|
// TODO>1.0 another way to get the exact number of samples is to decode
|
||||||
|
@ -648,17 +549,21 @@ impl Song {
|
||||||
match codec.send_packet(&packet) {
|
match codec.send_packet(&packet) {
|
||||||
Ok(_) => (),
|
Ok(_) => (),
|
||||||
Err(Error::Other { errno: EINVAL }) => {
|
Err(Error::Other { errno: EINVAL }) => {
|
||||||
return Err(BlissError::DecodingError(String::from(
|
return Err(BlissError::DecodingError(format!(
|
||||||
"wrong codec opened.",
|
"wrong codec opened for file '{}.",
|
||||||
|
path.display(),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(Error::Eof) => {
|
Err(Error::Eof) => {
|
||||||
warn!("Premature EOF reached while decoding.");
|
warn!(
|
||||||
|
"Premature EOF reached while decoding file '{}'.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
drop(tx);
|
drop(tx);
|
||||||
song.sample_array = child.join().unwrap()?;
|
song.sample_array = child.join().unwrap()?;
|
||||||
return Ok(song);
|
return Ok(song);
|
||||||
}
|
}
|
||||||
Err(e) => warn!("error while decoding {}: {}", path.display(), e),
|
Err(e) => warn!("error while decoding file '{}': {}", path.display(), e),
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
@ -667,8 +572,9 @@ impl Song {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tx.send(decoded).map_err(|e| {
|
tx.send(decoded).map_err(|e| {
|
||||||
BlissError::DecodingError(format!(
|
BlissError::DecodingError(format!(
|
||||||
"while sending decoded frame to the resampling thread: {:?}",
|
"while sending decoded frame to the resampling thread for file '{}': {:?}",
|
||||||
e
|
path.display(),
|
||||||
|
e,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
@ -682,12 +588,16 @@ impl Song {
|
||||||
match codec.send_packet(&packet) {
|
match codec.send_packet(&packet) {
|
||||||
Ok(_) => (),
|
Ok(_) => (),
|
||||||
Err(Error::Other { errno: EINVAL }) => {
|
Err(Error::Other { errno: EINVAL }) => {
|
||||||
return Err(BlissError::DecodingError(String::from(
|
return Err(BlissError::DecodingError(format!(
|
||||||
"wrong codec opened.",
|
"wrong codec opened for file '{}'.",
|
||||||
|
path.display()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(Error::Eof) => {
|
Err(Error::Eof) => {
|
||||||
warn!("Premature EOF reached while decoding.");
|
warn!(
|
||||||
|
"Premature EOF reached while decoding file '{}'.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
drop(tx);
|
drop(tx);
|
||||||
song.sample_array = child.join().unwrap()?;
|
song.sample_array = child.join().unwrap()?;
|
||||||
return Ok(song);
|
return Ok(song);
|
||||||
|
@ -701,7 +611,8 @@ impl Song {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tx.send(decoded).map_err(|e| {
|
tx.send(decoded).map_err(|e| {
|
||||||
BlissError::DecodingError(format!(
|
BlissError::DecodingError(format!(
|
||||||
"while sending decoded frame to the resampling thread: {:?}",
|
"while sending decoded frame to the resampling thread for file '{}': {:?}",
|
||||||
|
path.display(),
|
||||||
e
|
e
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
@ -807,18 +718,19 @@ fn push_to_sample_array(frame: &ffmpeg::frame::Audio, sample_array: &mut Vec<f32
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
use ripemd160::{Digest, Ripemd160};
|
use ripemd160::{Digest, Ripemd160};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_analysis_too_small() {
|
fn test_analysis_too_small() {
|
||||||
let error = Song::analyze(vec![0.]).unwrap_err();
|
let error = Song::analyze(&[0.]).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
error,
|
error,
|
||||||
BlissError::AnalysisError(String::from("empty or too short song."))
|
BlissError::AnalysisError(String::from("empty or too short song."))
|
||||||
);
|
);
|
||||||
|
|
||||||
let error = Song::analyze(vec![]).unwrap_err();
|
let error = Song::analyze(&[]).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
error,
|
error,
|
||||||
BlissError::AnalysisError(String::from("empty or too short song."))
|
BlissError::AnalysisError(String::from("empty or too short song."))
|
||||||
|
@ -1003,12 +915,14 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Song::decode(Path::new("nonexistent")).unwrap_err(),
|
Song::decode(Path::new("nonexistent")).unwrap_err(),
|
||||||
BlissError::DecodingError(String::from(
|
BlissError::DecodingError(String::from(
|
||||||
"while opening format: ffmpeg::Error(2: No such file or directory)."
|
"while opening format for file 'nonexistent': ffmpeg::Error(2: No such file or directory)."
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Song::decode(Path::new("data/picture.png")).unwrap_err(),
|
Song::decode(Path::new("data/picture.png")).unwrap_err(),
|
||||||
BlissError::DecodingError(String::from("No audio stream found.")),
|
BlissError::DecodingError(String::from(
|
||||||
|
"No audio stream found for file 'data/picture.png'."
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1138,125 +1052,6 @@ mod tests {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cue_analysis() {
|
|
||||||
let cue = BlissCue::from_path(Path::new("data/testcue.cue")).unwrap();
|
|
||||||
let cue_files = cue.files().unwrap();
|
|
||||||
let cue_file = cue_files.first().unwrap();
|
|
||||||
let songs = cue_file.get_songs();
|
|
||||||
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),
|
|
||||||
..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),
|
|
||||||
..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),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
assert_eq!(expected, songs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "bench", test))]
|
#[cfg(all(feature = "bench", test))]
|
||||||
|
|
Loading…
Reference in a new issue