From 0eb3e2f9fc6ffbaae49b82aa7f2027244c58875e Mon Sep 17 00:00:00 2001 From: Polochon-street Date: Sat, 19 Jun 2021 14:35:51 +0200 Subject: [PATCH] Add custom distances and run `cargo fmt` --- CHANGELOG.md | 6 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/chroma.rs | 2 +- src/distance.rs | 75 ++++++++++++++++++++++++++ src/lib.rs | 17 +++--- src/library.rs | 136 ++++++++++++++++++++++++++++++++++++++++++------ src/song.rs | 122 ++++++++++++++++++++++++++++--------------- src/utils.rs | 6 ++- 9 files changed, 300 insertions(+), 68 deletions(-) create mode 100644 src/distance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f08ebc9..172605d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## bliss 0.3.1 +* Show error message when song storage fails in the Library trait. +* Added a `distance` module containing euclidean and cosine distance. +* Added various custom_distance functions to avoid being limited to the + euclidean distance only. + ## bliss 0.3.0 * Changed `Song.path` from `String` to `PathBuf`. * Made `Song` metadata (artist, album, etc) `Option`s. diff --git a/Cargo.lock b/Cargo.lock index 7fd34f5..69e3815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,7 +75,7 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" [[package]] name = "bliss-audio" -version = "0.3.0" +version = "0.3.1" dependencies = [ "bliss-audio-aubio-rs", "crossbeam", diff --git a/Cargo.toml b/Cargo.toml index bd954ff..fc433f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bliss-audio" -version = "0.3.0" +version = "0.3.1" authors = ["Polochon-street "] edition = "2018" license = "GPL-3.0-only" diff --git a/src/chroma.rs b/src/chroma.rs index 9a57dd9..ff48e61 100644 --- a/src/chroma.rs +++ b/src/chroma.rs @@ -556,8 +556,8 @@ mod bench { use ndarray::{arr2, Array1, Array2}; use ndarray_npy::ReadNpyExt; use std::fs::File; - use test::Bencher; use std::path::Path; + use test::Bencher; #[bench] fn bench_estimate_tuning(b: &mut Bencher) { diff --git a/src/distance.rs b/src/distance.rs new file mode 100644 index 0000000..bd13e09 --- /dev/null +++ b/src/distance.rs @@ -0,0 +1,75 @@ +//! Module containing various distance metric functions. +//! +//! All of these functions are intended to be used with the +//! [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 +//! experiment with them if the default (euclidean distance for now) doesn't +//! suit you. +use crate::NUMBER_FEATURES; +#[cfg(doc)] +use crate::{Library, Song}; +use ndarray::{Array, Array1}; + +/// Convenience trait for user-defined distance metrics. +pub trait DistanceMetric: Fn(&Array1, &Array1) -> f32 {} +impl DistanceMetric for F where F: Fn(&Array1, &Array1) -> f32 {} + +/// Return the [euclidean +/// distance](https://en.wikipedia.org/wiki/Euclidean_distance#Higher_dimensions) +/// between two vectors. +pub fn euclidean_distance(a: &Array1, b: &Array1) -> f32 { + // Could be any square symmetric positive semi-definite matrix; + // just no metric learning has been done yet. + // See https://lelele.io/thesis.pdf chapter 4. + let m = Array::eye(NUMBER_FEATURES); + + (a - b).dot(&m).dot(&(a - b)).sqrt() +} + +/// Return the [cosine +/// distance](https://en.wikipedia.org/wiki/Cosine_similarity#Angular_distance_and_similarity) +/// between two vectors. +pub fn cosine_distance(a: &Array1, b: &Array1) -> f32 { + let similarity = a.dot(b) / (a.dot(a).sqrt() * b.dot(b).sqrt()); + 1. - similarity +} + +#[cfg(test)] +mod test { + use super::*; + use ndarray::arr1; + + #[test] + fn test_euclidean_distance() { + let a = arr1(&[ + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., + ]); + let b = arr1(&[ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., + ]); + assert_eq!(euclidean_distance(&a, &b), 4.242640687119285); + + let a = arr1(&[0.5; 20]); + let b = arr1(&[0.5; 20]); + assert_eq!(euclidean_distance(&a, &b), 0.); + assert_eq!(euclidean_distance(&a, &b), 0.); + } + + #[test] + fn test_cosine_distance() { + let a = arr1(&[ + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., + ]); + let b = arr1(&[ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., + ]); + assert_eq!(cosine_distance(&a, &b), 0.7705842661294382); + + let a = arr1(&[0.5; 20]); + let b = arr1(&[0.5; 20]); + assert_eq!(cosine_distance(&a, &b), 0.); + assert_eq!(cosine_distance(&a, &b), 0.); + } +} diff --git a/src/lib.rs b/src/lib.rs index b96d81e..ec0d20c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ //! ## Analyze & compute the distance between two songs //! ```no_run //! use bliss_audio::{BlissResult, Song}; -//! +//! //! fn main() -> BlissResult<()> { //! let song1 = Song::new("/path/to/song1")?; //! let song2 = Song::new("/path/to/song2")?; @@ -34,19 +34,19 @@ //! Ok(()) //! } //! ``` -//! +//! //! ### Make a playlist from a song //! ```no_run //! use bliss_audio::{BlissResult, Song}; //! use noisy_float::prelude::n32; -//! +//! //! fn main() -> BlissResult<()> { //! let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"]; //! let mut songs: Vec = paths //! .iter() //! .map(|path| Song::new(path)) //! .collect::>>()?; -//! +//! //! // Assuming there is a first song //! let first_song = songs.first().unwrap().to_owned(); //! @@ -65,6 +65,7 @@ #![warn(missing_docs)] #![warn(missing_doc_code_examples)] mod chroma; +pub mod distance; mod library; mod misc; mod song; @@ -80,7 +81,7 @@ extern crate serde; use thiserror::Error; pub use library::Library; -pub use song::{Analysis, AnalysisIndex, NUMBER_FEATURES, Song}; +pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES}; const CHANNELS: u16 = 1; const SAMPLE_RATE: u32 = 22050; @@ -178,7 +179,11 @@ mod tests { let mut analysed_songs: Vec = results .iter() - .filter_map(|x| x.as_ref().ok().map(|x| x.path.to_str().unwrap().to_string())) + .filter_map(|x| { + x.as_ref() + .ok() + .map(|x| x.path.to_str().unwrap().to_string()) + }) .collect(); analysed_songs.sort_by(|a, b| a.cmp(b)); diff --git a/src/library.rs b/src/library.rs index aeddc30..c8a1472 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,5 +1,8 @@ //! Module containing the Library trait, useful to get started to implement //! a plug-in for an audio player. +#[cfg(doc)] +use crate::distance; +use crate::distance::DistanceMetric; use crate::{BlissError, BlissResult, Song}; use log::{debug, error, info}; use noisy_float::prelude::*; @@ -42,15 +45,71 @@ pub trait Library { first_song: Song, playlist_length: usize, ) -> BlissResult> { - let analysis_current_song = first_song.analysis; let mut songs = self.get_stored_songs()?; - songs.sort_by_cached_key(|song| n32(analysis_current_song.distance(&song.analysis))); + songs.sort_by_cached_key(|song| n32(first_song.distance(&song))); let playlist = songs .into_iter() .take(playlist_length) .collect::>(); - debug!("Playlist created: {:?}", playlist); + debug!( + "Playlist created: {}", + playlist + .iter() + .map(|s| format!("{:?}", &s)) + .collect::>() + .join("\n"), + ); + Ok(playlist) + } + + /// Return a list of songs that are similar to ``first_song``, using a + /// 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. + /// + /// # 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::>()`. + /// + /// # Custom distance example + /// + /// ``` + /// use ndarray::Array1; + /// + /// fn manhattan_distance(a: &Array1, b: &Array1) -> 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> { + let mut songs = self.get_stored_songs()?; + songs.sort_by_cached_key(|song| n32(first_song.custom_distance(&song, &distance))); + + let playlist = songs + .into_iter() + .take(playlist_length) + .collect::>(); + debug!( + "Playlist created: {}", + playlist + .iter() + .map(|s| format!("{:?}", &s)) + .collect::>() + .join("\n"), + ); Ok(playlist) } @@ -95,9 +154,13 @@ pub trait Library { // 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(|_| error!("Error while storing song '{}'", song.path.display())); - info!("Analyzed and stored song '{}' successfully.", song.path.display()) + 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()) @@ -135,6 +198,7 @@ pub trait Library { mod test { use super::*; use crate::song::Analysis; + use ndarray::Array1; use std::path::Path; #[derive(Default)] @@ -158,11 +222,7 @@ mod test { Ok(()) } - fn store_error_song( - &mut self, - song_path: String, - error: BlissError, - ) -> BlissResult<()> { + fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()> { self.failed_files.push((song_path, error.to_string())); Ok(()) } @@ -221,11 +281,7 @@ mod test { Ok(vec![]) } - fn store_error_song( - &mut self, - song_path: String, - error: BlissError, - ) -> BlissResult<()> { + 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 @@ -384,4 +440,52 @@ mod test { let mut test_library = TestLibrary::default(); assert!(test_library.analyze_paths(vec![]).is_ok()); } + + fn custom_distance(a: &Array1, b: &Array1) -> 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() + ); + } } diff --git a/src/song.rs b/src/song.rs index 5340c38..7530312 100644 --- a/src/song.rs +++ b/src/song.rs @@ -14,6 +14,7 @@ extern crate ndarray_npy; use super::CHANNELS; use crate::chroma::ChromaDesc; +use crate::distance::{euclidean_distance, DistanceMetric}; use crate::misc::LoudnessDesc; use crate::temporal::BPMDesc; use crate::timbral::{SpectralDesc, ZeroCrossingRateDesc}; @@ -31,13 +32,13 @@ use ffmpeg_next::util::format::sample::{Sample, Type}; use ffmpeg_next::util::frame::audio::Audio; use ffmpeg_next::util::log; use ffmpeg_next::util::log::level::Level; -use ndarray::{arr1, Array, Array1}; +use ndarray::{arr1, Array1}; use std::convert::TryInto; use std::fmt; -use std::sync::mpsc; -use std::sync::mpsc::Receiver; use std::path::Path; use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::Receiver; use std::thread as std_thread; use strum::{EnumCount, IntoEnumIterator}; use strum_macros::{EnumCount, EnumIter}; @@ -173,34 +174,55 @@ impl Analysis { self.internal_analysis.to_vec() } - /// Return the [euclidean - /// distance](https://en.wikipedia.org/wiki/Euclidean_distance#Higher_dimensions) - /// between two analysis. + /// Compute distance between two analysis using a user-provided distance + /// metric. You most likely want to use `song.custom_distance` directly + /// rather than this function. /// - /// Note that it is usually easier to just use [`song.distance(song2)`](Song::distance) - /// (which calls this function in turn). - pub fn distance(&self, other: &Self) -> f32 { - let a1 = self.to_arr1(); - let a2 = other.to_arr1(); - // Could be any square symmetric positive semi-definite matrix; - // just no metric learning has been done yet. - // See https://lelele.io/thesis.pdf chapter 4. - let m = Array::eye(NUMBER_FEATURES); - - (self.to_arr1() - &a2).dot(&m).dot(&(&a1 - &a2)).sqrt() + /// For this function to be integrated properly with the rest + /// of bliss' parts, it should be a valid distance metric, i.e.: + /// 1. For X, Y real vectors, d(X, Y) = 0 ⇔ X = Y + /// 2. For X, Y real vectors, d(X, Y) >= 0 + /// 3. For X, Y real vectors, d(X, Y) = d(Y, X) + /// 4. For X, Y, Z real vectors d(X, Y) ≤ d(X + Z) + d(Z, Y) + /// + /// Note that almost all distance metrics you will find obey these + /// properties, so don't sweat it too much. + pub fn custom_distance(&self, other: &Self, distance: impl DistanceMetric) -> f32 { + distance(&self.to_arr1(), &other.to_arr1()) } } impl Song { #[allow(dead_code)] - /// Compute the distance between the current song and any given Song. + /// Compute the distance between the current song and any given + /// Song. /// /// The smaller the number, the closer the songs; usually more useful /// if compared between several songs /// (e.g. if song1.distance(song2) < song1.distance(song3), then song1 is /// closer to song2 than it is to song3. + /// + /// Currently uses the euclidean distance, but this can change in an + /// upcoming release if another metric performs better. pub fn distance(&self, other: &Self) -> f32 { - self.analysis.distance(&other.analysis) + self.analysis + .custom_distance(&other.analysis, euclidean_distance) + } + + /// Compute distance between two songs using a user-provided distance + /// metric. + /// + /// For this function to be integrated properly with the rest + /// of bliss' parts, it should be a valid distance metric, i.e.: + /// 1. For X, Y real vectors, d(X, Y) = 0 ⇔ X = Y + /// 2. For X, Y real vectors, d(X, Y) >= 0 + /// 3. For X, Y real vectors, d(X, Y) = d(Y, X) + /// 4. For X, Y, Z real vectors d(X, Y) ≤ d(X + Z) + d(Z, Y) + /// + /// Note that almost all distance metrics you will find obey these + /// properties, so don't sweat it too much. + pub fn custom_distance(&self, other: &Self, distance: impl DistanceMetric) -> f32 { + self.analysis.custom_distance(&other.analysis, distance) } /// Returns a decoded Song given a file path, or an error if the song @@ -261,25 +283,23 @@ impl Song { } thread::scope(|s| { - let child_tempo: thread::ScopedJoinHandle<'_, BlissResult> = - s.spawn(|_| { - let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?; - let windows = sample_array - .windows(BPMDesc::WINDOW_SIZE) - .step_by(BPMDesc::HOP_SIZE); + let child_tempo: thread::ScopedJoinHandle<'_, BlissResult> = s.spawn(|_| { + let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?; + let windows = sample_array + .windows(BPMDesc::WINDOW_SIZE) + .step_by(BPMDesc::HOP_SIZE); - for window in windows { - tempo_desc.do_(&window)?; - } - Ok(tempo_desc.get_value()) - }); + for window in windows { + tempo_desc.do_(&window)?; + } + Ok(tempo_desc.get_value()) + }); - let child_chroma: thread::ScopedJoinHandle<'_, BlissResult>> = - s.spawn(|_| { - let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); - chroma_desc.do_(&sample_array)?; - Ok(chroma_desc.get_values()) - }); + let child_chroma: thread::ScopedJoinHandle<'_, BlissResult>> = s.spawn(|_| { + let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12); + chroma_desc.do_(&sample_array)?; + Ok(chroma_desc.get_values()) + }); #[allow(clippy::type_complexity)] let child_timbral: thread::ScopedJoinHandle< @@ -305,8 +325,8 @@ impl Song { Ok(zcr_desc.get_value()) }); - let child_loudness: thread::ScopedJoinHandle<'_, BlissResult>> = s - .spawn(|_| { + let child_loudness: thread::ScopedJoinHandle<'_, BlissResult>> = + s.spawn(|_| { let mut loudness_desc = LoudnessDesc::default(); let windows = sample_array.chunks(LoudnessDesc::WINDOW_SIZE); @@ -390,7 +410,6 @@ impl Song { "" => None, a => Some(a.to_string()), }; - }; if let Some(album) = format.metadata().get("album") { song.album = match album { @@ -653,7 +672,7 @@ mod tests { fn test_empty_tags() { let song = Song::decode(Path::new("data/no_tags.flac")).unwrap(); assert_eq!(song.artist, None); - assert_eq!(song.title, None); + assert_eq!(song.title, None); assert_eq!(song.album, None); assert_eq!(song.track_number, None); assert_eq!(song.genre, None); @@ -804,14 +823,35 @@ mod tests { format!("{:?}", song.analysis), ); } + + fn dummy_distance(_: &Array1, _: &Array1) -> f32 { + 0. + } + #[test] + fn test_custom_distance() { + let mut a = Song::default(); + a.analysis = Analysis::new([ + 0.16391512, 0.11326739, 0.96868552, 0.8353934, 0.49867523, 0.76532606, 0.63448005, + 0.82506196, 0.71457147, 0.62395476, 0.69680329, 0.9855766, 0.41369333, 0.13900452, + 0.68001012, 0.11029723, 0.97192943, 0.57727861, 0.07994821, 0.88993185, + ]); + + let mut b = Song::default(); + b.analysis = Analysis::new([ + 0.5075758, 0.36440256, 0.28888011, 0.43032829, 0.62387977, 0.61894916, 0.99676086, + 0.11913155, 0.00640396, 0.15943407, 0.33829514, 0.34947174, 0.82927523, 0.18987604, + 0.54437275, 0.22076826, 0.91232151, 0.29233168, 0.32846024, 0.04522147, + ]); + assert_eq!(a.custom_distance(&b, dummy_distance), 0.); + } } #[cfg(all(feature = "bench", test))] mod bench { extern crate test; use crate::Song; - use test::Bencher; use std::path::Path; + use test::Bencher; #[bench] fn bench_resample_multi(b: &mut Bencher) { diff --git a/src/utils.rs b/src/utils.rs index c6e805d..4ac8181 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -525,8 +525,8 @@ mod bench { use super::*; use crate::Song; use ndarray::Array; - use test::Bencher; use std::path::Path; + use test::Bencher; #[bench] fn bench_convolve(b: &mut Bencher) { @@ -540,7 +540,9 @@ mod bench { #[bench] fn bench_compute_stft(b: &mut Bencher) { - let signal = Song::decode(Path::new("data/piano.flac")).unwrap().sample_array; + let signal = Song::decode(Path::new("data/piano.flac")) + .unwrap() + .sample_array; b.iter(|| { stft(&signal, 2048, 512);