Compare commits

..

No commits in common. "symphonia" and "minor-fixes-2" have entirely different histories.

28 changed files with 1109 additions and 6762 deletions

View file

@ -10,7 +10,7 @@ env:
CARGO_TERM_COLOR: always
jobs:
build-test-lint-linux:
build:
runs-on: ubuntu-latest
@ -20,61 +20,17 @@ jobs:
submodules: recursive
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly-2022-02-16
toolchain: nightly-2021-04-01
override: false
- name: Packages
run: sudo apt-get update && sudo apt-get install build-essential yasm libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev libavfilter-dev libavdevice-dev libswresample-dev libfftw3-dev ffmpeg
- name: Check format
run: cargo fmt -- --check
run: sudo apt-get install build-essential yasm libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev libavfilter-dev libavdevice-dev libswresample-dev libfftw3-dev ffmpeg
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Run library tests
run: cargo test --verbose --features=library
- name: Run example tests
run: cargo test --verbose --examples
- name: Build benches
run: cargo +nightly-2022-02-16 bench --verbose --features=bench --no-run
run: cargo +nightly-2021-04-01 bench --verbose --features=bench --no-run
- name: Build examples
run: cargo build --examples --verbose --features=serde,library
- name: Lint
run: cargo clippy --examples --features=serde,library -- -D warnings
build-test-lint-windows:
name: Windows - build, test and lint
runs-on: windows-latest
strategy:
matrix:
include:
- ffmpeg_version: latest
ffmpeg_download_url: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full-shared.7z
fail-fast: false
env:
FFMPEG_DOWNLOAD_URL: ${{ matrix.ffmpeg_download_url }}
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: |
$VCINSTALLDIR = $(& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath)
Add-Content $env:GITHUB_ENV "LIBCLANG_PATH=${VCINSTALLDIR}\VC\Tools\LLVM\x64\bin`n"
Invoke-WebRequest "${env:FFMPEG_DOWNLOAD_URL}" -OutFile ffmpeg-release-full-shared.7z
7z x ffmpeg-release-full-shared.7z
mkdir ffmpeg
mv ffmpeg-*/* ffmpeg/
Add-Content $env:GITHUB_ENV "FFMPEG_DIR=${pwd}\ffmpeg`n"
Add-Content $env:GITHUB_PATH "${pwd}\ffmpeg\bin`n"
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Build
run: cargo build --examples
- name: Test
run: cargo test --examples --features=serde
- name: Lint
run: cargo clippy --examples --features=serde -- -D warnings
- name: Check format
run: cargo fmt -- --check
run: cargo build --examples --verbose

View file

@ -1,97 +1,6 @@
#Changelog
# Changelog
## bliss 0.6.5
* Fix library update performance issues.
* Pretty-print JSON in the config file.
## bliss 0.6.4
* Fix a bug in the customizable CPU number option in `library`.
## bliss 0.6.3
* Add customizable CPU number in the `library` module.
## bliss 0.6.2
* Add a `library` module, that greatly helps when making player plug-ins.
## bliss 0.6.1
* Fix a decoding bug while decoding certain WAV files.
## bliss 0.6.0
* Change String to PathBuf in `analyze_paths`.
* Add `analyze_paths_with_cores`.
## bliss 0.5.2
* Fix a bug with some broken MP3 files.
* Bump ffmpeg to 5.1.0.
## bliss 0.5.0
* Add support for CUE files.
* Add `album_artist` and `duration` to `Song`.
* Fix a bug in `estimate_tuning` that led to empty chroma errors.
* 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
more coherent.
* Rename `Song::new` to `Song::from_path`.
## bliss 0.4.6
* Bump ffmpeg crate version to allow for cross-compilation.
## bliss 0.4.5
* Bump ffmpeg crate version.
* Add an "ffmpeg-static" option.
## bliss 0.4.4
* Make features' version public.
## bliss 0.4.3
* Add features' version on each Song instance.
## bliss 0.4.2
* Add a binary example to easily make playlists.
## bliss 0.4.1
* Add a function to make album playlists.
## bliss 0.4.0
* Make the song-to-song custom sorting method faster.
* Rename `to_vec` and `to_arr1` to `as_vec` and `as_arr1` .
* Add a playlist_dedup function.
## bliss 0.3.5
* Add custom sorting methods for playlist-making.
## bliss 0.3.4
* Bump ffmpeg's version to avoid building ffmpeg when building bliss.
## bliss 0.3.3
* Add a streaming analysis function, to help libraries displaying progress.
## bliss 0.3.2
* Fixed a rare ffmpeg multithreading bug.
## 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.
* Added a `BlissResult` error type.
## bliss 0.2.6
* Fixed an allocation bug in Song::decode that potentially caused segfaults.
## bliss 0.2.5
* Updates to docs
## bliss 0.2.4
* Make `Analysis::to_vec()` public.
## bliss 0.2.3
## bliss 0.2.2
* Made `NUMBER_FEATURES` public.

1082
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,27 @@
[package]
name = "bliss-audio-symphonia"
version = "0.6.5"
authors = ["Polochon-street <polochonstreet@gmx.fr>", "NGnius (Graham) <ngniusness@gmail.com>"]
name = "bliss-audio"
version = "0.2.1"
authors = ["Polochon-street <polochonstreet@gmx.fr>"]
edition = "2018"
license = "GPL-3.0-only"
description = "A song analysis library for making playlists"
homepage = "https://lelele.io/bliss.html"
repository = "https://github.com/NGnius/bliss-rs"
repository = "https://github.com/Polochon-street/bliss-rs"
keywords = ["audio", "analysis", "MIR", "playlist", "similarity"]
readme = "README.md"
[package.metadata.docs.rs]
features = ["bliss-audio-aubio-rs/rustdoc", "library"]
features = ["bliss-audio-aubio-rs/rustdoc"]
no-default-features = true
[features]
default = ["bliss-audio-aubio-rs/static"]
# Build ffmpeg instead of using the host's.
# Use if you want to build python bindings with maturin.
python-bindings = ["bliss-audio-aubio-rs/fftw3"]
# Enable the benchmarks with `cargo +nightly bench --features=bench`
# Building ffmpeg until either
# https://github.com/zmwangx/rust-ffmpeg/pull/60
# or https://github.com/zmwangx/rust-ffmpeg/pull/62 is in
default = ["bliss-audio-aubio-rs/static", "build-ffmpeg"]
build-ffmpeg = ["ffmpeg-next/build"]
bench = []
library = [
"serde", "dep:rusqlite", "dep:dirs", "dep:tempdir",
"dep:anyhow", "dep:serde_ini", "dep:serde_json",
"dep:indicatif",
]
serde = ["dep:serde"]
python-bindings = ["bliss-audio-aubio-rs/fftw3"]
[dependencies]
ripemd160 = "0.9.0"
@ -39,8 +34,7 @@ lazy_static = "1.4.0"
rayon = "1.5.0"
crossbeam = "0.8.0"
noisy_float = "0.2.0"
symphonia = { version = "0.5", features = ["mp3", "aac", "alac"]}
rubato = { version = "0.12" }
ffmpeg-next = "4.3.8"
log = "0.4.14"
env_logger = "0.8.3"
thiserror = "1.0.24"
@ -49,35 +43,4 @@ thiserror = "1.0.24"
bliss-audio-aubio-rs = "0.2.0"
strum = "0.21"
strum_macros = "0.21"
rcue = "0.1.1"
# Deps for the library feature
serde = { version = "1.0", optional = true, features = ["derive"] }
serde_json = { version = "1.0.59", optional = true }
serde_ini = { version = "0.2.0", optional = true }
tempdir = { version = "0.3.7", optional = true }
rusqlite = { version = "0.27.0", optional = true }
dirs = { version = "4.0.0", optional = true }
anyhow = { version = "1.0.58", optional = true }
indicatif = { version = "0.17.0", optional = true }
[dev-dependencies]
mime_guess = "2.0.3"
glob = "0.3.0"
anyhow = "1.0.45"
clap = "2.33.3"
pretty_assertions = "1.2.1"
serde_json = "1.0.59"
[[example]]
name = "library"
required-features = ["library"]
[[example]]
name = "library_extra_info"
required-features = ["library"]
[[example]]
name = "playlist"
required-features = ["serde"]

104
README.md
View file

@ -1,17 +1,14 @@
A modified version of the bliss-audio to remove ffmpeg and replace it with Rust's symphonia library.
[![crate](https://img.shields.io/crates/v/bliss-audio.svg)](https://crates.io/crates/bliss-audio)
[![build](https://github.com/Polochon-street/bliss-rs/workflows/Rust/badge.svg)](https://github.com/Polochon-street/bliss-rs/actions)
[![doc](https://docs.rs/bliss-audio/badge.svg)](https://docs.rs/bliss-audio/)
[![doc](https://docs.rs/bliss-rs/badge.svg)](https://docs.rs/bliss-audio/)
# bliss music analyzer - Rust version
# bliss music analyser - Rust version
bliss-rs is the Rust improvement of [bliss](https://github.com/Polochon-street/bliss), a
library used to make playlists by analyzing songs, and computing distance between them.
Like bliss, it eases the creation of « intelligent » playlists and/or continuous
play, à la Spotify/Grooveshark Radio, as well as easing creating plug-ins for
existing audio players. For instance, you can use it to make calm playlists
to help you sleeping, fast playlists to get you started during the day, etc.
existing audio players.
For now (and if you're looking for an easy-to use smooth play experience),
[blissify](https://crates.io/crates/blissify) implements bliss for
@ -24,56 +21,53 @@ used by C-bliss, since it uses
different, more accurate values, based on
[actual literature](https://lelele.io/thesis.pdf). It is also faster.
Note 2: The `bliss-rs` crate is outdated. You should use `bliss-audio`
(this crate) instead.
## Examples
For simple analysis / distance computing, take a look at `examples/distance.rs` and
`examples/analyze.rs`.
For simple analysis / distance computing, a look at `examples/distance.rs` and
`examples/analyse.rs`.
If you simply want to try out making playlists from a folder containing songs,
[this example](https://github.com/Polochon-street/bliss-rs/blob/master/examples/playlist.rs)
contains all you need. Usage:
cargo run --features=serde --release --example=playlist /path/to/folder /path/to/first/song
Don't forget the `--release` flag!
By default, it outputs the playlist to stdout, but you can use `-o <path>`
to output it to a specific path.
To avoid having to analyze the entire folder
several times, it also stores the analysis in `/tmp/analysis.json`. You can customize
this behavior by using `-a <path>` to store this file in a specific place.
Ready to use code examples:
Ready to use examples:
### Compute the distance between two songs
```
use bliss_audio::{BlissError, Song};
use bliss_audio::Song;
fn main() -> Result<(), BlissError> {
let song1 = Song::from_path("/path/to/song1")?;
let song2 = Song::from_path("/path/to/song2")?;
println!("Distance between song1 and song2 is {}", song1.distance(&song2));
Ok(())
fn main() {
let song1 = Song::new("/path/to/song1");
let song2 = Song::new("/path/to/song2");
println!("Distance between song1 and song2 is {}", song1.distance(song2));
}
```
### Make a playlist from a song
```
use bliss_audio::{BlissError, Song};
use bliss_rs::{BlissError, Song};
use ndarray::{arr1, Array};
use noisy_float::prelude::n32;
fn main() -> Result<(), BlissError> {
let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"];
let mut songs: Vec<Song> = paths
.iter()
.map(|path| Song::from_path(path))
.map(|path| Song::new(path))
.collect::<Result<Vec<Song>, BlissError>>()?;
// Assuming there is a first song
let first_song = songs.first().unwrap().to_owned();
let analysis_first_song = arr1(&songs[0].analysis);
songs.sort_by_cached_key(|song| n32(first_song.distance(&song)));
// Identity matrix used to compute the distance.
// Note that it can be changed to alter feature ponderation, which
// may yield to better playlists (subjectively).
let m = Array::eye(analysis_first_song.len());
songs.sort_by_cached_key(|song| {
n32((arr1(&song.analysis) - &analysis_first_song)
.dot(&m)
.dot(&(arr1(&song.analysis) - &analysis_first_song)))
});
println!(
"Playlist is: {:?}",
songs
@ -88,43 +82,17 @@ fn main() -> Result<(), BlissError> {
## Further use
Instead of reinventing ways to fetch a user library, play songs, etc,
and embed that into bliss, it is easier to look at the
[library](https://docs.rs/bliss-audio/latest/bliss_audio/library/index.html) module.
It implements common analysis functions, and allows analyzed songs to be put
in a sqlite database seamlessly.
and embed that into bliss, it is easier to look at the
[Library](https://github.com/Polochon-street/bliss-rs/blob/master/src/library.rs#L12)
trait.
By implementing a few functions to get songs from a media library, and store
the resulting analysis, you get access to functions to analyze an entire
library (with multithreading), and to make playlists easily.
See [blissify](https://crates.io/crates/blissify) for a reference
implementation.
## Cross-compilation
To cross-compile bliss-rs from linux to x86_64 windows, install the
`x86_64-pc-windows-gnu` target via:
rustup target add x86_64-pc-windows-gnu
Make sure you have `x86_64-w64-mingw32-gcc` installed on your computer.
Then run:
cargo build --target x86_64-pc-windows-gnu --release
Will produce a `.rlib` library file. If you want to generate a shared `.dll`
library, add:
[lib]
crate-type = ["cdylib"]
to `Cargo.toml` before compiling, and if you want to generate a `.lib` static
library, add:
[lib]
crate-type = ["staticlib"]
You can of course test the examples yourself by compiling them as .exe:
cargo build --target x86_64-pc-windows-gnu --release --examples
## Acknowledgements
* This library relies heavily on [aubio](https://aubio.org/)'s

View file

@ -1,29 +0,0 @@
REM GENRE Random
REM DATE 2022
PERFORMER "Polochon_street"
TITLE "Album for CUE test"
FILE "empty.wav" WAVE
TRACK 01 AUDIO
TITLE "Renaissance"
PERFORMER "David TMX"
INDEX 01 0:00:00
TRACK 02 AUDIO
TITLE "Piano"
PERFORMER "Polochon_street"
INDEX 01 0:11:05
TRACK 03 AUDIO
TITLE "Tone"
PERFORMER "Polochon_street"
INDEX 01 0:16:69
FILE "not-existing.wav" WAVE
TRACK 01 AUDIO
TITLE "Nope"
PERFORMER "Charlie"
INDEX 01 0:00:00
TRACK 02 AUDIO
TITLE "Nope"
PERFORMER "Charlie"
INDEX 01 0:10:00

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,29 +0,0 @@
REM GENRE Random
REM DATE 2022
PERFORMER "Polochon_street"
TITLE "Album for CUE test"
FILE "testcue.flac" WAVE
TRACK 01 AUDIO
TITLE "Renaissance"
PERFORMER "David TMX"
INDEX 01 0:00:00
TRACK 02 AUDIO
TITLE "Piano"
PERFORMER "Polochon_street"
INDEX 01 0:11:05
TRACK 03 AUDIO
TITLE "Tone"
PERFORMER "Polochon_street"
INDEX 01 0:16:69
FILE "not-existing.wav" WAVE
TRACK 01 AUDIO
TITLE "Nope"
PERFORMER "Charlie"
INDEX 01 0:00:00
TRACK 02 AUDIO
TITLE "Nope"
PERFORMER "Charlie"
INDEX 01 0:10:00

Binary file not shown.

View file

@ -1,15 +1,15 @@
use bliss_audio_symphonia::Song;
use bliss_audio::Song;
use std::env;
/**
* Simple utility to print the result of an Analysis.
*
* Takes a list of files to analyze an the result of the corresponding Analysis.
* Takes a list of files to analyse an the result of the corresponding Analysis.
*/
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
for path in &args {
match Song::from_path(&path) {
match Song::new(&path) {
Ok(song) => println!("{}: {:?}", path, song.analysis),
Err(e) => println!("{}: {}", path, e),
}

View file

@ -1,10 +1,10 @@
use bliss_audio_symphonia::Song;
use bliss_audio::Song;
use std::env;
/**
* Simple utility to print distance between two songs according to bliss.
*
* Takes two file paths, and analyze the corresponding songs, printing
* Takes two file paths, and analyse the corresponding songs, printing
* the distance between the two files according to bliss.
*/
fn main() -> Result<(), String> {
@ -13,11 +13,11 @@ fn main() -> Result<(), String> {
let first_path = paths.next().ok_or("Help: ./distance <song1> <song2>")?;
let second_path = paths.next().ok_or("Help: ./distance <song1> <song2>")?;
let song1 = Song::from_path(&first_path).map_err(|x| x.to_string())?;
let song2 = Song::from_path(&second_path).map_err(|x| x.to_string())?;
let song1 = Song::new(&first_path).map_err(|x| x.to_string())?;
let song2 = Song::new(&second_path).map_err(|x| x.to_string())?;
println!(
"d({:?}, {:?}) = {}",
"d({}, {}) = {}",
song1.path,
song2.path,
song1.distance(&song2)

View file

@ -1,204 +0,0 @@
/// Basic example of how one would combine bliss with an "audio player",
/// through [Library].
///
/// For simplicity's sake, this example recursively gets songs from a folder
/// to emulate an audio player library, without handling CUE files.
use anyhow::Result;
use bliss_audio::library::{AppConfigTrait, BaseConfig, Library};
use clap::{App, Arg, SubCommand};
use glob::glob;
use serde::{Deserialize, Serialize};
use std::fs;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, Debug)]
// A config structure, that will be serialized as a
// JSON file upon Library creation.
pub struct Config {
#[serde(flatten)]
// The base configuration, containing both the config file
// path, as well as the database path.
pub base_config: BaseConfig,
// An extra field, to store the music library path. Any number
// of arbitrary fields (even Serializable structures) can
// of course be added.
pub music_library_path: PathBuf,
}
impl Config {
pub fn new(
music_library_path: PathBuf,
config_path: Option<PathBuf>,
database_path: Option<PathBuf>,
number_cores: Option<NonZeroUsize>,
) -> Result<Self> {
let base_config = BaseConfig::new(config_path, database_path, number_cores)?;
Ok(Self {
base_config,
music_library_path,
})
}
}
// The AppConfigTrait must know how to access the base config.
impl AppConfigTrait for Config {
fn base_config(&self) -> &BaseConfig {
&self.base_config
}
fn base_config_mut(&mut self) -> &mut BaseConfig {
&mut self.base_config
}
}
// A trait allowing to implement methods for the Library,
// useful if you don't need to store extra information in fields.
// Otherwise, doing
// ```
// struct CustomLibrary {
// library: Library<Config>,
// extra_field: ...,
// }
// ```
// and implementing functions for that struct would be the way to go.
// That's what the [reference](https://github.com/Polochon-street/blissify-rs)
// implementation does.
trait CustomLibrary {
fn song_paths(&self) -> Result<Vec<String>>;
}
impl CustomLibrary for Library<Config> {
/// Get all songs in the player library
fn song_paths(&self) -> Result<Vec<String>> {
let music_path = &self.config.music_library_path;
let pattern = Path::new(&music_path).join("**").join("*");
Ok(glob(&pattern.to_string_lossy())?
.map(|e| fs::canonicalize(e.unwrap()).unwrap())
.filter(|e| match mime_guess::from_path(e).first() {
Some(m) => m.type_() == "audio",
None => false,
})
.map(|x| x.to_string_lossy().to_string())
.collect::<Vec<String>>())
}
}
// A simple example of what a CLI-app would look.
//
// Note that `Library::new` is used only on init, and subsequent
// commands use `Library::from_path`.
fn main() -> Result<()> {
let matches = App::new("library-example")
.version(env!("CARGO_PKG_VERSION"))
.author("Polochon_street")
.about("Example binary implementing bliss for an audio player.")
.subcommand(
SubCommand::with_name("init")
.about(
"Initialize a Library, both storing the config and analyzing folders
containing songs.",
)
.arg(
Arg::with_name("FOLDER")
.help("A folder containing the music library to analyze.")
.required(true),
)
.arg(
Arg::with_name("database-path")
.short("d")
.long("database-path")
.help(
"Optional path where to store the database file containing
the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.",
)
.takes_value(true),
)
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to store the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
),
)
.subcommand(
SubCommand::with_name("update")
.about(
"Update a Library's songs, trying to analyze failed songs,
as well as songs not in the library.",
)
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to load the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
),
)
.subcommand(
SubCommand::with_name("playlist")
.about(
"Make a playlist, starting with the song at SONG_PATH, returning
the songs' paths.",
)
.arg(Arg::with_name("SONG_PATH").takes_value(true))
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to load the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
)
.arg(
Arg::with_name("playlist-length")
.short("l")
.long("playlist-length")
.help("Optional playlist length. Defaults to 20.")
.takes_value(true),
),
)
.get_matches();
if let Some(sub_m) = matches.subcommand_matches("init") {
let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap());
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let database_path = sub_m.value_of("database-path").map(PathBuf::from);
let config = Config::new(folder, config_path, database_path, None)?;
let mut library = Library::new(config)?;
library.analyze_paths(library.song_paths()?, true)?;
} else if let Some(sub_m) = matches.subcommand_matches("update") {
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let mut library: Library<Config> = Library::from_config_path(config_path)?;
library.update_library(library.song_paths()?, true)?;
} else if let Some(sub_m) = matches.subcommand_matches("playlist") {
let song_path = sub_m.value_of("SONG_PATH").unwrap();
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let playlist_length = sub_m
.value_of("playlist-length")
.unwrap_or("20")
.parse::<usize>()?;
let library: Library<Config> = Library::from_config_path(config_path)?;
let songs = library.playlist_from::<()>(song_path, playlist_length)?;
let song_paths = songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>();
for song in song_paths {
println!("{:?}", song);
}
}
Ok(())
}

