Add custom distances and run cargo fmt

This commit is contained in:
Polochon-street 2021-06-19 14:35:51 +02:00
parent 138ff39dd1
commit 0eb3e2f9fc
9 changed files with 300 additions and 68 deletions

View file

@ -1,5 +1,11 @@
# Changelog # 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 ## bliss 0.3.0
* Changed `Song.path` from `String` to `PathBuf`. * Changed `Song.path` from `String` to `PathBuf`.
* Made `Song` metadata (artist, album, etc) `Option`s. * Made `Song` metadata (artist, album, etc) `Option`s.

2
Cargo.lock generated
View file

@ -75,7 +75,7 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]] [[package]]
name = "bliss-audio" name = "bliss-audio"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"bliss-audio-aubio-rs", "bliss-audio-aubio-rs",
"crossbeam", "crossbeam",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bliss-audio" name = "bliss-audio"
version = "0.3.0" version = "0.3.1"
authors = ["Polochon-street <polochonstreet@gmx.fr>"] authors = ["Polochon-street <polochonstreet@gmx.fr>"]
edition = "2018" edition = "2018"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -556,8 +556,8 @@ mod bench {
use ndarray::{arr2, Array1, Array2}; use ndarray::{arr2, Array1, Array2};
use ndarray_npy::ReadNpyExt; use ndarray_npy::ReadNpyExt;
use std::fs::File; use std::fs::File;
use test::Bencher;
use std::path::Path; use std::path::Path;
use test::Bencher;
#[bench] #[bench]
fn bench_estimate_tuning(b: &mut Bencher) { fn bench_estimate_tuning(b: &mut Bencher) {

75
src/distance.rs Normal file
View file

@ -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<f32>, &Array1<f32>) -> f32 {}
impl<F> DistanceMetric for F where F: Fn(&Array1<f32>, &Array1<f32>) -> f32 {}
/// Return the [euclidean
/// distance](https://en.wikipedia.org/wiki/Euclidean_distance#Higher_dimensions)
/// between two vectors.
pub fn euclidean_distance(a: &Array1<f32>, b: &Array1<f32>) -> 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<f32>, b: &Array1<f32>) -> 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.);
}
}

View file

