Remove library trait; move things in playlist.rs
This commit is contained in:
parent
3a4bac5a34
commit
d2124a3b2c
6 changed files with 361 additions and 929 deletions
|
@ -1,6 +1,9 @@
|
||||||
#Changelog
|
#Changelog
|
||||||
|
|
||||||
## bliss 0.5.0
|
## bliss 0.5.0
|
||||||
|
* Remove the unusued Library trait, and extract a few useful functions from
|
||||||
|
there (`analyze_paths`, `closest_to_album_group`.
|
||||||
|
* Rename `distance` module to `playlist`.
|
||||||
* Remove all traces of the "analyse" word vs "analyze" to make the codebase
|
* Remove all traces of the "analyse" word vs "analyze" to make the codebase
|
||||||
more coherent.
|
more coherent.
|
||||||
* Rename `Song::new` to `Song::from_path`.
|
* Rename `Song::new` to `Song::from_path`.
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use bliss_audio::distance::{closest_to_first_song, dedup_playlist, euclidean_distance};
|
use bliss_audio::playlist::{closest_to_first_song, dedup_playlist, euclidean_distance};
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use bliss_audio::{library::analyze_paths_streaming, Song};
|
use bliss_audio::{analyze_paths, Song};
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use clap::{App, Arg};
|
use clap::{App, Arg};
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
|
@ -66,16 +66,16 @@ fn main() -> Result<()> {
|
||||||
.map(|x| x.to_string_lossy().to_string())
|
.map(|x| x.to_string_lossy().to_string())
|
||||||
.collect::<Vec<String>>();
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
let rx = analyze_paths_streaming(
|
let song_iterator = analyze_paths(
|
||||||
paths
|
paths
|
||||||
.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(),
|
||||||
)?;
|
);
|
||||||
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()];
|
||||||
for (path, result) in rx.iter() {
|
for (path, result) in song_iterator {
|
||||||
match result {
|
match result {
|
||||||
Ok(song) => analyzed_songs.push(song),
|
Ok(song) => analyzed_songs.push(song),
|
||||||
Err(e) => println!("error analyzing {}: {}", path, e),
|
Err(e) => println!("error analyzing {}: {}", path, e),
|
||||||
|
|
155
src/lib.rs
155
src/lib.rs
|
@ -7,19 +7,14 @@
|
||||||
//! 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")`.
|
||||||
//!
|
//!
|
||||||
//! The [analysis](Song::analysis) field of each song is an array of f32, which makes the
|
//! The [analysis](Song::analysis) field of each song is an array of f32, which
|
||||||
//! comparison between songs easy, by just using euclidean distance (see
|
//! makes the comparison between songs easy, by just using e.g. euclidean
|
||||||
//! [distance](Song::distance) for instance).
|
//! distance (see [distance](Song::distance) for instance).
|
||||||
//!
|
//!
|
||||||
//! Once several songs have been analyzed, making a playlist from one Song
|
//! Once several songs have been analyzed, making a playlist from one Song
|
||||||
//! is as easy as computing distances between that song and the rest, and ordering
|
//! is as easy as computing distances between that song and the rest, and ordering
|
||||||
//! the songs by distance, ascending.
|
//! the songs by distance, ascending.
|
||||||
//!
|
//!
|
||||||
//! It is also convenient to make plug-ins for existing audio players.
|
|
||||||
//! It should be as easy as implementing the necessary traits for [Library].
|
|
||||||
//! A reference implementation for the MPD player is available
|
|
||||||
//! [here](https://github.com/Polochon-street/blissify-rs)
|
|
||||||
//!
|
|
||||||
//! # Examples
|
//! # Examples
|
||||||
//!
|
//!
|
||||||
//! ## Analyze & compute the distance between two songs
|
//! ## Analyze & compute the distance between two songs
|
||||||
|
@ -65,9 +60,8 @@
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![warn(rustdoc::missing_doc_code_examples)]
|
#![warn(rustdoc::missing_doc_code_examples)]
|
||||||
mod chroma;
|
mod chroma;
|
||||||
pub mod distance;
|
|
||||||
pub mod library;
|
|
||||||
mod misc;
|
mod misc;
|
||||||
|
pub mod playlist;
|
||||||
mod song;
|
mod song;
|
||||||
mod temporal;
|
mod temporal;
|
||||||
mod timbral;
|
mod timbral;
|
||||||
|
@ -78,9 +72,11 @@ extern crate num_cpus;
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate serde;
|
extern crate serde;
|
||||||
|
use log::info;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use library::Library;
|
|
||||||
pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES};
|
pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES};
|
||||||
|
|
||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
|
@ -94,54 +90,73 @@ pub const FEATURES_VERSION: u16 = 1;
|
||||||
/// Umbrella type for bliss error types
|
/// Umbrella type for bliss error types
|
||||||
pub enum BlissError {
|
pub enum BlissError {
|
||||||
#[error("error happened while decoding file – {0}")]
|
#[error("error happened while decoding file – {0}")]
|
||||||
/// An error happened while decoding an (audio) file
|
/// An error happened while decoding an (audio) file.
|
||||||
DecodingError(String),
|
DecodingError(String),
|
||||||
#[error("error happened while analyzing file – {0}")]
|
#[error("error happened while analyzing file – {0}")]
|
||||||
/// An error happened during the analysis of the samples by bliss
|
/// An error happened during the analysis of the song's samples by bliss.
|
||||||
AnalysisError(String),
|
AnalysisError(String),
|
||||||
#[error("error happened with the music library provider - {0}")]
|
#[error("error happened with the music library provider - {0}")]
|
||||||
/// An error happened with the music library provider.
|
/// An error happened with the music library provider.
|
||||||
/// Useful to report errors when you implement the [Library] trait.
|
/// Useful to report errors when you implement bliss for an audio player.
|
||||||
ProviderError(String),
|
ProviderError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// bliss error type
|
/// bliss error type
|
||||||
pub type BlissResult<T> = Result<T, BlissError>;
|
pub type BlissResult<T> = Result<T, BlissError>;
|
||||||
|
|
||||||
/// Simple function to bulk analyze a set of songs represented by their
|
/// Analyze songs in `paths`, and return the analyzed [Song] objects through an
|
||||||
/// absolute paths.
|
/// [mpsc::IntoIter]
|
||||||
///
|
///
|
||||||
/// When making an extension for an audio player, prefer
|
/// Returns an iterator, whose items are a tuple made of
|
||||||
/// implementing the `Library` trait.
|
/// the song path (to display to the user in case the analysis failed),
|
||||||
#[doc(hidden)]
|
/// and a Result<Song>.
|
||||||
pub fn bulk_analyze(paths: Vec<String>) -> Vec<BlissResult<Song>> {
|
///
|
||||||
let mut songs = Vec::with_capacity(paths.len());
|
/// * 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, e),
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// Ok(())
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn analyze_paths(paths: Vec<String>) -> mpsc::IntoIter<(String, BlissResult<Song>)> {
|
||||||
let num_cpus = num_cpus::get();
|
let num_cpus = num_cpus::get();
|
||||||
|
|
||||||
crossbeam::scope(|s| {
|
#[allow(clippy::type_complexity)]
|
||||||
let mut handles = Vec::with_capacity(paths.len() / num_cpus);
|
let (tx, rx): (
|
||||||
let mut chunk_number = paths.len() / num_cpus;
|
mpsc::Sender<(String, BlissResult<Song>)>,
|
||||||
if chunk_number == 0 {
|
mpsc::Receiver<(String, BlissResult<Song>)>,
|
||||||
chunk_number = paths.len();
|
) = mpsc::channel();
|
||||||
|
if paths.is_empty() {
|
||||||
|
return rx.into_iter();
|
||||||
}
|
}
|
||||||
for chunk in paths.chunks(chunk_number) {
|
let mut handles = Vec::new();
|
||||||
handles.push(s.spawn(move |_| {
|
let mut chunk_length = paths.len() / num_cpus;
|
||||||
let mut result = Vec::with_capacity(chunk.len());
|
if chunk_length == 0 {
|
||||||
for path in chunk {
|
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);
|
||||||
let song = Song::from_path(&path);
|
let song = Song::from_path(&path);
|
||||||
result.push(song);
|
tx_thread.send((path.to_string(), song)).unwrap();
|
||||||
}
|
}
|
||||||
result
|
});
|
||||||
}));
|
handles.push(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
for handle in handles {
|
rx.into_iter()
|
||||||
songs.extend(handle.join().unwrap());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
songs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -161,52 +176,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bulk_analyze() {
|
fn test_analyze_paths() {
|
||||||
let results = bulk_analyze(vec![
|
let paths = vec![
|
||||||
String::from("data/s16_mono_22_5kHz.flac"),
|
String::from("./data/s16_mono_22_5kHz.flac"),
|
||||||
String::from("data/s16_mono_22_5kHz.flac"),
|
String::from("./data/white_noise.flac"),
|
||||||
String::from("nonexistent"),
|
String::from("definitely-not-existing.foo"),
|
||||||
String::from("data/s16_stereo_22_5kHz.flac"),
|
String::from("not-existing.foo"),
|
||||||
String::from("nonexistent"),
|
];
|
||||||
String::from("nonexistent"),
|
let mut results = analyze_paths(paths)
|
||||||
String::from("nonexistent"),
|
.map(|x| match &x.1 {
|
||||||
String::from("nonexistent"),
|
Ok(s) => (true, s.path.to_string_lossy().to_string()),
|
||||||
String::from("nonexistent"),
|
Err(_) => (false, x.0.to_owned()),
|
||||||
String::from("nonexistent"),
|
|
||||||
String::from("nonexistent"),
|
|
||||||
]);
|
|
||||||
let mut errored_songs: Vec<String> = results
|
|
||||||
.iter()
|
|
||||||
.filter_map(|x| x.as_ref().err().map(|x| x.to_string()))
|
|
||||||
.collect();
|
|
||||||
errored_songs.sort_by(|a, b| a.cmp(b));
|
|
||||||
|
|
||||||
let mut analyzed_songs: Vec<String> = results
|
|
||||||
.iter()
|
|
||||||
.filter_map(|x| {
|
|
||||||
x.as_ref()
|
|
||||||
.ok()
|
|
||||||
.map(|x| x.path.to_str().unwrap().to_string())
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
analyzed_songs.sort_by(|a, b| a.cmp(b));
|
results.sort();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
results,
|
||||||
vec![
|
vec![
|
||||||
String::from(
|
(false, String::from("definitely-not-existing.foo")),
|
||||||
"error happened while decoding file – while opening format: ffmpeg::Error(2: No such file or directory)."
|
(false, String::from("not-existing.foo")),
|
||||||
);
|
(true, String::from("./data/s16_mono_22_5kHz.flac")),
|
||||||
8
|
(true, String::from("./data/white_noise.flac")),
|
||||||
],
|
],
|
||||||
errored_songs
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
vec![
|
|
||||||
String::from("data/s16_mono_22_5kHz.flac"),
|
|
||||||
String::from("data/s16_mono_22_5kHz.flac"),
|
|
||||||
String::from("data/s16_stereo_22_5kHz.flac"),
|
|
||||||
],
|
|
||||||
analyzed_songs,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
829
src/library.rs
829
src/library.rs
|
@ -1,829 +0,0 @@
|
||||||
//! Module containing the Library trait, useful to get started to implement
|
|
||||||
//! a plug-in for an audio player.
|
|
||||||
//!
|
|
||||||
//! Looking at the [reference implementation for
|
|
||||||
//! MPD](https://github.com/Polochon-street/blissify-rs) could also be useful.
|
|
||||||
#[cfg(doc)]
|
|
||||||
use crate::distance;
|
|
||||||
use crate::distance::{closest_to_first_song, euclidean_distance, DistanceMetric};
|
|
||||||
use crate::{BlissError, BlissResult, Song};
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use ndarray::{Array, Array2, Axis};
|
|
||||||
use noisy_float::prelude::n32;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
/// Library trait to make creating plug-ins for existing audio players easier.
|
|
||||||
pub trait Library {
|
|
||||||
/// Return the absolute path of all the songs in an
|
|
||||||
/// audio player's music library.
|
|
||||||
fn get_songs_paths(&self) -> BlissResult<Vec<String>>;
|
|
||||||
/// Store an analyzed Song object in some (cold) storage, e.g.
|
|
||||||
/// a database, a file...
|
|
||||||
fn store_song(&mut self, song: &Song) -> BlissResult<()>;
|
|
||||||
/// Log and / or store that an error happened while trying to decode and
|
|
||||||
/// analyze a song.
|
|
||||||
fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()>;
|
|
||||||
/// Retrieve a list of all the stored Songs.
|
|
||||||
///
|
|
||||||
/// This should work only after having run `analyze_library` at least
|
|
||||||
/// once.
|
|
||||||
fn get_stored_songs(&self) -> BlissResult<Vec<Song>>;
|
|
||||||
|
|
||||||
/// Return a list of `number_albums` albums that are similar
|
|
||||||
/// to `album`, discarding songs that don't belong to an album.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `album` - The album the playlist will be built from.
|
|
||||||
/// * `number_albums` - The number of albums to queue.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A vector of songs, including `first_song`, that you
|
|
||||||
/// most likely want to plug in your audio player by using something like
|
|
||||||
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
|
||||||
fn playlist_from_songs_album(
|
|
||||||
&self,
|
|
||||||
first_album: &str,
|
|
||||||
playlist_length: usize,
|
|
||||||
) -> BlissResult<Vec<Song>> {
|
|
||||||
let songs = self.get_stored_songs()?;
|
|
||||||
let mut albums_analysis: HashMap<&str, Array2<f32>> = HashMap::new();
|
|
||||||
let mut albums = Vec::new();
|
|
||||||
|
|
||||||
for song in &songs {
|
|
||||||
if let Some(album) = &song.album {
|
|
||||||
if let Some(analysis) = albums_analysis.get_mut(album as &str) {
|
|
||||||
analysis
|
|
||||||
.push_row(song.analysis.as_arr1().view())
|
|
||||||
.map_err(|e| {
|
|
||||||
BlissError::ProviderError(format!("while computing distances: {}", e))
|
|
||||||
})?;
|
|
||||||
} else {
|
|
||||||
let mut array = Array::zeros((1, song.analysis.as_arr1().len()));
|
|
||||||
array.assign(&song.analysis.as_arr1());
|
|
||||||
albums_analysis.insert(album, array);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut first_analysis = None;
|
|
||||||
for (album, analysis) in albums_analysis.iter() {
|
|
||||||
let mean_analysis = analysis
|
|
||||||
.mean_axis(Axis(0))
|
|
||||||
.ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?;
|
|
||||||
let album = album.to_owned();
|
|
||||||
albums.push((album, mean_analysis.to_owned()));
|
|
||||||
if album == first_album {
|
|
||||||
first_analysis = Some(mean_analysis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if first_analysis.is_none() {
|
|
||||||
return Err(BlissError::ProviderError(format!(
|
|
||||||
"Could not find album \"{}\".",
|
|
||||||
first_album
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
albums.sort_by_key(|(_, analysis)| {
|
|
||||||
n32(euclidean_distance(
|
|
||||||
first_analysis.as_ref().unwrap(),
|
|
||||||
analysis,
|
|
||||||
))
|
|
||||||
});
|
|
||||||
let albums = albums.get(..playlist_length).unwrap_or(&albums);
|
|
||||||
let mut playlist = Vec::new();
|
|
||||||
for (album, _) in albums {
|
|
||||||
let mut al = songs
|
|
||||||
.iter()
|
|
||||||
.filter(|s| s.album.is_some() && s.album.as_ref().unwrap() == &album.to_string())
|
|
||||||
.map(|s| s.to_owned())
|
|
||||||
.collect::<Vec<Song>>();
|
|
||||||
al.sort_by(|s1, s2| {
|
|
||||||
let track_number1 = s1
|
|
||||||
.track_number
|
|
||||||
.to_owned()
|
|
||||||
.unwrap_or_else(|| String::from(""));
|
|
||||||
let track_number2 = s2
|
|
||||||
.track_number
|
|
||||||
.to_owned()
|
|
||||||
.unwrap_or_else(|| String::from(""));
|
|
||||||
if let Ok(x) = track_number1.parse::<i32>() {
|
|
||||||
if let Ok(y) = track_number2.parse::<i32>() {
|
|
||||||
return x.cmp(&y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s1.track_number.cmp(&s2.track_number)
|
|
||||||
});
|
|
||||||
playlist.extend_from_slice(&al);
|
|
||||||
}
|
|
||||||
Ok(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a list of `playlist_length` songs that are similar
|
|
||||||
/// to ``first_song``, deduplicating identical songs.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `first_song` - The song the playlist will be built from.
|
|
||||||
/// * `playlist_length` - The playlist length. If there are not enough
|
|
||||||
/// songs in the library, it will be truncated to the size of the library.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A vector of `playlist_length` songs, including `first_song`, that you
|
|
||||||
/// most likely want to plug in your audio player by using something like
|
|
||||||
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
|
||||||
// TODO return an iterator and not a Vec
|
|
||||||
fn playlist_from_song(
|
|
||||||
&self,
|
|
||||||
first_song: Song,
|
|
||||||
playlist_length: usize,
|
|
||||||
) -> BlissResult<Vec<Song>> {
|
|
||||||
let playlist = self.playlist_from_song_custom(
|
|
||||||
first_song,
|
|
||||||
playlist_length,
|
|
||||||
euclidean_distance,
|
|
||||||
closest_to_first_song,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Playlist created: {}",
|
|
||||||
playlist
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("{:?}", &s))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
Ok(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a list of songs that are similar to ``first_song``, using a
|
|
||||||
/// custom distance metric and deduplicating indentical songs.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `first_song` - The song the playlist will be built from.
|
|
||||||
/// * `playlist_length` - The playlist length. If there are not enough
|
|
||||||
/// songs in the library, it will be truncated to the size of the library.
|
|
||||||
/// * `distance` - a user-supplied valid distance metric, either taken
|
|
||||||
/// from the [distance](distance) module, or made from scratch.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A vector of `playlist_length` Songs, including `first_song`, that you
|
|
||||||
/// most likely want to plug in your audio player by using something like
|
|
||||||
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
|
||||||
///
|
|
||||||
/// # Custom distance example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use ndarray::Array1;
|
|
||||||
///
|
|
||||||
/// fn manhattan_distance(a: &Array1<f32>, b: &Array1<f32>) -> f32 {
|
|
||||||
/// (a - b).mapv(|x| x.abs()).sum()
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
fn playlist_from_song_custom_distance(
|
|
||||||
&self,
|
|
||||||
first_song: Song,
|
|
||||||
playlist_length: usize,
|
|
||||||
distance: impl DistanceMetric,
|
|
||||||
) -> BlissResult<Vec<Song>> {
|
|
||||||
let playlist = self.playlist_from_song_custom(
|
|
||||||
first_song,
|
|
||||||
playlist_length,
|
|
||||||
distance,
|
|
||||||
closest_to_first_song,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Playlist created: {}",
|
|
||||||
playlist
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("{:?}", &s))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
Ok(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a playlist of songs, starting with `first_song`, sorted using
|
|
||||||
/// the custom `sort` function, and the custom `distance` metric.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `first_song` - The song the playlist will be built from.
|
|
||||||
/// * `playlist_length` - The playlist length. If there are not enough
|
|
||||||
/// songs in the library, it will be truncated to the size of the library.
|
|
||||||
/// * `distance` - a user-supplied valid distance metric, either taken
|
|
||||||
/// from the [distance](distance) module, or made from scratch.
|
|
||||||
/// * `sort` - a user-supplied sorting function that uses the `distance`
|
|
||||||
/// metric, either taken from the [distance module](distance), or made
|
|
||||||
/// from scratch.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A vector of `playlist_length` Songs, including `first_song`, that you
|
|
||||||
/// most likely want to plug in your audio player by using something like
|
|
||||||
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
|
||||||
fn playlist_from_song_custom<F, G>(
|
|
||||||
&self,
|
|
||||||
first_song: Song,
|
|
||||||
playlist_length: usize,
|
|
||||||
distance: G,
|
|
||||||
mut sort: F,
|
|
||||||
) -> BlissResult<Vec<Song>>
|
|
||||||
where
|
|
||||||
F: FnMut(&Song, &mut Vec<Song>, G),
|
|
||||||
G: DistanceMetric,
|
|
||||||
{
|
|
||||||
let mut songs = self.get_stored_songs()?;
|
|
||||||
sort(&first_song, &mut songs, distance);
|
|
||||||
Ok(songs
|
|
||||||
.into_iter()
|
|
||||||
.take(playlist_length)
|
|
||||||
.collect::<Vec<Song>>())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analyze and store songs in `paths`, using `store_song` and
|
|
||||||
/// `store_error_song` implementations.
|
|
||||||
///
|
|
||||||
/// note: this is mostly useful for updating a song library. for the first
|
|
||||||
/// run, you probably want to use `analyze_library`.
|
|
||||||
fn analyze_paths(&mut self, paths: Vec<String>) -> BlissResult<()> {
|
|
||||||
if paths.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let num_cpus = num_cpus::get();
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
let (tx, rx): (
|
|
||||||
Sender<(String, BlissResult<Song>)>,
|
|
||||||
Receiver<(String, BlissResult<Song>)>,
|
|
||||||
) = mpsc::channel();
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
let mut chunk_length = paths.len() / num_cpus;
|
|
||||||
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);
|
|
||||||
let song = Song::from_path(&path);
|
|
||||||
tx_thread.send((path.to_string(), song)).unwrap();
|
|
||||||
}
|
|
||||||
drop(tx_thread);
|
|
||||||
});
|
|
||||||
handles.push(child);
|
|
||||||
}
|
|
||||||
drop(tx);
|
|
||||||
|
|
||||||
for (path, song) in rx.iter() {
|
|
||||||
// A storage fail should just warn the user, but not abort the whole process
|
|
||||||
match song {
|
|
||||||
Ok(song) => {
|
|
||||||
self.store_song(&song).unwrap_or_else(|e| {
|
|
||||||
error!("Error while storing song '{}': {}", song.path.display(), e)
|
|
||||||
});
|
|
||||||
info!(
|
|
||||||
"Analyzed and stored song '{}' successfully.",
|
|
||||||
song.path.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.store_error_song(path.to_string(), e.to_owned())
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
error!("Error while storing errored song '{}': {}", path, e)
|
|
||||||
});
|
|
||||||
error!(
|
|
||||||
"Analysis of song '{}': {} failed. Error has been stored.",
|
|
||||||
path, e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for child in handles {
|
|
||||||
child
|
|
||||||
.join()
|
|
||||||
.map_err(|_| BlissError::AnalysisError("in analysis".to_string()))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analyzes a song library, using `get_songs_paths`, `store_song` and
|
|
||||||
/// `store_error_song` implementations.
|
|
||||||
fn analyze_library(&mut self) -> BlissResult<()> {
|
|
||||||
let paths = self
|
|
||||||
.get_songs_paths()
|
|
||||||
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
|
|
||||||
self.analyze_paths(paths)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analyze an entire library using `get_songs_paths`, but instead of
|
|
||||||
/// storing songs using [store_song](Library::store_song)
|
|
||||||
/// and [store_error_song](Library::store_error_song).
|
|
||||||
///
|
|
||||||
/// Returns an iterable [Receiver], whose items are a tuple made of
|
|
||||||
/// the song path (to display to the user in case the analysis failed),
|
|
||||||
/// and a Result<Song>.
|
|
||||||
fn analyze_library_streaming(&mut self) -> BlissResult<Receiver<(String, BlissResult<Song>)>> {
|
|
||||||
let paths = self
|
|
||||||
.get_songs_paths()
|
|
||||||
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
|
|
||||||
analyze_paths_streaming(paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analyze songs in `paths`, and return the analyzed [Song] objects through a
|
|
||||||
/// [Receiver].
|
|
||||||
///
|
|
||||||
/// Returns an iterable [Receiver], whose items are a tuple made of
|
|
||||||
/// the song path (to display to the user in case the analysis failed),
|
|
||||||
/// and a Result<Song>.
|
|
||||||
///
|
|
||||||
/// Note: this is mostly useful for updating a song library, while displaying
|
|
||||||
/// status to the user (since you have access to each song object). For the
|
|
||||||
/// first run, you probably want to use `analyze_library`.
|
|
||||||
///
|
|
||||||
/// * Example:
|
|
||||||
/// ```no_run
|
|
||||||
/// use bliss_audio::{library::analyze_paths_streaming, BlissResult};
|
|
||||||
///
|
|
||||||
/// fn main() -> BlissResult<()> {
|
|
||||||
/// let paths = vec![String::from("/path/to/song1"), String::from("/path/to/song2")];
|
|
||||||
/// let rx = analyze_paths_streaming(paths)?;
|
|
||||||
/// for (path, result) in rx.iter() {
|
|
||||||
/// 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),
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub fn analyze_paths_streaming(
|
|
||||||
paths: Vec<String>,
|
|
||||||
) -> BlissResult<Receiver<(String, BlissResult<Song>)>> {
|
|
||||||
let num_cpus = num_cpus::get();
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
let (tx, rx): (
|
|
||||||
Sender<(String, BlissResult<Song>)>,
|
|
||||||
Receiver<(String, BlissResult<Song>)>,
|
|
||||||
) = mpsc::channel();
|
|
||||||
if paths.is_empty() {
|
|
||||||
return Ok(rx);
|
|
||||||
}
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
let mut chunk_length = paths.len() / num_cpus;
|
|
||||||
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);
|
|
||||||
let song = Song::from_path(&path);
|
|
||||||
tx_thread.send((path.to_string(), song)).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handles.push(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
use crate::song::Analysis;
|
|
||||||
use ndarray::Array1;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TestLibrary {
|
|
||||||
internal_storage: Vec<Song>,
|
|
||||||
failed_files: Vec<(String, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Library for TestLibrary {
|
|
||||||
fn get_songs_paths(&self) -> BlissResult<Vec<String>> {
|
|
||||||
Ok(vec![
|
|
||||||
String::from("./data/white_noise.flac"),
|
|
||||||
String::from("./data/s16_mono_22_5kHz.flac"),
|
|
||||||
String::from("not-existing.foo"),
|
|
||||||
String::from("definitely-not-existing.foo"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_song(&mut self, song: &Song) -> BlissResult<()> {
|
|
||||||
self.internal_storage.push(song.to_owned());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()> {
|
|
||||||
self.failed_files.push((song_path, error.to_string()));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_stored_songs(&self) -> BlissResult<Vec<Song>> {
|
|
||||||
Ok(self.internal_storage.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FailingLibrary;
|
|
||||||
|
|
||||||
impl Library for FailingLibrary {
|
|
||||||
fn get_songs_paths(&self) -> BlissResult<Vec<String>> {
|
|
||||||
Err(BlissError::ProviderError(String::from(
|
|
||||||
"Could not get songs path",
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_song(&mut self, _: &Song) -> BlissResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_stored_songs(&self) -> BlissResult<Vec<Song>> {
|
|
||||||
Err(BlissError::ProviderError(String::from(
|
|
||||||
"Could not get stored songs",
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_error_song(&mut self, _: String, _: BlissError) -> BlissResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FailingStorage;
|
|
||||||
|
|
||||||
impl Library for FailingStorage {
|
|
||||||
fn get_songs_paths(&self) -> BlissResult<Vec<String>> {
|
|
||||||
Ok(vec![
|
|
||||||
String::from("./data/white_noise.flac"),
|
|
||||||
String::from("./data/s16_mono_22_5kHz.flac"),
|
|
||||||
String::from("not-existing.foo"),
|
|
||||||
String::from("definitely-not-existing.foo"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_song(&mut self, song: &Song) -> BlissResult<()> {
|
|
||||||
Err(BlissError::ProviderError(format!(
|
|
||||||
"Could not store song {}",
|
|
||||||
song.path.display()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_stored_songs(&self) -> BlissResult<Vec<Song>> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()> {
|
|
||||||
Err(BlissError::ProviderError(format!(
|
|
||||||
"Could not store errored song: {}, with error: {}",
|
|
||||||
song_path, error
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_analyze_library_fail() {
|
|
||||||
let mut test_library = FailingLibrary {};
|
|
||||||
assert_eq!(
|
|
||||||
test_library.analyze_library(),
|
|
||||||
Err(BlissError::ProviderError(String::from(
|
|
||||||
"error happened with the music library provider - Could not get songs path"
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_song_fail() {
|
|
||||||
let test_library = FailingLibrary {};
|
|
||||||
let song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
test_library.playlist_from_song(song, 10),
|
|
||||||
Err(BlissError::ProviderError(String::from(
|
|
||||||
"Could not get stored songs"
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_analyze_library_fail_storage() {
|
|
||||||
let mut test_library = FailingStorage {};
|
|
||||||
|
|
||||||
// A storage fail should just warn the user, but not abort the whole process
|
|
||||||
assert!(test_library.analyze_library().is_ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_analyze_library_streaming() {
|
|
||||||
let mut test_library = TestLibrary {
|
|
||||||
internal_storage: vec![],
|
|
||||||
failed_files: vec![],
|
|
||||||
};
|
|
||||||
let rx = test_library.analyze_library_streaming().unwrap();
|
|
||||||
|
|
||||||
let mut result = rx.iter().collect::<Vec<(String, BlissResult<Song>)>>();
|
|
||||||
result.sort_by_key(|k| k.0.to_owned());
|
|
||||||
let expected = result
|
|
||||||
.iter()
|
|
||||||
.map(|x| match &x.1 {
|
|
||||||
Ok(s) => (true, s.path.to_string_lossy().to_string()),
|
|
||||||
Err(_) => (false, x.0.to_owned()),
|
|
||||||
})
|
|
||||||
.collect::<Vec<(bool, String)>>();
|
|
||||||
assert_eq!(
|
|
||||||
vec![
|
|
||||||
(true, String::from("./data/s16_mono_22_5kHz.flac")),
|
|
||||||
(true, String::from("./data/white_noise.flac")),
|
|
||||||
(false, String::from("definitely-not-existing.foo")),
|
|
||||||
(false, String::from("not-existing.foo")),
|
|
||||||
],
|
|
||||||
expected,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_analyze_library() {
|
|
||||||
let mut test_library = TestLibrary {
|
|
||||||
internal_storage: vec![],
|
|
||||||
failed_files: vec![],
|
|
||||||
};
|
|
||||||
test_library.analyze_library().unwrap();
|
|
||||||
|
|
||||||
let mut failed_files = test_library
|
|
||||||
.failed_files
|
|
||||||
.iter()
|
|
||||||
.map(|x| x.0.to_owned())
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
failed_files.sort();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
failed_files,
|
|
||||||
vec![
|
|
||||||
String::from("definitely-not-existing.foo"),
|
|
||||||
String::from("not-existing.foo"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut songs = test_library
|
|
||||||
.internal_storage
|
|
||||||
.iter()
|
|
||||||
.map(|x| x.path.to_str().unwrap().to_string())
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
songs.sort();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
songs,
|
|
||||||
vec![
|
|
||||||
String::from("./data/s16_mono_22_5kHz.flac"),
|
|
||||||
String::from("./data/white_noise.flac"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_album() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
let first_song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
album: Some(String::from("Album")),
|
|
||||||
track_number: Some(String::from("01")),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let second_song = Song {
|
|
||||||
path: Path::new("path-to-second").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.1; 20]),
|
|
||||||
album: Some(String::from("Another Album")),
|
|
||||||
track_number: Some(String::from("10")),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let third_song = Song {
|
|
||||||
path: Path::new("path-to-third").to_path_buf(),
|
|
||||||
analysis: Analysis::new([10.; 20]),
|
|
||||||
album: Some(String::from("Album")),
|
|
||||||
track_number: Some(String::from("02")),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let fourth_song = Song {
|
|
||||||
path: Path::new("path-to-fourth").to_path_buf(),
|
|
||||||
analysis: Analysis::new([20.; 20]),
|
|
||||||
album: Some(String::from("Another Album")),
|
|
||||||
track_number: Some(String::from("01")),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let fifth_song = Song {
|
|
||||||
path: Path::new("path-to-fifth").to_path_buf(),
|
|
||||||
analysis: Analysis::new([20.; 20]),
|
|
||||||
album: None,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
test_library.internal_storage = vec![
|
|
||||||
first_song.to_owned(),
|
|
||||||
fourth_song.to_owned(),
|
|
||||||
third_song.to_owned(),
|
|
||||||
second_song.to_owned(),
|
|
||||||
fifth_song.to_owned(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
vec![first_song, third_song, fourth_song, second_song],
|
|
||||||
test_library.playlist_from_songs_album("Album", 3).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_song() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
let first_song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let second_song = Song {
|
|
||||||
path: Path::new("path-to-second").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.1; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let third_song = Song {
|
|
||||||
path: Path::new("path-to-third").to_path_buf(),
|
|
||||||
analysis: Analysis::new([10.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let fourth_song = Song {
|
|
||||||
path: Path::new("path-to-fourth").to_path_buf(),
|
|
||||||
analysis: Analysis::new([20.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
test_library.internal_storage = vec![
|
|
||||||
first_song.to_owned(),
|
|
||||||
fourth_song.to_owned(),
|
|
||||||
third_song.to_owned(),
|
|
||||||
second_song.to_owned(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
vec![first_song.to_owned(), second_song, third_song],
|
|
||||||
test_library.playlist_from_song(first_song, 3).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_song_too_little_songs() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
let first_song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let second_song = Song {
|
|
||||||
path: Path::new("path-to-second").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.1; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let third_song = Song {
|
|
||||||
path: Path::new("path-to-third").to_path_buf(),
|
|
||||||
analysis: Analysis::new([10.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
test_library.internal_storage = vec![
|
|
||||||
first_song.to_owned(),
|
|
||||||
second_song.to_owned(),
|
|
||||||
third_song.to_owned(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
vec![first_song.to_owned(), second_song, third_song],
|
|
||||||
test_library.playlist_from_song(first_song, 200).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_analyze_empty_path() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
assert!(test_library.analyze_paths(vec![]).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn custom_distance(a: &Array1<f32>, b: &Array1<f32>) -> f32 {
|
|
||||||
if a == b {
|
|
||||||
return 0.;
|
|
||||||
}
|
|
||||||
1. / (a.first().unwrap() - b.first().unwrap()).abs()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_song_custom_distance() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
let first_song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let second_song = Song {
|
|
||||||
path: Path::new("path-to-second").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.1; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let third_song = Song {
|
|
||||||
path: Path::new("path-to-third").to_path_buf(),
|
|
||||||
analysis: Analysis::new([10.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let fourth_song = Song {
|
|
||||||
path: Path::new("path-to-fourth").to_path_buf(),
|
|
||||||
analysis: Analysis::new([20.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
test_library.internal_storage = vec![
|
|
||||||
first_song.to_owned(),
|
|
||||||
fourth_song.to_owned(),
|
|
||||||
third_song.to_owned(),
|
|
||||||
second_song.to_owned(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
vec![first_song.to_owned(), fourth_song, third_song],
|
|
||||||
test_library
|
|
||||||
.playlist_from_song_custom_distance(first_song, 3, custom_distance)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn custom_sort(_: &Song, songs: &mut Vec<Song>, _: impl DistanceMetric) {
|
|
||||||
songs.sort_by_key(|song| song.path.to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_playlist_from_song_custom() {
|
|
||||||
let mut test_library = TestLibrary::default();
|
|
||||||
let first_song = Song {
|
|
||||||
path: Path::new("path-to-first").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let second_song = Song {
|
|
||||||
path: Path::new("path-to-second").to_path_buf(),
|
|
||||||
analysis: Analysis::new([0.1; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let third_song = Song {
|
|
||||||
path: Path::new("path-to-third").to_path_buf(),
|
|
||||||
analysis: Analysis::new([10.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let fourth_song = Song {
|
|
||||||
path: Path::new("path-to-fourth").to_path_buf(),
|
|
||||||
analysis: Analysis::new([20.; 20]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
test_library.internal_storage = vec![
|
|
||||||
first_song.to_owned(),
|
|
||||||
fourth_song.to_owned(),
|
|
||||||
third_song.to_owned(),
|
|
||||||
second_song.to_owned(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
vec![first_song.to_owned(), fourth_song, second_song],
|
|
||||||
test_library
|
|
||||||
.playlist_from_song_custom(first_song, 3, custom_distance, custom_sort)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +1,17 @@
|
||||||
//! Module containing various distance metric functions.
|
//! Module containing various functions to build playlists, as well as various
|
||||||
|
//! distance metrics.
|
||||||
//!
|
//!
|
||||||
//! All of these functions are intended to be used with the
|
//! All of the distance functions are intended to be used with the
|
||||||
//! [custom_distance](Song::custom_distance) method, or with
|
//! [custom_distance](Song::custom_distance) method, or with
|
||||||
//! [playlist_from_songs_custom_distance](Library::playlist_from_song_custom_distance).
|
|
||||||
//!
|
//!
|
||||||
//! They will yield different styles of playlists, so don't hesitate to
|
//! They will yield different styles of playlists, so don't hesitate to
|
||||||
//! experiment with them if the default (euclidean distance for now) doesn't
|
//! experiment with them if the default (euclidean distance for now) doesn't
|
||||||
//! suit you.
|
//! suit you.
|
||||||
#[cfg(doc)]
|
use crate::{BlissError, BlissResult, Song, NUMBER_FEATURES};
|
||||||
use crate::Library;
|
use ndarray::{Array, Array1, Array2, Axis};
|
||||||
use crate::Song;
|
|
||||||
use crate::NUMBER_FEATURES;
|
|
||||||
use ndarray::{Array, Array1};
|
|
||||||
use ndarray_stats::QuantileExt;
|
use ndarray_stats::QuantileExt;
|
||||||
use noisy_float::prelude::*;
|
use noisy_float::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Convenience trait for user-defined distance metrics.
|
/// Convenience trait for user-defined distance metrics.
|
||||||
pub trait DistanceMetric: Fn(&Array1<f32>, &Array1<f32>) -> f32 {}
|
pub trait DistanceMetric: Fn(&Array1<f32>, &Array1<f32>) -> f32 {}
|
||||||
|
@ -117,6 +115,92 @@ pub fn dedup_playlist_custom_distance(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return a list of albums in a `pool` of songs that are similar to
|
||||||
|
/// songs in `group`, discarding songs that don't belong to an album.
|
||||||
|
/// It basically makes an "album" playlist from the `pool` of songs.
|
||||||
|
///
|
||||||
|
/// Songs from `group` would usually just be songs from an album, but not
|
||||||
|
/// necessarily - they are discarded from `pool` no matter what.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `group` - A small group of songs, e.g. an album.
|
||||||
|
/// * `pool` - A pool of songs to find similar songs in, e.g. a user's song
|
||||||
|
/// library.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A vector of songs, including `group` at the beginning, that you
|
||||||
|
/// most likely want to plug in your audio player by using something like
|
||||||
|
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
||||||
|
pub fn closest_album_to_group(group: Vec<Song>, pool: Vec<Song>) -> BlissResult<Vec<Song>> {
|
||||||
|
let mut albums_analysis: HashMap<&str, Array2<f32>> = HashMap::new();
|
||||||
|
let mut albums = Vec::new();
|
||||||
|
|
||||||
|
// Remove songs from the group from the pool.
|
||||||
|
let pool = pool
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !group.contains(s))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for song in &pool {
|
||||||
|
if let Some(album) = &song.album {
|
||||||
|
if let Some(analysis) = albums_analysis.get_mut(album as &str) {
|
||||||
|
analysis
|
||||||
|
.push_row(song.analysis.as_arr1().view())
|
||||||
|
.map_err(|e| {
|
||||||
|
BlissError::ProviderError(format!("while computing distances: {}", e))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
let mut array = Array::zeros((1, song.analysis.as_arr1().len()));
|
||||||
|
array.assign(&song.analysis.as_arr1());
|
||||||
|
albums_analysis.insert(album, array);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut group_analysis = Array::zeros((group.len(), NUMBER_FEATURES));
|
||||||
|
for (song, mut column) in group.iter().zip(group_analysis.axis_iter_mut(Axis(0))) {
|
||||||
|
column.assign(&song.analysis.as_arr1());
|
||||||
|
}
|
||||||
|
let first_analysis = group_analysis
|
||||||
|
.mean_axis(Axis(0))
|
||||||
|
.ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?;
|
||||||
|
for (album, analysis) in albums_analysis.iter() {
|
||||||
|
let mean_analysis = analysis
|
||||||
|
.mean_axis(Axis(0))
|
||||||
|
.ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?;
|
||||||
|
let album = album.to_owned();
|
||||||
|
albums.push((album, mean_analysis.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
albums.sort_by_key(|(_, analysis)| n32(euclidean_distance(&first_analysis, analysis)));
|
||||||
|
let mut playlist = group;
|
||||||
|
for (album, _) in albums {
|
||||||
|
let mut al = pool
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.album.is_some() && s.album.as_ref().unwrap() == &album.to_string())
|
||||||
|
.map(|s| s.to_owned())
|
||||||
|
.collect::<Vec<Song>>();
|
||||||
|
al.sort_by(|s1, s2| {
|
||||||
|
let track_number1 = s1
|
||||||
|
.track_number
|
||||||
|
.to_owned()
|
||||||
|
.unwrap_or_else(|| String::from(""));
|
||||||
|
let track_number2 = s2
|
||||||
|
.track_number
|
||||||
|
.to_owned()
|
||||||
|
.unwrap_or_else(|| String::from(""));
|
||||||
|
if let Ok(x) = track_number1.parse::<i32>() {
|
||||||
|
if let Ok(y) = track_number2.parse::<i32>() {
|
||||||
|
return x.cmp(&y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s1.track_number.cmp(&s2.track_number)
|
||||||
|
});
|
||||||
|
playlist.extend_from_slice(&al);
|
||||||
|
}
|
||||||
|
Ok(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -227,7 +311,7 @@ mod test {
|
||||||
vec![
|
vec![
|
||||||
first_song.to_owned(),
|
first_song.to_owned(),
|
||||||
second_song.to_owned(),
|
second_song.to_owned(),
|
||||||
fourth_song.to_owned()
|
fourth_song.to_owned(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -389,4 +473,68 @@ mod test {
|
||||||
assert_eq!(cosine_distance(&a, &b), 0.);
|
assert_eq!(cosine_distance(&a, &b), 0.);
|
||||||
assert_eq!(cosine_distance(&a, &b), 0.);
|
assert_eq!(cosine_distance(&a, &b), 0.);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_closest_to_group() {
|
||||||
|
let first_song = Song {
|
||||||
|
path: Path::new("path-to-first").to_path_buf(),
|
||||||
|
analysis: Analysis::new([0.; 20]),
|
||||||
|
album: Some(String::from("Album")),
|
||||||
|
artist: Some(String::from("Artist")),
|
||||||
|
track_number: Some(String::from("01")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let second_song = Song {
|
||||||
|
path: Path::new("path-to-second").to_path_buf(),
|
||||||
|
analysis: Analysis::new([0.1; 20]),
|
||||||
|
album: Some(String::from("Another Album")),
|
||||||
|
artist: Some(String::from("Artist")),
|
||||||
|
track_number: Some(String::from("10")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let third_song = Song {
|
||||||
|
path: Path::new("path-to-third").to_path_buf(),
|
||||||
|
analysis: Analysis::new([10.; 20]),
|
||||||
|
album: Some(String::from("Album")),
|
||||||
|
artist: Some(String::from("Another Artist")),
|
||||||
|
track_number: Some(String::from("02")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let fourth_song = Song {
|
||||||
|
path: Path::new("path-to-fourth").to_path_buf(),
|
||||||
|
analysis: Analysis::new([20.; 20]),
|
||||||
|
album: Some(String::from("Another Album")),
|
||||||
|
artist: Some(String::from("Another Artist")),
|
||||||
|
track_number: Some(String::from("01")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let fifth_song = Song {
|
||||||
|
path: Path::new("path-to-fifth").to_path_buf(),
|
||||||
|
analysis: Analysis::new([40.; 20]),
|
||||||
|
artist: Some(String::from("Third Artist")),
|
||||||
|
album: None,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = vec![
|
||||||
|
first_song.to_owned(),
|
||||||
|
fourth_song.to_owned(),
|
||||||
|
third_song.to_owned(),
|
||||||
|
second_song.to_owned(),
|
||||||
|
fifth_song.to_owned(),
|
||||||
|
];
|
||||||
|
let group = vec![first_song.to_owned(), third_song.to_owned()];
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
first_song.to_owned(),
|
||||||
|
third_song.to_owned(),
|
||||||
|
fourth_song.to_owned(),
|
||||||
|
second_song.to_owned()
|
||||||
|
],
|
||||||
|
closest_album_to_group(group, pool.to_owned()).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
121
src/song.rs
121
src/song.rs
|
@ -13,8 +13,10 @@ extern crate ndarray;
|
||||||
extern crate ndarray_npy;
|
extern crate ndarray_npy;
|
||||||
|
|
||||||
use crate::chroma::ChromaDesc;
|
use crate::chroma::ChromaDesc;
|
||||||
use crate::distance::{euclidean_distance, DistanceMetric};
|
|
||||||
use crate::misc::LoudnessDesc;
|
use crate::misc::LoudnessDesc;
|
||||||
|
#[cfg(doc)]
|
||||||
|
use crate::playlist;
|
||||||
|
use crate::playlist::{closest_to_first_song, dedup_playlist, euclidean_distance, DistanceMetric};
|
||||||
use crate::temporal::BPMDesc;
|
use crate::temporal::BPMDesc;
|
||||||
use crate::timbral::{SpectralDesc, ZeroCrossingRateDesc};
|
use crate::timbral::{SpectralDesc, ZeroCrossingRateDesc};
|
||||||
use crate::{BlissError, BlissResult, SAMPLE_RATE};
|
use crate::{BlissError, BlissResult, SAMPLE_RATE};
|
||||||
|
@ -228,6 +230,44 @@ impl Song {
|
||||||
self.analysis.custom_distance(&other.analysis, distance)
|
self.analysis.custom_distance(&other.analysis, distance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orders songs in `pool` by proximity to `self`, using the distance
|
||||||
|
/// metric `distance` to compute the order.
|
||||||
|
/// Basically return a playlist from songs in `pool`, starting
|
||||||
|
/// from `self`, using `distance` (some distance metrics can
|
||||||
|
/// be found in the [playlist] module).
|
||||||
|
///
|
||||||
|
/// Note that contrary to [Song::closest_from_pool], `self` is NOT added
|
||||||
|
/// to the beginning of the returned vector.
|
||||||
|
///
|
||||||
|
/// No deduplication is ran either; if you're looking for something easy
|
||||||
|
/// that works "out of the box", use [Song::closest_from_pool].
|
||||||
|
pub fn closest_from_pool_custom(
|
||||||
|
&self,
|
||||||
|
pool: Vec<Self>,
|
||||||
|
distance: impl DistanceMetric,
|
||||||
|
) -> Vec<Self> {
|
||||||
|
let mut pool = pool;
|
||||||
|
closest_to_first_song(self, &mut pool, distance);
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order songs in `pool` by proximity to `self`.
|
||||||
|
/// Convenience method to return a playlist from songs in `pool`,
|
||||||
|
/// starting from `self`.
|
||||||
|
///
|
||||||
|
/// The distance is already chosen, deduplication is ran, and the first song
|
||||||
|
/// is added to the top of the playlist, to make everything easier.
|
||||||
|
///
|
||||||
|
/// If you want more control over which distance metric is chosen,
|
||||||
|
/// run deduplication manually, etc, use [Song::closest_from_pool_custom].
|
||||||
|
pub fn closest_from_pool(&self, pool: Vec<Self>) -> Vec<Self> {
|
||||||
|
let mut playlist = vec![self.to_owned()];
|
||||||
|
playlist.extend_from_slice(&pool);
|
||||||
|
closest_to_first_song(self, &mut playlist, euclidean_distance);
|
||||||
|
dedup_playlist(&mut playlist, None);
|
||||||
|
playlist
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a decoded [Song] given a file path, or an error if the song
|
/// Returns a decoded [Song] given a file path, or an error if the song
|
||||||
/// could not be analyzed for some reason.
|
/// could not be analyzed for some reason.
|
||||||
///
|
///
|
||||||
|
@ -848,6 +888,7 @@ mod tests {
|
||||||
fn dummy_distance(_: &Array1<f32>, _: &Array1<f32>) -> f32 {
|
fn dummy_distance(_: &Array1<f32>, _: &Array1<f32>) -> f32 {
|
||||||
0.
|
0.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_custom_distance() {
|
fn test_custom_distance() {
|
||||||
let mut a = Song::default();
|
let mut a = Song::default();
|
||||||
|
@ -865,6 +906,84 @@ mod tests {
|
||||||
]);
|
]);
|
||||||
assert_eq!(a.custom_distance(&b, dummy_distance), 0.);
|
assert_eq!(a.custom_distance(&b, dummy_distance), 0.);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_closest_from_pool() {
|
||||||
|
let song = Song {
|
||||||
|
path: Path::new("path-to-first").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let first_song_dupe = Song {
|
||||||
|
path: Path::new("path-to-dupe").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let second_song = Song {
|
||||||
|
path: Path::new("path-to-second").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 1.9, 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let third_song = Song {
|
||||||
|
path: Path::new("path-to-third").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2.5, 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let fourth_song = Song {
|
||||||
|
path: Path::new("path-to-fourth").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 0., 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let fifth_song = Song {
|
||||||
|
path: Path::new("path-to-fifth").to_path_buf(),
|
||||||
|
analysis: Analysis::new([
|
||||||
|
2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 0., 1., 1., 1.,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let songs = vec![
|
||||||
|
song.to_owned(),
|
||||||
|
first_song_dupe.to_owned(),
|
||||||
|
second_song.to_owned(),
|
||||||
|
third_song.to_owned(),
|
||||||
|
fourth_song.to_owned(),
|
||||||
|
fifth_song.to_owned(),
|
||||||
|
];
|
||||||
|
let playlist = song.closest_from_pool(songs.to_owned());
|
||||||
|
assert_eq!(
|
||||||
|
playlist,
|
||||||
|
vec![
|
||||||
|
song.to_owned(),
|
||||||
|
second_song.to_owned(),
|
||||||
|
fourth_song.to_owned(),
|
||||||
|
third_song.to_owned(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let playlist = song.closest_from_pool_custom(songs, euclidean_distance);
|
||||||
|
assert_eq!(
|
||||||
|
playlist,
|
||||||
|
vec![
|
||||||
|
song,
|
||||||
|
first_song_dupe,
|
||||||
|
second_song,
|
||||||
|
fourth_song,
|
||||||
|
fifth_song,
|
||||||
|
third_song
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "bench", test))]
|
#[cfg(all(feature = "bench", test))]
|
||||||
|
|
Loading…
Reference in a new issue