View file

@ -1,227 +0,0 @@
/// Basic example of how one would combine bliss with an "audio player",
/// through [Library], showing how to put extra info in the database for
/// each song.
///
/// For simplicity's sake, this example recursively gets songs from a folder
/// to emulate an audio player library, without handling CUE files.
use anyhow::Result;
use bliss_audio::library::{AppConfigTrait, BaseConfig, Library};
use clap::{App, Arg, SubCommand};
use glob::glob;
use serde::{Deserialize, Serialize};
use std::fs;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, Debug)]
/// A config structure, that will be serialized as a
/// JSON file upon Library creation.
pub struct Config {
#[serde(flatten)]
/// The base configuration, containing both the config file
/// path, as well as the database path.
pub base_config: BaseConfig,
/// An extra field, to store the music library path. Any number
/// of arbitrary fields (even Serializable structures) can
/// of course be added.
pub music_library_path: PathBuf,
}
impl Config {
pub fn new(
music_library_path: PathBuf,
config_path: Option<PathBuf>,
database_path: Option<PathBuf>,
number_cores: Option<NonZeroUsize>,
) -> Result<Self> {
let base_config = BaseConfig::new(config_path, database_path, number_cores)?;
Ok(Self {
base_config,
music_library_path,
})
}
}
// The AppConfigTrait must know how to access the base config.
impl AppConfigTrait for Config {
fn base_config(&self) -> &BaseConfig {
&self.base_config
}
fn base_config_mut(&mut self) -> &mut BaseConfig {
&mut self.base_config
}
}
// A trait allowing to implement methods for the Library,
// useful if you don't need to store extra information in fields.
// Otherwise, doing
// ```
// struct CustomLibrary {
// library: Library<Config>,
// extra_field: ...,
// }
// ```
// and implementing functions for that struct would be the way to go.
// That's what the [reference](https://github.com/Polochon-street/blissify-rs)
// implementation does.
trait CustomLibrary {
fn song_paths_info(&self) -> Result<Vec<(String, ExtraInfo)>>;
}
impl CustomLibrary for Library<Config> {
/// Get all songs in the player library, along with the extra info
/// one would want to store along with each song.
fn song_paths_info(&self) -> Result<Vec<(String, ExtraInfo)>> {
let music_path = &self.config.music_library_path;
let pattern = Path::new(&music_path).join("**").join("*");
Ok(glob(&pattern.to_string_lossy())?
.map(|e| fs::canonicalize(e.unwrap()).unwrap())
.filter_map(|e| {
mime_guess::from_path(&e).first().map(|m| {
(
e.to_string_lossy().to_string(),
ExtraInfo {
extension: e.extension().map(|e| e.to_string_lossy().to_string()),
file_name: e.file_name().map(|e| e.to_string_lossy().to_string()),
mime_type: format!("{}/{}", m.type_(), m.subtype()),
},
)
})
})
.collect::<Vec<(String, ExtraInfo)>>())
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)]
// An (somewhat simple) example of what extra metadata one would put, along
// with song analysis data.
struct ExtraInfo {
extension: Option<String>,
file_name: Option<String>,
mime_type: String,
}
// A simple example of what a CLI-app would look.
//
// Note that `Library::new` is used only on init, and subsequent
// commands use `Library::from_path`.
fn main() -> Result<()> {
let matches = App::new("library-example")
.version(env!("CARGO_PKG_VERSION"))
.author("Polochon_street")
.about("Example binary implementing bliss for an audio player.")
.subcommand(
SubCommand::with_name("init")
.about(
"Initialize a Library, both storing the config and analyzing folders
containing songs.",
)
.arg(
Arg::with_name("FOLDER")
.help("A folder containing the music library to analyze.")
.required(true),
)
.arg(
Arg::with_name("database-path")
.short("d")
.long("database-path")
.help(
"Optional path where to store the database file containing
the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.",
)
.takes_value(true),
)
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to store the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
),
)
.subcommand(
SubCommand::with_name("update")
.about(
"Update a Library's songs, trying to analyze failed songs,
as well as songs not in the library.",
)
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to load the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
),
)
.subcommand(
SubCommand::with_name("playlist")
.about(
"Make a playlist, starting with the song at SONG_PATH, returning
the songs' paths.",
)
.arg(Arg::with_name("SONG_PATH").takes_value(true))
.arg(
Arg::with_name("config-path")
.short("c")
.long("config-path")
.help(
"Optional path where to load the config file containing
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
)
.takes_value(true),
)
.arg(
Arg::with_name("playlist-length")
.short("l")
.long("playlist-length")
.help("Optional playlist length. Defaults to 20.")
.takes_value(true),
),
)
.get_matches();
if let Some(sub_m) = matches.subcommand_matches("init") {
let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap());
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let database_path = sub_m.value_of("database-path").map(PathBuf::from);
let config = Config::new(folder, config_path, database_path, None)?;
let mut library = Library::new(config)?;
library.analyze_paths_extra_info(library.song_paths_info()?, true)?;
} else if let Some(sub_m) = matches.subcommand_matches("update") {
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let mut library: Library<Config> = Library::from_config_path(config_path)?;
library.update_library_extra_info(library.song_paths_info()?, true)?;
} else if let Some(sub_m) = matches.subcommand_matches("playlist") {
let song_path = sub_m.value_of("SONG_PATH").unwrap();
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
let playlist_length = sub_m
.value_of("playlist-length")
.unwrap_or("20")
.parse::<usize>()?;
let library: Library<Config> = Library::from_config_path(config_path)?;
let songs = library.playlist_from::<ExtraInfo>(song_path, playlist_length)?;
let playlist = songs
.into_iter()
.map(|s| {
(
s.bliss_song.path.to_string_lossy().to_string(),
s.extra_info.mime_type,
)
})
.collect::<Vec<(String, String)>>();
for (path, mime_type) in playlist {
println!("{} <{}>", path, mime_type,);
}
}
Ok(())
}