@ -65,6 +65,7 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#![warn(missing_doc_code_examples)] #![warn(missing_doc_code_examples)]
mod chroma; mod chroma;
pub mod distance;
mod library; mod library;
mod misc; mod misc;
mod song; mod song;
@ -80,7 +81,7 @@ extern crate serde;
use thiserror::Error; use thiserror::Error;
pub use library::Library; 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 CHANNELS: u16 = 1;
const SAMPLE_RATE: u32 = 22050; const SAMPLE_RATE: u32 = 22050;
@ -178,7 +179,11 @@ mod tests {
let mut analysed_songs: Vec<String> = results let mut analysed_songs: Vec<String> = results
.iter() .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(); .collect();
analysed_songs.sort_by(|a, b| a.cmp(b)); analysed_songs.sort_by(|a, b| a.cmp(b));

View file

@ -1,5 +1,8 @@
//! Module containing the Library trait, useful to get started to implement //! Module containing the Library trait, useful to get started to implement
//! a plug-in for an audio player. //! a plug-in for an audio player.
#[cfg(doc)]
use crate::distance;
use crate::distance::DistanceMetric;
use crate::{BlissError, BlissResult, Song}; use crate::{BlissError, BlissResult, Song};
use log::{debug, error, info}; use log::{debug, error, info};
use noisy_float::prelude::*; use noisy_float::prelude::*;
@ -42,15 +45,71 @@ pub trait Library {
first_song: Song, first_song: Song,
playlist_length: usize, playlist_length: usize,
) -> BlissResult<Vec<Song>> { ) -> BlissResult<Vec<Song>> {
let analysis_current_song = first_song.analysis;
let mut songs = self.get_stored_songs()?; 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 let playlist = songs
.into_iter() .into_iter()
.take(playlist_length) .take(playlist_length)
.collect::<Vec<Song>>(); .collect::<Vec<Song>>();
debug!("Playlist created: {:?}", playlist); 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.
///
/// # 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 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::<Vec<Song>>();
debug!(
"Playlist created: {}",
playlist
.iter()
.map(|s| format!("{:?}", &s))
.collect::<Vec<String>>()
.join("\n"),
);
Ok(playlist) Ok(playlist)
} }
@ -95,9 +154,13 @@ pub trait Library {
// A storage fail should just warn the user, but not abort the whole process // A storage fail should just warn the user, but not abort the whole process
match song { match song {
Ok(song) => { Ok(song) => {
self.store_song(&song) self.store_song(&song).unwrap_or_else(|e| {
.unwrap_or_else(|_| error!("Error while storing song '{}'", song.path.display())); error!("Error while storing song '{}': {}", song.path.display(), e)
info!("Analyzed and stored song '{}' successfully.", song.path.display()) });
info!(
"Analyzed and stored song '{}' successfully.",
song.path.display()
)
} }
Err(e) => { Err(e) => {
self.store_error_song(path.to_string(), e.to_owned()) self.store_error_song(path.to_string(), e.to_owned())
@ -135,6 +198,7 @@ pub trait Library {
mod test { mod test {
use super::*; use super::*;
use crate::song::Analysis; use crate::song::Analysis;
use ndarray::Array1;
use std::path::Path; use std::path::Path;
#[derive(Default)] #[derive(Default)]
@ -158,11 +222,7 @@ mod test {
Ok(()) Ok(())
} }
fn store_error_song( fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()> {
&mut self,
song_path: String,
error: BlissError,
) -> BlissResult<()> {
self.failed_files.push((song_path, error.to_string())); self.failed_files.push((song_path, error.to_string()));
Ok(()) Ok(())
} }
@ -221,11 +281,7 @@ mod test {
Ok(vec![]) Ok(vec![])
} }
fn store_error_song( fn store_error_song(&mut self, song_path: String, error: BlissError) -> BlissResult<()> {
&mut self,
song_path: String,
error: BlissError,
) -> BlissResult<()> {
Err(BlissError::ProviderError(format!( Err(BlissError::ProviderError(format!(
"Could not store errored song: {}, with error: {}", "Could not store errored song: {}, with error: {}",
song_path, error song_path, error
@ -384,4 +440,52 @@ mod test {
let mut test_library = TestLibrary::default(); let mut test_library = TestLibrary::default();
assert!(test_library.analyze_paths(vec![]).is_ok()); 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()
);
}
} }

View file

@ -14,6 +14,7 @@ extern crate ndarray_npy;
use super::CHANNELS; use super::CHANNELS;
use crate::chroma::ChromaDesc; use crate::chroma::ChromaDesc;
use crate::distance::{euclidean_distance, DistanceMetric};
use crate::misc::LoudnessDesc; use crate::misc::LoudnessDesc;
use crate::temporal::BPMDesc; use crate::temporal::BPMDesc;
use crate::timbral::{SpectralDesc, ZeroCrossingRateDesc}; 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::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, Array, Array1}; use ndarray::{arr1, Array1};
use std::convert::TryInto; use std::convert::TryInto;
use std::fmt; use std::fmt;
use std::sync::mpsc;
use std::sync::mpsc::Receiver;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::mpsc::Receiver;
use std::thread as std_thread; use std::thread as std_thread;
use strum::{EnumCount, IntoEnumIterator}; use strum::{EnumCount, IntoEnumIterator};
use strum_macros::{EnumCount, EnumIter}; use strum_macros::{EnumCount, EnumIter};
@ -173,34 +174,55 @@ impl Analysis {
self.internal_analysis.to_vec() self.internal_analysis.to_vec()
} }
/// Return the [euclidean /// Compute distance between two analysis using a user-provided distance
/// distance](https://en.wikipedia.org/wiki/Euclidean_distance#Higher_dimensions) /// metric. You most likely want to use `song.custom_distance` directly
/// between two analysis. /// rather than this function.
/// ///
/// Note that it is usually easier to just use [`song.distance(song2)`](Song::distance) /// For this function to be integrated properly with the rest
/// (which calls this function in turn). /// of bliss' parts, it should be a valid distance metric, i.e.:
pub fn distance(&self, other: &Self) -> f32 { /// 1. For X, Y real vectors, d(X, Y) = 0 ⇔ X = Y
let a1 = self.to_arr1(); /// 2. For X, Y real vectors, d(X, Y) >= 0
let a2 = other.to_arr1(); /// 3. For X, Y real vectors, d(X, Y) = d(Y, X)
// Could be any square symmetric positive semi-definite matrix; /// 4. For X, Y, Z real vectors d(X, Y) ≤ d(X + Z) + d(Z, Y)
// just no metric learning has been done yet. ///
// See https://lelele.io/thesis.pdf chapter 4. /// Note that almost all distance metrics you will find obey these
let m = Array::eye(NUMBER_FEATURES); /// properties, so don't sweat it too much.
pub fn custom_distance(&self, other: &Self, distance: impl DistanceMetric) -> f32 {
(self.to_arr1() - &a2).dot(&m).dot(&(&a1 - &a2)).sqrt() distance(&self.to_arr1(), &other.to_arr1())
} }
} }
impl Song { impl Song {
#[allow(dead_code)] #[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 /// The smaller the number, the closer the songs; usually more useful
/// if compared between several songs /// if compared between several songs
/// (e.g. if song1.distance(song2) < song1.distance(song3), then song1 is /// (e.g. if song1.distance(song2) < song1.distance(song3), then song1 is
/// closer to song2 than it is to song3. /// 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 { 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 /// Returns a decoded Song given a file path, or an error if the song
@ -261,25 +283,23 @@ impl Song {
} }
thread::scope(|s| { thread::scope(|s| {
let child_tempo: thread::ScopedJoinHandle<'_, BlissResult<f32>> = let child_tempo: thread::ScopedJoinHandle<'_, BlissResult<f32>> = s.spawn(|_| {
s.spawn(|_| { let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?;
let mut tempo_desc = BPMDesc::new(SAMPLE_RATE)?; let windows = sample_array
let windows = sample_array .windows(BPMDesc::WINDOW_SIZE)
.windows(BPMDesc::WINDOW_SIZE) .step_by(BPMDesc::HOP_SIZE);
.step_by(BPMDesc::HOP_SIZE);
for window in windows { for window in windows {
tempo_desc.do_(&window)?; tempo_desc.do_(&window)?;
} }
Ok(tempo_desc.get_value()) Ok(tempo_desc.get_value())
}); });
let child_chroma: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> = let child_chroma: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> = s.spawn(|_| {
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()) });
});
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
let child_timbral: thread::ScopedJoinHandle< let child_timbral: thread::ScopedJoinHandle<
@ -305,8 +325,8 @@ impl Song {
Ok(zcr_desc.get_value()) Ok(zcr_desc.get_value())
}); });
let child_loudness: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> = s let child_loudness: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> =
.spawn(|_| { s.spawn(|_| {
let mut loudness_desc = LoudnessDesc::default(); let mut loudness_desc = LoudnessDesc::default();
let windows = sample_array.chunks(LoudnessDesc::WINDOW_SIZE); let windows = sample_array.chunks(LoudnessDesc::WINDOW_SIZE);
@ -390,7 +410,6 @@ impl Song {
"" => None, "" => None,
a => Some(a.to_string()), a => Some(a.to_string()),
}; };
}; };
if let Some(album) = format.metadata().get("album") { if let Some(album) = format.metadata().get("album") {
song.album = match album { song.album = match album {
@ -804,14 +823,35 @@ mod tests {
format!("{:?}", song.analysis), format!("{:?}", song.analysis),
); );
} }
fn dummy_distance(_: &Array1<f32>, _: &Array1<f32>) -> 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))] #[cfg(all(feature = "bench", test))]
mod bench { mod bench {
extern crate test; extern crate test;
use crate::Song; use crate::Song;
use test::Bencher;
use std::path::Path; use std::path::Path;
use test::Bencher;
#[bench] #[bench]
fn bench_resample_multi(b: &mut Bencher) { fn bench_resample_multi(b: &mut Bencher) {

View file

@ -525,8 +525,8 @@ mod bench {
use super::*; use super::*;
use crate::Song; use crate::Song;
use ndarray::Array; use ndarray::Array;
use test::Bencher;
use std::path::Path; use std::path::Path;
use test::Bencher;
#[bench] #[bench]
fn bench_convolve(b: &mut Bencher) { fn bench_convolve(b: &mut Bencher) {
@ -540,7 +540,9 @@ mod bench {
#[bench] #[bench]
fn bench_compute_stft(b: &mut Bencher) { 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(|| { b.iter(|| {
stft(&signal, 2048, 512); stft(&signal, 2048, 512);