Merge pull request #16 from Polochon-street/add-cosine-distance

Add cosine distance and formatter
This commit is contained in:
Polochon-street 2021-06-21 21:57:01 +02:00 committed by GitHub
commit ff500851c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 300 additions and 68 deletions

View File

@ -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.

2
Cargo.lock generated
View File

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

View File

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

View File

@ -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) {

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

@ -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<Song> = paths
//! .iter()
//! .map(|path| Song::new(path))
//! .collect::<BlissResult<Vec<Song>>>()?;
//!
//!
//! // 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<String> = 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));

View File

@ -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<Vec<Song>> {
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::<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)
}
@ -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<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 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<f32>> =
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<f32>> = 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<Vec<f32>>> =
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<Vec<f32>>> = 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<Vec<f32>>> = s
.spawn(|_| {
let child_loudness: thread::ScopedJoinHandle<'_, BlissResult<Vec<f32>>> =
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<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))]
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) {

View File

@ -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);