View file

@ -1,95 +0,0 @@
use anyhow::Result;
use bliss_audio_symphonia::playlist::{closest_to_first_song, dedup_playlist, euclidean_distance};
use bliss_audio_symphonia::{analyze_paths, Song};
use clap::{App, Arg};
use glob::glob;
use std::env;
use std::fs;
use std::io::BufReader;
use std::path::{Path, PathBuf};
/* Analyzes a folder recursively, and make a playlist out of the file
* provided by the user. */
// How to use: ./playlist [-o file.m3u] [-a analysis.json] <folder> <file to start the playlist from>
fn main() -> Result<()> {
let matches = App::new("playlist")
.version(env!("CARGO_PKG_VERSION"))
.author("Polochon_street")
.about("Analyze a folder and make a playlist from a target song")
.arg(Arg::with_name("output-playlist").short("o").long("output-playlist")
.value_name("PLAYLIST.M3U")
.help("Outputs the playlist to a file.")
.takes_value(true))
.arg(Arg::with_name("analysis-file").short("a").long("analysis-file")
.value_name("ANALYSIS.JSON")
.help("Use the songs that have been analyzed in <analysis-file>, and appends newly analyzed songs to it. Defaults to /tmp/analysis.json.")
.takes_value(true))
.arg(Arg::with_name("FOLDER").help("Folders containing some songs.").required(true))
.arg(Arg::with_name("FIRST-SONG").help("Song to start from (can be outside of FOLDER).").required(true))
.get_matches();
let folder = matches.value_of("FOLDER").unwrap();
let file = fs::canonicalize(matches.value_of("FIRST-SONG").unwrap())?;
let pattern = Path::new(folder).join("**").join("*");
let mut songs: Vec<Song> = Vec::new();
let analysis_path = matches
.value_of("analysis-file")
.unwrap_or("/tmp/analysis.json");
let analysis_file = fs::File::open(analysis_path);
if let Ok(f) = analysis_file {
let reader = BufReader::new(f);
songs = serde_json::from_reader(reader)?;
}
let analyzed_paths = songs
.iter()
.map(|s| s.path.to_owned())
.collect::<Vec<PathBuf>>();
let paths = glob(&pattern.to_string_lossy())?
.map(|e| fs::canonicalize(e.unwrap()).unwrap())
.filter(|e| match mime_guess::from_path(e).first() {
Some(m) => m.type_() == "audio",
None => false,
})
.map(|x| x.to_string_lossy().to_string())
.collect::<Vec<String>>();
let song_iterator = analyze_paths(
paths
.iter()
.filter(|p| !analyzed_paths.contains(&PathBuf::from(p)))
.map(|p| p.to_owned())
.collect::<Vec<String>>(),
);
let first_song = Song::from_path(file)?;
let mut analyzed_songs = vec![first_song.to_owned()];
for (path, result) in song_iterator {
match result {
Ok(song) => analyzed_songs.push(song),
Err(e) => println!("error analyzing {}: {}", path.display(), e),
};
}
analyzed_songs.extend_from_slice(&songs);
let serialized = serde_json::to_string(&analyzed_songs).unwrap();
let mut songs_to_chose_from: Vec<_> = analyzed_songs
.into_iter()
.filter(|x| x == &first_song || paths.contains(&x.path.to_string_lossy().to_string()))
.collect();
closest_to_first_song(&first_song, &mut songs_to_chose_from, euclidean_distance);
dedup_playlist(&mut songs_to_chose_from, None);
fs::write(analysis_path, serialized)?;
let playlist = songs_to_chose_from
.iter()
.map(|s| s.path.to_string_lossy().to_string())
.collect::<Vec<String>>()
.join("\n");
if let Some(m) = matches.value_of("output-playlist") {
fs::write(m, playlist)?;
} else {
println!("{}", playlist);
}
Ok(())
}

View file

@ -7,7 +7,7 @@ extern crate noisy_float;
use crate::utils::stft;
use crate::utils::{hz_to_octs_inplace, Normalize};
use crate::{BlissError, BlissResult};
use crate::BlissError;
use ndarray::{arr1, arr2, concatenate, s, Array, Array1, Array2, Axis, Zip};
use ndarray_stats::interpolate::Midpoint;
use ndarray_stats::QuantileExt;
@ -51,7 +51,7 @@ impl ChromaDesc {
* Passing a full song here once instead of streaming smaller parts of the
* song will greatly improve accuracy.
*/
pub fn do_(&mut self, signal: &[f32]) -> BlissResult<()> {
pub fn do_(&mut self, signal: &[f32]) -> Result<(), BlissError> {
let mut stft = stft(signal, ChromaDesc::WINDOW_SIZE, 2205);
let tuning = estimate_tuning(
self.sample_rate as u32,
@ -155,7 +155,7 @@ fn chroma_filter(
n_fft: usize,
n_chroma: u32,
tuning: f64,
) -> BlissResult<Array2<f64>> {
) -> Result<Array2<f64>, BlissError> {
let ctroct = 5.0;
let octwidth = 2.;
let n_chroma_float = f64::from(n_chroma);
@ -180,7 +180,7 @@ fn chroma_filter(
}),
);
let mut d: Array2<f64> = Array::zeros((n_chroma as usize, (freq_bins).len()));
let mut d: Array2<f64> = Array::zeros((n_chroma as usize, (&freq_bins).len()));
for (idx, mut row) in d.rows_mut().into_iter().enumerate() {
row.fill(idx as f64);
}
@ -207,13 +207,13 @@ fn chroma_filter(
wts *= &freq_bins;
// np.roll(), np bro
let mut uninit: Vec<f64> = vec![0.; (wts).len()];
let mut uninit: Vec<f64> = Vec::with_capacity((&wts).len());
unsafe {
uninit.set_len(wts.len());
}
let mut b = Array::from(uninit)
.into_shape(wts.dim())
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e)))?;
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e.to_string())))?;
b.slice_mut(s![-3.., ..]).assign(&wts.slice(s![..3, ..]));
b.slice_mut(s![..-3, ..]).assign(&wts.slice(s![3.., ..]));
@ -226,7 +226,7 @@ fn pip_track(
sample_rate: u32,
spectrum: &Array2<f64>,
n_fft: usize,
) -> BlissResult<(Vec<f64>, Vec<f64>)> {
) -> Result<(Vec<f64>, Vec<f64>), BlissError> {
let sample_rate_float = f64::from(sample_rate);
let fmin = 150.0_f64;
let fmax = 4000.0_f64.min(sample_rate_float / 2.0);
@ -291,7 +291,7 @@ fn pitch_tuning(
frequencies: &mut Array1<f64>,
resolution: f64,
bins_per_octave: u32,
) -> BlissResult<f64> {
) -> Result<f64, BlissError> {
if frequencies.is_empty() {
return Ok(0.0);
}
@ -308,7 +308,7 @@ fn pitch_tuning(
}
let max_index = counts
.argmax()
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e)))?;
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e.to_string())))?;
// Return the bin with the most reoccuring frequency.
Ok((-50. + (100. * resolution * max_index as f64)) / 100.)
@ -320,8 +320,8 @@ fn estimate_tuning(
n_fft: usize,
resolution: f64,
bins_per_octave: u32,
) -> BlissResult<f64> {
let (pitch, mag) = pip_track(sample_rate, spectrum, n_fft)?;
) -> Result<f64, BlissError> {
let (pitch, mag) = pip_track(sample_rate, &spectrum, n_fft)?;
let (filtered_pitch, filtered_mag): (Vec<N64>, Vec<N64>) = pitch
.iter()
@ -330,14 +330,11 @@ fn estimate_tuning(
.map(|(x, y)| (n64(*x), n64(*y)))
.unzip();
if pitch.is_empty() {
return Ok(0.);
}
let threshold: N64 = Array::from(filtered_mag.to_vec())
.quantile_axis_mut(Axis(0), n64(0.5), &Midpoint)
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e)))?
.map_err(|e| BlissError::AnalysisError(format!("in chroma: {}", e.to_string())))?
.into_scalar();
let mut pitch = filtered_pitch
.iter()
.zip(&filtered_mag)
@ -375,7 +372,6 @@ mod test {
use ndarray::{arr1, arr2, Array2};
use ndarray_npy::ReadNpyExt;
use std::fs::File;
use std::path::Path;
#[test]
fn test_chroma_interval_features() {
@ -441,7 +437,7 @@ mod test {
#[test]
fn test_chroma_desc() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
chroma_desc.do_(&song.sample_array).unwrap();
let expected_values = vec![
@ -463,7 +459,7 @@ mod test {
#[test]
fn test_chroma_stft_decode() {
let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac"))
let signal = Song::decode("data/s16_mono_22_5kHz.flac")
.unwrap()
.sample_array;
let mut stft = stft(&signal, 8192, 2205);
@ -489,14 +485,9 @@ mod test {
assert!(0.000001 > (-0.09999999999999998 - tuning).abs());
}
#[test]
fn test_chroma_estimate_tuning_empty_fix() {
assert!(0. == estimate_tuning(22050, &Array2::zeros((8192, 1)), 8192, 0.01, 12).unwrap());
}
#[test]
fn test_estimate_tuning_decode() {
let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac"))
let signal = Song::decode("data/s16_mono_22_5kHz.flac")
.unwrap()
.sample_array;
let stft = stft(&signal, 8192, 2205);
@ -564,7 +555,6 @@ mod bench {
use ndarray::{arr2, Array1, Array2};
use ndarray_npy::ReadNpyExt;
use std::fs::File;
use std::path::Path;
use test::Bencher;
#[bench]
@ -613,7 +603,7 @@ mod bench {
#[bench]
fn bench_chroma_desc(b: &mut Bencher) {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
let signal = song.sample_array;
b.iter(|| {
@ -624,7 +614,7 @@ mod bench {
#[bench]
fn bench_chroma_stft(b: &mut Bencher) {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
let signal = song.sample_array;
b.iter(|| {
@ -635,7 +625,7 @@ mod bench {
#[bench]
fn bench_chroma_stft_decode(b: &mut Bencher) {
let signal = Song::decode(Path::new("data/s16_mono_22_5kHz.flac"))
let signal = Song::decode("data/s16_mono_22_5kHz.flac")
.unwrap()
.sample_array;
let mut stft = stft(&signal, 8192, 2205);

View file

@ -1,338 +0,0 @@
//! CUE-handling module.
//!
//! Using [BlissCue::songs_from_path] is most likely what you want.
use crate::{Analysis, BlissError, BlissResult, Song, FEATURES_VERSION, SAMPLE_RATE};
use rcue::cue::{Cue, Track};
use rcue::parser::parse_from_file;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Default, Debug, PartialEq, Eq, Clone)]
/// A struct populated when the corresponding [Song] has been extracted from an
/// audio file split with the help of a CUE sheet.
pub struct CueInfo {
/// The path of the original CUE sheet, e.g. `/path/to/album_name.cue`.
pub cue_path: PathBuf,
/// The path of the audio file the song was extracted from, e.g.
/// `/path/to/album_name.wav`. Used because one CUE sheet can refer to
/// several audio files.
pub audio_file_path: PathBuf,
}
/// A struct to handle CUEs with bliss.
/// Use either [analyze_paths](crate::analyze_paths) with CUE files or
/// [songs_from_path](BlissCue::songs_from_path) to return a list of [Song]s
/// from CUE files.
pub struct BlissCue {
cue: Cue,
cue_path: PathBuf,
}
#[allow(missing_docs)]
#[derive(Default, Debug, PartialEq, Clone)]
struct BlissCueFile {
sample_array: Vec<f32>,
album: Option<String>,
artist: Option<String>,
genre: Option<String>,
tracks: Vec<Track>,
cue_path: PathBuf,
audio_file_path: PathBuf,
}
impl BlissCue {
/// Analyze songs from a CUE file, extracting individual [Song] objects
/// for each individual song.
///
/// Each returned [Song] has a populated [cue_info](Song::cue_info) object, that can be
/// be used to retrieve which CUE sheet was used to extract it, as well
/// as the corresponding audio file.
pub fn songs_from_path<P: AsRef<Path>>(path: P) -> BlissResult<Vec<BlissResult<Song>>> {
let cue = BlissCue::from_path(&path)?;
let cue_files = cue.files();
let mut songs = Vec::new();
for cue_file in cue_files.into_iter() {
match cue_file {
Ok(f) => {
if !f.sample_array.is_empty() {
songs.extend_from_slice(&f.get_songs());
} else {
songs.push(Err(BlissError::DecodingError(
"empty audio file associated to CUE sheet".into(),
)));
}
}
Err(e) => songs.push(Err(e)),
}
}
Ok(songs)
}
// Extract a BlissCue from a given path.
fn from_path<P: AsRef<Path>>(path: P) -> BlissResult<Self> {
let cue = parse_from_file(&path.as_ref().to_string_lossy(), false).map_err(|e| {
BlissError::DecodingError(format!(
"when opening CUE file '{:?}': {:?}",
path.as_ref(),
e
))
})?;
Ok(BlissCue {
cue,
cue_path: path.as_ref().to_owned(),
})
}
// List all BlissCueFile from a BlissCue.
fn files(&self) -> Vec<BlissResult<BlissCueFile>> {
let mut cue_files = Vec::new();
for cue_file in self.cue.files.iter() {
let audio_file_path = match &self.cue_path.parent() {
Some(parent) => parent.join(Path::new(&cue_file.file)),
None => PathBuf::from(cue_file.file.to_owned()),
};
let genre = self
.cue
.comments
.iter()
.find(|(c, _)| c == "GENRE")
.map(|(_, v)| v.to_owned());
let raw_song = Song::decode(Path::new(&audio_file_path));
if let Ok(song) = raw_song {
let bliss_cue_file = BlissCueFile {
sample_array: song.sample_array,
genre,
artist: self.cue.performer.to_owned(),
album: self.cue.title.to_owned(),
tracks: cue_file.tracks.to_owned(),
audio_file_path,
cue_path: self.cue_path.to_owned(),
};
cue_files.push(Ok(bliss_cue_file))
} else {
cue_files.push(Err(raw_song.unwrap_err()));
}
}
cue_files
}
}
impl BlissCueFile {
fn create_song(
&self,
analysis: BlissResult<Analysis>,
current_track: &Track,
duration: Duration,
index: usize,
) -> BlissResult<Song> {
if let Ok(a) = analysis {
let song = Song {
path: PathBuf::from(format!(
"{}/CUE_TRACK{:03}",
self.cue_path.to_string_lossy(),
index,
)),
album: self.album.to_owned(),
artist: current_track.performer.to_owned(),
album_artist: self.artist.to_owned(),
analysis: a,
duration,
genre: self.genre.to_owned(),
title: current_track.title.to_owned(),
track_number: Some(current_track.no.to_owned()),
features_version: FEATURES_VERSION,
cue_info: Some(CueInfo {
cue_path: self.cue_path.to_owned(),
audio_file_path: self.audio_file_path.to_owned(),
}),
};
Ok(song)
} else {
Err(analysis.unwrap_err())
}
}
// Get all songs from a BlissCueFile, using Song::analyze, each song being
// located using the sample_array and the timestamp delimiter.
fn get_songs(&self) -> Vec<BlissResult<Song>> {
let mut songs = Vec::new();
for (index, tuple) in (self.tracks[..]).windows(2).enumerate() {
let (current_track, next_track) = (tuple[0].to_owned(), tuple[1].to_owned());
if let Some((_, start_current)) = current_track.indices.get(0) {
if let Some((_, end_current)) = next_track.indices.get(0) {
let start_current = (start_current.as_secs_f32() * SAMPLE_RATE as f32) as usize;
let end_current = (end_current.as_secs_f32() * SAMPLE_RATE as f32) as usize;
let duration = Duration::from_secs_f32(
(end_current - start_current) as f32 / SAMPLE_RATE as f32,
);
let analysis = Song::analyze(&self.sample_array[start_current..end_current]);
let song = self.create_song(analysis, &current_track, duration, index + 1);
songs.push(song);
}
}
}
// Take care of the last track, since the windows iterator doesn't.
if let Some(last_track) = self.tracks.last() {
if let Some((_, start_current)) = last_track.indices.get(0) {
let start_current = (start_current.as_secs_f32() * SAMPLE_RATE as f32) as usize;
let duration = Duration::from_secs_f32(
(self.sample_array.len() - start_current) as f32 / SAMPLE_RATE as f32,
);
let analysis = Song::analyze(&self.sample_array[start_current..]);
let song = self.create_song(analysis, last_track, duration, self.tracks.len());
songs.push(song);
}
}
songs
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_empty_cue() {
let songs = BlissCue::songs_from_path("data/empty.cue").unwrap();
let error = songs[0].to_owned().unwrap_err();
assert_eq!(
error,
BlissError::DecodingError("while opening format: DecodeError(\"wav: chunk length exceeds parent (list) chunk length\").".to_string())
);
}
#[test]
fn test_cue_analysis() {
let songs = BlissCue::songs_from_path("data/testcue.cue").unwrap();
let expected = vec![
Ok(Song {
path: Path::new("data/testcue.cue/CUE_TRACK001").to_path_buf(),
analysis: Analysis {
internal_analysis: [
0.38463724,
-0.85219246,
-0.761946,
-0.8904667,
-0.63892543,
-0.73945934,
-0.8004017,
-0.8237293,
0.33865356,
0.32481194,
-0.35692245,
-0.6355889,
-0.29584837,
0.06431806,
0.21875131,
-0.58104205,
-0.9466792,
-0.94811195,
-0.9820919,
-0.9596871,
],
},
album: Some(String::from("Album for CUE test")),
artist: Some(String::from("David TMX")),
title: Some(String::from("Renaissance")),
genre: Some(String::from("Random")),
track_number: Some(String::from("01")),
features_version: FEATURES_VERSION,
album_artist: Some(String::from("Polochon_street")),
duration: Duration::from_secs_f32(11.066666603),
cue_info: Some(CueInfo {
cue_path: PathBuf::from("data/testcue.cue"),
audio_file_path: PathBuf::from("data/testcue.flac"),
}),
..Default::default()
}),
Ok(Song {
path: Path::new("data/testcue.cue/CUE_TRACK002").to_path_buf(),
analysis: Analysis {
internal_analysis: [
0.18622077,
-0.5989029,
-0.5554645,
-0.6343865,
-0.24163479,
-0.25766593,
-0.40616858,
-0.23334873,
0.76875293,
0.7785741,
-0.5075115,
-0.5272629,
-0.56706166,
-0.568486,
-0.5639081,
-0.5706943,
-0.96501005,
-0.96501285,
-0.9649896,
-0.96498996,
],
},
features_version: FEATURES_VERSION,
album: Some(String::from("Album for CUE test")),
artist: Some(String::from("Polochon_street")),
title: Some(String::from("Piano")),
genre: Some(String::from("Random")),
track_number: Some(String::from("02")),
album_artist: Some(String::from("Polochon_street")),
duration: Duration::from_secs_f64(5.853333473),
cue_info: Some(CueInfo {
cue_path: PathBuf::from("data/testcue.cue"),
audio_file_path: PathBuf::from("data/testcue.flac"),
}),
..Default::default()
}),
Ok(Song {
path: Path::new("data/testcue.cue/CUE_TRACK003").to_path_buf(),
analysis: Analysis {
internal_analysis: [
0.0024261475,
0.9874661,
0.97330654,
-0.9724426,
0.99678576,
-0.9961549,
-0.9840142,
-0.9269961,
0.7498772,
0.22429907,
-0.8355152,
-0.9977258,
-0.9977849,
-0.997785,
-0.99778515,
-0.997785,
-0.99999976,
-0.99999976,
-0.99999976,
-0.99999976,
],
},
album: Some(String::from("Album for CUE test")),
artist: Some(String::from("Polochon_street")),
title: Some(String::from("Tone")),
genre: Some(String::from("Random")),
track_number: Some(String::from("03")),
features_version: FEATURES_VERSION,
album_artist: Some(String::from("Polochon_street")),
duration: Duration::from_secs_f32(5.586666584),
cue_info: Some(CueInfo {
cue_path: PathBuf::from("data/testcue.cue"),
audio_file_path: PathBuf::from("data/testcue.flac"),
}),
..Default::default()
}),
Err(BlissError::DecodingError(String::from(
"while opening song: Os { code: 2, kind: NotFound, message: \"No such file or directory\" }.",
))),
];
assert_eq!(expected, songs);
}
}

View file

@ -2,75 +2,72 @@
//!
//! bliss is a library for making "smart" audio playlists.
//!
//! The core of the library is the [Song] object, which relates to a
//! The core of the library is the `Song` object, which relates to a
//! specific analyzed song and contains its path, title, analysis, and
//! 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::new("/path/to/song")`.
//!
//! The [analysis](Song::analysis) field of each song is an array of f32, which
//! makes the comparison between songs easy, by just using e.g. euclidean
//! distance (see [distance](Song::distance) for instance).
//! The [analysis](Song::analysis) field of each song is an array of f32, which makes the
//! comparison between songs easy, by just using euclidean distance (see
//! [distance](Song::distance) for instance).
//!
//! 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
//! the songs by distance, ascending.
//!
//! If you want to implement a bliss plugin for an already existing audio
//! player, the [Library] struct is a collection of goodies that should prove
//! useful (it contains utilities to store analyzed songs in a self-contained
//! database file, to make playlists directly from the database, etc).
//! [blissify](https://github.com/Polochon-street/blissify-rs/) for both
//! an example of how the [Library] struct works, and a real-life demo of bliss
//! implemented for [MPD](https://www.musicpd.org/).
//! 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
//!
//! ### Analyze & compute the distance between two songs
//! ## Analyze & compute the distance between two songs
//! ```no_run
//! use bliss_audio::{BlissResult, Song};
//!
//! fn main() -> BlissResult<()> {
//! let song1 = Song::from_path("/path/to/song1")?;
//! let song2 = Song::from_path("/path/to/song2")?;
//! use bliss_audio::{BlissError, Song};
//!
//! fn main() -> Result<(), BlissError> {
//! let song1 = Song::new("/path/to/song1")?;
//! let song2 = Song::new("/path/to/song2")?;
//!
//! println!("Distance between song1 and song2 is {}", song1.distance(&song2));
//! Ok(())
//! }
//! ```
//!
//! ### Make a playlist from a song, discarding failed songs
//!
//! ### Make a playlist from a song
//! ```no_run
//! use bliss_audio::{
//! analyze_paths,
//! playlist::{closest_to_first_song, euclidean_distance},
//! BlissResult, Song,
//! };
//!
//! fn main() -> BlissResult<()> {
//! use bliss_audio::{BlissError, Song};
//! use ndarray::{arr1, Array};
//! use noisy_float::prelude::n32;
//!
//! fn main() -> Result<(), BlissError> {
//! let paths = vec!["/path/to/song1", "/path/to/song2", "/path/to/song3"];
//! let mut songs: Vec<Song> = analyze_paths(&paths).filter_map(|(_, s)| s.ok()).collect();
//!
//! let mut songs: Vec<Song> = paths
//! .iter()
//! .map(|path| Song::new(path))
//! .collect::<Result<Vec<Song>, BlissError>>()?;
//!
//! // Assuming there is a first song
//! let first_song = songs.first().unwrap().to_owned();
//!
//! closest_to_first_song(&first_song, &mut songs, euclidean_distance);
//!
//! println!("Playlist is:");
//! for song in songs {
//! println!("{}", song.path.display());
//! }
//! songs.sort_by_cached_key(|song| n32(first_song.distance(&song)));
//! println!(
//! "Playlist is: {:?}",
//! songs
//! .iter()
//! .map(|song| &song.path)
//! .collect::<Vec<&String>>()
//! );
//! Ok(())
//! }
//! ```
#![cfg_attr(feature = "bench", feature(test))]
#![warn(missing_docs)]
#![warn(rustdoc::missing_doc_code_examples)]
#![warn(missing_doc_code_examples)]
mod chroma;
pub mod cue;
#[cfg(feature = "library")]
pub mod library;
mod library;
mod misc;
pub mod playlist;
mod song;
mod temporal;
mod timbral;
@ -81,182 +78,68 @@ extern crate num_cpus;
#[cfg(feature = "serde")]
#[macro_use]
extern crate serde;
use crate::cue::BlissCue;
use log::info;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use thiserror::Error;
pub use song::{Analysis, AnalysisIndex, Song, NUMBER_FEATURES};
pub use library::Library;
pub use song::{Analysis, AnalysisIndex, Song};
//const CHANNELS: u16 = 1;
const CHANNELS: u16 = 1;
const SAMPLE_RATE: u32 = 22050;
/// Stores the current version of bliss-rs' features.
/// It is bumped every time one or more feature is added, updated or removed,
/// so plug-ins can rescan libraries when there is a major change.
pub const FEATURES_VERSION: u16 = 1;
#[derive(Error, Clone, Debug, PartialEq, Eq)]
#[derive(Error, Clone, Debug, PartialEq)]
/// Umbrella type for bliss error types
pub enum BlissError {
#[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),
#[error("error happened while analyzing file {0}")]
/// An error happened during the analysis of the song's samples by bliss.
/// An error happened during the analysis of the samples by bliss
AnalysisError(String),
#[error("error happened with the music library provider - {0}")]
/// An error happened with the music library provider.
/// Useful to report errors when you implement bliss for an audio player.
/// Useful to report errors when you implement the [Library] trait.
ProviderError(String),
}
/// bliss error type
pub type BlissResult<T> = Result<T, BlissError>;
/// Simple function to bulk analyze a set of songs represented by their
/// absolute paths.
///
/// When making an extension for an audio player, prefer
/// implementing the `Library` trait.
#[doc(hidden)]
pub fn bulk_analyse(paths: Vec<String>) -> Vec<Result<Song, BlissError>> {
let mut songs = Vec::with_capacity(paths.len());
let num_cpus = num_cpus::get();
/// Analyze songs in `paths`, and return the analyzed [Song] objects through an
/// [mpsc::IntoIter].
///
/// Returns an iterator, 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 function also works with CUE files - it finds the audio files
/// mentionned in the CUE sheet, and then runs the analysis on each song
/// defined by it, returning a proper [Song] object for each one of them.
///
/// Make sure that you don't submit both the audio file along with the CUE
/// sheet if your library uses them, otherwise the audio file will be
/// analyzed as one, single, long song. For instance, with a CUE sheet named
/// `cue-file.cue` with the corresponding audio files `album-1.wav` and
/// `album-2.wav` defined in the CUE sheet, you would just pass `cue-file.cue`
/// to `analyze_paths`, and it will return [Song]s from both files, with
/// more information about which file it is extracted from in the
/// [cue info field](Song::cue_info).
///
/// # Example:
/// ```no_run
/// 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.display(), e),
/// }
/// }
/// Ok(())
/// }
/// ```
pub fn analyze_paths<P: Into<PathBuf>, F: IntoIterator<Item = P>>(
paths: F,
) -> mpsc::IntoIter<(PathBuf, BlissResult<Song>)> {
let cores = NonZeroUsize::new(num_cpus::get()).unwrap();
analyze_paths_with_cores(paths, cores)
}
/// Analyze songs in `paths`, and return the analyzed [Song] objects through an
/// [mpsc::IntoIter]. `number_cores` sets the number of cores the analysis
/// will use, capped by your system's capacity. Most of the time, you want to
/// use the simpler `analyze_paths` functions, which autodetects the number
/// of cores in your system.
///
/// Return an iterator, 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 function also works with CUE files - it finds the audio files
/// mentionned in the CUE sheet, and then runs the analysis on each song
/// defined by it, returning a proper [Song] object for each one of them.
///
/// Make sure that you don't submit both the audio file along with the CUE
/// sheet if your library uses them, otherwise the audio file will be
/// analyzed as one, single, long song. For instance, with a CUE sheet named
/// `cue-file.cue` with the corresponding audio files `album-1.wav` and
/// `album-2.wav` defined in the CUE sheet, you would just pass `cue-file.cue`
/// to `analyze_paths`, and it will return [Song]s from both files, with
/// more information about which file it is extracted from in the
/// [cue info field](Song::cue_info).
///
/// # Example:
/// ```no_run
/// 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.display(), e),
/// }
/// }
/// Ok(())
/// }
/// ```
pub fn analyze_paths_with_cores<P: Into<PathBuf>, F: IntoIterator<Item = P>>(
paths: F,
number_cores: NonZeroUsize,
) -> mpsc::IntoIter<(PathBuf, BlissResult<Song>)> {
let mut cores = NonZeroUsize::new(num_cpus::get()).unwrap();
if cores > number_cores {
cores = number_cores;
}
let paths: Vec<PathBuf> = paths.into_iter().map(|p| p.into()).collect();
#[allow(clippy::type_complexity)]
let (tx, rx): (
mpsc::Sender<(PathBuf, BlissResult<Song>)>,
mpsc::Receiver<(PathBuf, BlissResult<Song>)>,
) = mpsc::channel();
if paths.is_empty() {
return rx.into_iter();
}
let mut handles = Vec::new();
let mut chunk_length = paths.len() / cores;
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);
if let Some(extension) = Path::new(&path).extension() {
let extension = extension.to_string_lossy().to_lowercase();
if extension == "cue" {
match BlissCue::songs_from_path(&path) {
Ok(songs) => {
for song in songs {
tx_thread.send((path.to_owned(), song)).unwrap();
}
}
Err(e) => tx_thread.send((path.to_owned(), Err(e))).unwrap(),
};
continue;
}
crossbeam::scope(|s| {
let mut handles = Vec::with_capacity(paths.len() / num_cpus);
let mut chunk_number = paths.len() / num_cpus;
if chunk_number == 0 {
chunk_number = paths.len();
}
for chunk in paths.chunks(chunk_number) {
handles.push(s.spawn(move |_| {
let mut result = Vec::with_capacity(chunk.len());
for path in chunk {
let song = Song::new(&path);
result.push(song);
}
let song = Song::from_path(&path);
tx_thread.send((path.to_owned(), song)).unwrap();
}
});
handles.push(child);
}
result
}));
}
rx.into_iter()
for handle in handles {
songs.extend(handle.join().unwrap());
}
})
.unwrap();
songs
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(test)]
use pretty_assertions::assert_eq;
#[test]
fn test_send_song() {
@ -271,59 +154,48 @@ mod tests {
}
#[test]
fn test_analyze_paths() {
let paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/testcue.cue",
"./data/white_noise.flac",
"definitely-not-existing.foo",
"not-existing.foo",
];
let mut results = analyze_paths(&paths)
.map(|x| match &x.1 {
Ok(s) => (true, s.path.to_owned(), None),
Err(e) => (false, x.0.to_owned(), Some(e.to_string())),
})
.collect::<Vec<_>>();
results.sort();
let expected_results = vec![
(
false,
PathBuf::from("./data/testcue.cue"),
Some(String::from(
"error happened while decoding file while opening song: Os { code: 2, kind: NotFound, message: \"No such file or directory\" }.",
)),
),
(
false,
PathBuf::from("definitely-not-existing.foo"),
Some(String::from(
"error happened while decoding file while opening song: Os { code: 2, kind: NotFound, message: \"No such file or directory\" }.",
)),
),
(
false,
PathBuf::from("not-existing.foo"),
Some(String::from(
"error happened while decoding file while opening song: Os { code: 2, kind: NotFound, message: \"No such file or directory\" }.",
)),
),
(true, PathBuf::from("./data/s16_mono_22_5kHz.flac"), None),
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK001"), None),
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK002"), None),
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK003"), None),
(true, PathBuf::from("./data/white_noise.flac"), None),
];
fn test_bulk_analyse() {
let results = bulk_analyse(vec![
String::from("data/s16_mono_22_5kHz.flac"),
String::from("data/s16_mono_22_5kHz.flac"),
String::from("nonexistent"),
String::from("data/s16_stereo_22_5kHz.flac"),
String::from("nonexistent"),
String::from("nonexistent"),
String::from("nonexistent"),
String::from("nonexistent"),
String::from("nonexistent"),
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));
assert_eq!(results, expected_results);
let mut analysed_songs: Vec<String> = results
.iter()
.filter_map(|x| x.as_ref().ok().map(|x| x.path.to_string()))
.collect();
analysed_songs.sort_by(|a, b| a.cmp(b));
let mut results = analyze_paths_with_cores(&paths, NonZeroUsize::new(1).unwrap())
.map(|x| match &x.1 {
Ok(s) => (true, s.path.to_owned(), None),
Err(e) => (false, x.0.to_owned(), Some(e.to_string())),
})
.collect::<Vec<_>>();
results.sort();
assert_eq!(results, expected_results);
assert_eq!(
vec![
String::from(
"error happened while decoding file while opening format: ffmpeg::Error(2: No such file or directory)."
);
8
],
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"),
],
analysed_songs,
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -64,11 +64,10 @@ impl Normalize for LoudnessDesc {
mod tests {
use super::*;
use crate::Song;
use std::path::Path;
#[test]
fn test_loudness() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut loudness_desc = LoudnessDesc::default();
for chunk in song.sample_array.chunks_exact(LoudnessDesc::WINDOW_SIZE) {
loudness_desc.do_(&chunk);

View file

@ -1,984 +0,0 @@
//! Module containing various functions to build playlists, as well as various
//! distance metrics.
//!
//! All of the distance functions are intended to be used with the
//! [custom_distance](Song::custom_distance) method, or with
//!
//! 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.
// TODO on the `by_key` functions: maybe Fn(&T) -> &Song is enough? Compared
// to -> Song
use crate::{BlissError, BlissResult, Song, NUMBER_FEATURES};
use ndarray::{Array, Array1, Array2, Axis};
use ndarray_stats::QuantileExt;
use noisy_float::prelude::*;
use std::collections::HashMap;
/// 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
}
/// Sort `songs` in place by putting songs close to `first_song` first
/// using the `distance` metric.
pub fn closest_to_first_song(
first_song: &Song,
#[allow(clippy::ptr_arg)] songs: &mut Vec<Song>,
distance: impl DistanceMetric,
) {
songs.sort_by_cached_key(|song| n32(first_song.custom_distance(song, &distance)));
}
/// Sort `songs` in place by putting songs close to `first_song` first
/// using the `distance` metric.
///
/// Sort songs with a key extraction function, useful for when you have a
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
pub fn closest_to_first_song_by_key<F, T>(
first_song: &T,
#[allow(clippy::ptr_arg)] songs: &mut Vec<T>,
distance: impl DistanceMetric,
key_fn: F,
) where
F: Fn(&T) -> Song,
{
let first_song = key_fn(first_song);
songs.sort_by_cached_key(|song| n32(first_song.custom_distance(&key_fn(song), &distance)));
}
/// Sort `songs` in place using the `distance` metric and ordering by
/// the smallest distance between each song.
///
/// If the generated playlist is `[song1, song2, song3, song4]`, it means
/// song2 is closest to song1, song3 is closest to song2, and song4 is closest
/// to song3.
///
/// Note that this has a tendency to go from one style to the other very fast,
/// and it can be slow on big libraries.
pub fn song_to_song(first_song: &Song, songs: &mut Vec<Song>, distance: impl DistanceMetric) {
let mut new_songs = Vec::with_capacity(songs.len());
let mut song = first_song.to_owned();
while !songs.is_empty() {
let distances: Array1<f32> =
Array::from_shape_fn(songs.len(), |i| song.custom_distance(&songs[i], &distance));
let idx = distances.argmin().unwrap();
song = songs[idx].to_owned();
new_songs.push(song.to_owned());
songs.retain(|s| s != &song);
}
*songs = new_songs;
}
/// Sort `songs` in place using the `distance` metric and ordering by
/// the smallest distance between each song.
///
/// If the generated playlist is `[song1, song2, song3, song4]`, it means
/// song2 is closest to song1, song3 is closest to song2, and song4 is closest
/// to song3.
///
/// Note that this has a tendency to go from one style to the other very fast,
/// and it can be slow on big libraries.
///
/// Sort songs with a key extraction function, useful for when you have a
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
// TODO: maybe Clone is not needed?
pub fn song_to_song_by_key<F, T: std::cmp::PartialEq + Clone>(
first_song: &T,
songs: &mut Vec<T>,
distance: impl DistanceMetric,
key_fn: F,
) where
F: Fn(&T) -> Song,
{
let mut new_songs: Vec<T> = Vec::with_capacity(songs.len());
let mut bliss_song = key_fn(&first_song.to_owned());
while !songs.is_empty() {
let distances: Array1<f32> = Array::from_shape_fn(songs.len(), |i| {
bliss_song.custom_distance(&key_fn(&songs[i]), &distance)
});
let idx = distances.argmin().unwrap();
let song = songs[idx].to_owned();
bliss_song = key_fn(&songs[idx]).to_owned();
new_songs.push(song.to_owned());
songs.retain(|s| s != &song);
}
*songs = new_songs;
}
/// Remove duplicate songs from a playlist, in place.
///
/// Two songs are considered duplicates if they either have the same,
/// non-empty title and artist name, or if they are close enough in terms
/// of distance.
///
/// # Arguments
///
/// * `songs`: The playlist to remove duplicates from.
/// * `distance_threshold`: The distance threshold under which two songs are
/// considered identical. If `None`, a default value of 0.05 will be used.
pub fn dedup_playlist(songs: &mut Vec<Song>, distance_threshold: Option<f32>) {
dedup_playlist_custom_distance(songs, distance_threshold, euclidean_distance);
}
/// Remove duplicate songs from a playlist, in place.
///
/// Two songs are considered duplicates if they either have the same,
/// non-empty title and artist name, or if they are close enough in terms
/// of distance.
///
/// Dedup songs with a key extraction function, useful for when you have a
/// structure like `CustomSong { bliss_song: Song, something_else: bool }` you
/// want to deduplicate.
///
/// # Arguments
///
/// * `songs`: The playlist to remove duplicates from.
/// * `distance_threshold`: The distance threshold under which two songs are
/// considered identical. If `None`, a default value of 0.05 will be used.
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
pub fn dedup_playlist_by_key<T, F>(songs: &mut Vec<T>, distance_threshold: Option<f32>, key_fn: F)
where
F: Fn(&T) -> Song,
{
dedup_playlist_custom_distance_by_key(songs, distance_threshold, euclidean_distance, key_fn);
}
/// Remove duplicate songs from a playlist, in place, using a custom distance
/// metric.
///
/// Two songs are considered duplicates if they either have the same,
/// non-empty title and artist name, or if they are close enough in terms
/// of distance.
///
/// # Arguments
///
/// * `songs`: The playlist to remove duplicates from.
/// * `distance_threshold`: The distance threshold under which two songs are
/// considered identical. If `None`, a default value of 0.05 will be used.
/// * `distance`: A custom distance metric.
pub fn dedup_playlist_custom_distance(
songs: &mut Vec<Song>,
distance_threshold: Option<f32>,
distance: impl DistanceMetric,
) {
songs.dedup_by(|s1, s2| {
n32(s1.custom_distance(s2, &distance)) < distance_threshold.unwrap_or(0.05)
|| (s1.title.is_some()
&& s2.title.is_some()
&& s1.artist.is_some()
&& s2.artist.is_some()
&& s1.title == s2.title
&& s1.artist == s2.artist)
});
}
/// Remove duplicate songs from a playlist, in place, using a custom distance
/// metric.
///
/// Two songs are considered duplicates if they either have the same,
/// non-empty title and artist name, or if they are close enough in terms
/// of distance.
///
/// Dedup songs with a key extraction function, useful for when you have a
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
/// you want to deduplicate.
///
/// # Arguments
///
/// * `songs`: The playlist to remove duplicates from.
/// * `distance_threshold`: The distance threshold under which two songs are
/// considered identical. If `None`, a default value of 0.05 will be used.
/// * `distance`: A custom distance metric.
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
pub fn dedup_playlist_custom_distance_by_key<F, T>(
songs: &mut Vec<T>,
distance_threshold: Option<f32>,
distance: impl DistanceMetric,
key_fn: F,
) where
F: Fn(&T) -> Song,
{
songs.dedup_by(|s1, s2| {
let s1 = key_fn(s1);
let s2 = key_fn(s2);
n32(s1.custom_distance(&s2, &distance)) < distance_threshold.unwrap_or(0.05)
|| (s1.title.is_some()
&& s2.title.is_some()
&& s1.artist.is_some()
&& s2.artist.is_some()
&& s1.title == s2.title
&& s1.artist == s2.artist)
});
}
/// 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.
///
/// `group` should be ordered by track number.
///
/// 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)
}
/// 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.
///
/// `group` should be ordered by track number.
///
/// Songs from `group` would usually just be songs from an album, but not
/// necessarily - they are discarded from `pool` no matter what.
///
/// Order songs with a key extraction function, useful for when you have a
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
/// you want to order.
///
/// # 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.
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
///
/// # Returns
///
/// A vector of T, 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>>()`.
// TODO: maybe Clone is not needed?
pub fn closest_album_to_group_by_key<T: PartialEq + Clone, F>(
group: Vec<T>,
pool: Vec<T>,
key_fn: F,
) -> BlissResult<Vec<T>>
where
F: Fn(&T) -> Song,
{
let mut albums_analysis: HashMap<String, 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 {
let song = key_fn(song);
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.to_owned(), 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))) {
let song = key_fn(song);
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| {
let s = key_fn(s);
s.album.is_some() && s.album.as_ref().unwrap() == &album.to_string()
})
.map(|s| s.to_owned())
.collect::<Vec<T>>();
al.sort_by(|s1, s2| {
let s1 = key_fn(s1);
let s2 = key_fn(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)]
mod test {
use super::*;
use crate::Analysis;
use ndarray::arr1;
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
struct CustomSong {
something: bool,
bliss_song: Song,
}
#[test]
fn test_dedup_playlist_custom_distance() {
let first_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.,
]),
title: Some(String::from("dupe-title")),
artist: Some(String::from("dupe-artist")),
..Default::default()
};
let third_song = Song {
path: Path::new("path-to-third").to_path_buf(),
title: Some(String::from("dupe-title")),
artist: Some(String::from("dupe-artist")),
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(),
artist: Some(String::from("no-dupe-artist")),
title: Some(String::from("dupe-title")),
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-fourth").to_path_buf(),
analysis: Analysis::new([
2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 0.001, 1., 1., 1.,
]),
..Default::default()
};
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_custom_distance(&mut playlist, None, euclidean_distance);
assert_eq!(
playlist,
vec![
first_song.to_owned(),
second_song.to_owned(),
fourth_song.to_owned(),
],
);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_custom_distance(&mut playlist, Some(20.), cosine_distance);
assert_eq!(playlist, vec![first_song.to_owned()]);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist(&mut playlist, Some(20.));
assert_eq!(playlist, vec![first_song.to_owned()]);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist(&mut playlist, None);
assert_eq!(
playlist,
vec![
first_song.to_owned(),
second_song.to_owned(),
fourth_song.to_owned(),
]
);
let first_song = CustomSong {
bliss_song: first_song,
something: true,
};
let second_song = CustomSong {
bliss_song: second_song,
something: true,
};
let first_song_dupe = CustomSong {
bliss_song: first_song_dupe,
something: true,
};
let third_song = CustomSong {
bliss_song: third_song,
something: true,
};
let fourth_song = CustomSong {
bliss_song: fourth_song,
something: true,
};
let fifth_song = CustomSong {
bliss_song: fifth_song,
something: true,
};
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_custom_distance_by_key(&mut playlist, None, euclidean_distance, |s| {
s.bliss_song.to_owned()
});
assert_eq!(
playlist,
vec![
first_song.to_owned(),
second_song.to_owned(),
fourth_song.to_owned(),
],
);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_custom_distance_by_key(&mut playlist, Some(20.), cosine_distance, |s| {
s.bliss_song.to_owned()
});
assert_eq!(playlist, vec![first_song.to_owned()]);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_by_key(&mut playlist, Some(20.), |s| s.bliss_song.to_owned());
assert_eq!(playlist, vec![first_song.to_owned()]);
let mut playlist = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
dedup_playlist_by_key(&mut playlist, None, |s| s.bliss_song.to_owned());
assert_eq!(
playlist,
vec![
first_song.to_owned(),
second_song.to_owned(),
fourth_song.to_owned(),
]
);
}
#[test]
fn test_song_to_song() {
let first_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 mut songs = vec![
first_song.to_owned(),
third_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
fourth_song.to_owned(),
];
song_to_song(&first_song, &mut songs, euclidean_distance);
assert_eq!(
songs,
vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
],
);
let first_song = CustomSong {
bliss_song: first_song,
something: true,
};
let second_song = CustomSong {
bliss_song: second_song,
something: true,
};
let first_song_dupe = CustomSong {
bliss_song: first_song_dupe,
something: true,
};
let third_song = CustomSong {
bliss_song: third_song,
something: true,
};
let fourth_song = CustomSong {
bliss_song: fourth_song,
something: true,
};
let mut songs: Vec<CustomSong> = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
second_song.to_owned(),
];
song_to_song_by_key(&first_song, &mut songs, euclidean_distance, |s| {
s.bliss_song.to_owned()
});
assert_eq!(
songs,
vec![
first_song,
first_song_dupe,
second_song,
third_song,
fourth_song,
],
);
}
#[test]
fn test_sort_closest_to_first_song() {
let first_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 mut songs = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
closest_to_first_song(&first_song, &mut songs, euclidean_distance);
let first_song = CustomSong {
bliss_song: first_song,
something: true,
};
let second_song = CustomSong {
bliss_song: second_song,
something: true,
};
let first_song_dupe = CustomSong {
bliss_song: first_song_dupe,
something: true,
};
let third_song = CustomSong {
bliss_song: third_song,
something: true,
};
let fourth_song = CustomSong {
bliss_song: fourth_song,
something: true,
};
let fifth_song = CustomSong {
bliss_song: fifth_song,
something: true,
};
let mut songs: Vec<CustomSong> = vec![
first_song.to_owned(),
first_song_dupe.to_owned(),
second_song.to_owned(),
third_song.to_owned(),
fourth_song.to_owned(),
fifth_song.to_owned(),
];
closest_to_first_song_by_key(&first_song, &mut songs, euclidean_distance, |s| {
s.bliss_song.to_owned()
});
assert_eq!(
songs,
vec![
first_song,
first_song_dupe,
second_song,
fourth_song,
fifth_song,
third_song
],
);
}
#[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.);
}
#[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(),
);
let first_song = CustomSong {
bliss_song: first_song,
something: true,
};
let second_song = CustomSong {
bliss_song: second_song,
something: true,
};
let third_song = CustomSong {
bliss_song: third_song,
something: true,
};
let fourth_song = CustomSong {
bliss_song: fourth_song,
something: true,
};
let fifth_song = CustomSong {
bliss_song: fifth_song,
something: true,
};
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_by_key(group, pool.to_owned(), |s| s.bliss_song.to_owned())
.unwrap(),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
//! of a given Song.
use crate::utils::Normalize;
use crate::{BlissError, BlissResult};
use crate::BlissError;
use bliss_audio_aubio_rs::{OnsetMode, Tempo};
use log::warn;
use ndarray::arr1;
@ -19,7 +19,7 @@ use noisy_float::prelude::*;
* It indicates the (subjective) "speed" of a music piece. The higher the BPM,
* the "quicker" the song will feel.
*
* It uses `SpecFlux`, a phase-deviation onset detection function to perform
* It uses `WPhase`, a phase-deviation onset detection function to perform
* onset detection; it proved to be the best for finding out the BPM of a panel
* of songs I had, but it could very well be replaced by something better in the
* future.
@ -39,7 +39,7 @@ impl BPMDesc {
pub const WINDOW_SIZE: usize = 512;
pub const HOP_SIZE: usize = BPMDesc::WINDOW_SIZE / 2;
pub fn new(sample_rate: u32) -> BlissResult<Self> {
pub fn new(sample_rate: u32) -> Result<Self, BlissError> {
Ok(BPMDesc {
aubio_obj: Tempo::new(
OnsetMode::SpecFlux,
@ -48,15 +48,21 @@ impl BPMDesc {
sample_rate,
)
.map_err(|e| {
BlissError::AnalysisError(format!("error while loading aubio tempo object: {}", e))
BlissError::AnalysisError(format!(
"error while loading aubio tempo object: {}",
e.to_string()
))
})?,
bpms: Vec::new(),
})
}
pub fn do_(&mut self, chunk: &[f32]) -> BlissResult<()> {
pub fn do_(&mut self, chunk: &[f32]) -> Result<(), BlissError> {
let result = self.aubio_obj.do_result(chunk).map_err(|e| {
BlissError::AnalysisError(format!("aubio error while computing tempo {}", e))
BlissError::AnalysisError(format!(
"aubio error while computing tempo {}",
e.to_string()
))
})?;
if result > 0. {
@ -95,11 +101,10 @@ impl Normalize for BPMDesc {
mod tests {
use super::*;
use crate::{Song, SAMPLE_RATE};
use std::path::Path;
#[test]
fn test_tempo_real() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut tempo_desc = BPMDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(BPMDesc::HOP_SIZE) {
tempo_desc.do_(&chunk).unwrap();

View file

@ -9,7 +9,7 @@ use bliss_audio_aubio_rs::{bin_to_freq, PVoc, SpecDesc, SpecShape};
use ndarray::{arr1, Axis};
use super::utils::{geometric_mean, mean, number_crossings, Normalize};
use crate::{BlissError, BlissResult, SAMPLE_RATE};
use crate::{BlissError, SAMPLE_RATE};
/**
* General object holding all the spectral descriptor.
@ -120,27 +120,27 @@ impl SpectralDesc {
]
}
pub fn new(sample_rate: u32) -> BlissResult<Self> {
pub fn new(sample_rate: u32) -> Result<Self, BlissError> {
Ok(SpectralDesc {
centroid_aubio_desc: SpecDesc::new(SpecShape::Centroid, SpectralDesc::WINDOW_SIZE)
.map_err(|e| {
BlissError::AnalysisError(format!(
"error while loading aubio centroid object: {}",
e
e.to_string()
))
})?,
rolloff_aubio_desc: SpecDesc::new(SpecShape::Rolloff, SpectralDesc::WINDOW_SIZE)
.map_err(|e| {
BlissError::AnalysisError(format!(
"error while loading aubio rolloff object: {}",
e
e.to_string()
))
})?,
phase_vocoder: PVoc::new(SpectralDesc::WINDOW_SIZE, SpectralDesc::HOP_SIZE).map_err(
|e| {
BlissError::AnalysisError(format!(
"error while loading aubio pvoc object: {}",
e
e.to_string()
))
},
)?,
@ -158,12 +158,15 @@ impl SpectralDesc {
* `get_centroid`, `get_flatness` and `get_rolloff` to get the respective
* descriptors' values.
*/
pub fn do_(&mut self, chunk: &[f32]) -> BlissResult<()> {
pub fn do_(&mut self, chunk: &[f32]) -> Result<(), BlissError> {
let mut fftgrain: Vec<f32> = vec![0.0; SpectralDesc::WINDOW_SIZE];
self.phase_vocoder
.do_(chunk, fftgrain.as_mut_slice())
.map_err(|e| {
BlissError::AnalysisError(format!("error while processing aubio pv object: {}", e))
BlissError::AnalysisError(format!(
"error while processing aubio pv object: {}",
e.to_string()
))
})?;
let bin = self
@ -172,7 +175,7 @@ impl SpectralDesc {
.map_err(|e| {
BlissError::AnalysisError(format!(
"error while processing aubio centroid object: {}",
e
e.to_string()
))
})?;
@ -201,12 +204,12 @@ impl SpectralDesc {
self.values_rolloff.push(freq);
let cvec: CVec = fftgrain.as_slice().into();
let geo_mean = geometric_mean(cvec.norm());
let geo_mean = geometric_mean(&cvec.norm());
if geo_mean == 0.0 {
self.values_flatness.push(0.0);
return Ok(());
}
let flatness = geo_mean / mean(cvec.norm());
let flatness = geo_mean / mean(&cvec.norm());
self.values_flatness.push(flatness);
Ok(())
}
@ -263,7 +266,6 @@ impl Normalize for ZeroCrossingRateDesc {
mod tests {
use super::*;
use crate::Song;
use std::path::Path;
#[test]
fn test_zcr_boundaries() {
@ -285,7 +287,7 @@ mod tests {
#[test]
fn test_zcr() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut zcr_desc = ZeroCrossingRateDesc::default();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
zcr_desc.do_(&chunk);
@ -307,7 +309,7 @@ mod tests {
assert!(0.0000001 > (expected - actual).abs());
}
let song = Song::decode(Path::new("data/white_noise.flac")).unwrap();
let song = Song::decode("data/white_noise.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(22050).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();
@ -324,7 +326,7 @@ mod tests {
#[test]
fn test_spectral_flatness() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();
@ -354,7 +356,7 @@ mod tests {
assert!(0.0000001 > (expected - actual).abs());
}
let song = Song::decode(Path::new("data/tone_11080Hz.flac")).unwrap();
let song = Song::decode("data/tone_11080Hz.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();
@ -370,7 +372,7 @@ mod tests {
#[test]
fn test_spectral_roll_off() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();
@ -388,7 +390,7 @@ mod tests {
#[test]
fn test_spectral_centroid() {
let song = Song::decode(Path::new("data/s16_mono_22_5kHz.flac")).unwrap();
let song = Song::decode("data/s16_mono_22_5kHz.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();
@ -417,7 +419,7 @@ mod tests {
{
assert!(0.0000001 > (expected - actual).abs());
}
let song = Song::decode(Path::new("data/tone_11080Hz.flac")).unwrap();
let song = Song::decode("data/tone_11080Hz.flac").unwrap();
let mut spectral_desc = SpectralDesc::new(SAMPLE_RATE).unwrap();
for chunk in song.sample_array.chunks_exact(SpectralDesc::HOP_SIZE) {
spectral_desc.do_(&chunk).unwrap();

View file

@ -3,6 +3,7 @@ use ndarray::{arr1, s, Array, Array1, Array2};
use rustfft::num_complex::Complex;
use rustfft::num_traits::Zero;
use rustfft::FftPlanner;
extern crate ffmpeg_next as ffmpeg;
use log::warn;
use std::f32::consts::PI;
@ -28,7 +29,7 @@ pub(crate) fn stft(signal: &[f32], window_length: usize, hop_length: usize) -> A
(signal.len() as f32 / hop_length as f32).ceil() as usize,
window_length / 2 + 1,
));
let signal = reflect_pad(signal, window_length / 2);
let signal = reflect_pad(&signal, window_length / 2);
// Periodic, so window_size + 1
let mut hann_window = Array::zeros(window_length + 1);
@ -44,7 +45,7 @@ pub(crate) fn stft(signal: &[f32], window_length: usize, hop_length: usize) -> A
.step_by(hop_length)
.zip(stft.rows_mut())
{
let mut signal = (arr1(window) * &hann_window).mapv(|x| Complex::new(x, 0.));
let mut signal = (arr1(&window) * &hann_window).mapv(|x| Complex::new(x, 0.));
match signal.as_slice_mut() {
Some(s) => fft.process(s),
None => {
@ -100,7 +101,8 @@ pub(crate) fn geometric_mean(input: &[f32]) -> f32 {
let mut exponents: i32 = 0;
let mut mantissas: f64 = 1.;
for ch in input.chunks_exact(8) {
let mut m = (ch[0] as f64 * ch[1] as f64) * (ch[2] as f64 * ch[3] as f64);
let mut m;
m = (ch[0] as f64 * ch[1] as f64) * (ch[2] as f64 * ch[3] as f64);
m *= 3.273390607896142e150; // 2^500 : avoid underflows and denormals
m *= (ch[4] as f64 * ch[5] as f64) * (ch[6] as f64 * ch[7] as f64);
if m == 0. {
@ -170,7 +172,6 @@ mod tests {
use ndarray::{arr1, Array};
use ndarray_npy::ReadNpyExt;
use std::fs::File;
use std::path::Path;
#[test]
fn test_convolve() {
@ -496,7 +497,7 @@ mod tests {
let file = File::open("data/librosa-stft.npy").unwrap();
let expected_stft = Array2::<f32>::read_npy(file).unwrap().mapv(|x| x as f64);
let song = Song::decode(Path::new("data/piano.flac")).unwrap();
let song = Song::decode("data/piano.flac").unwrap();
let stft = stft(&song.sample_array, 2048, 512);
@ -523,7 +524,6 @@ mod bench {
use super::*;
use crate::Song;
use ndarray::Array;
use std::path::Path;
use test::Bencher;
#[bench]
@ -538,9 +538,7 @@ 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("data/piano.flac").unwrap().sample_array;
b.iter(|| {
stft(&signal, 2048, 512);