commit 029901cd0fb66bc83afe39d31e687114ced3f429
parent 1e631b46b0575ff95e1d8cb091abe7a0e8e15ecd
Author: mtmn <miro@haravara.org>
Date: Wed, 6 May 2026 22:00:27 +0200
chore: remove matchakey, virittaa (now live in their own repos)
Diffstat:
23 files changed, 0 insertions(+), 1689 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -5,6 +5,4 @@ members = [
"hakunadata",
"lastfm_rs",
"mack",
- "matchakey",
- "virittaa",
]
diff --git a/MODULE.bazel b/MODULE.bazel
@@ -33,9 +33,7 @@ crate.from_cargo(
"//:diffamer/Cargo.toml",
"//:hakunadata/Cargo.toml",
"//:mack/Cargo.toml",
- "//:virittaa/Cargo.toml",
"//:lastfm_rs/Cargo.toml",
- "//:matchakey/Cargo.toml",
],
)
use_repo(crate, "crates")
diff --git a/matchakey/BUILD b/matchakey/BUILD
@@ -1,33 +0,0 @@
-load("@crates//:defs.bzl", "all_crate_deps")
-load("//bazel:rust.bzl", "rust_app", "rust_lib")
-load("@rules_rust//rust:defs.bzl", "rust_test")
-
-rust_lib(
- name = "lib",
- crate_name = "matchakey",
- deps = all_crate_deps(),
-)
-
-rust_app(
- name = "matchakey",
- srcs = ["src/main.rs"],
- deps = [":lib"] + all_crate_deps(),
-)
-
-rust_test(
- name = "matchakey_integration_test",
- srcs = [
- "tests/integration_tests.rs",
- "tests/utils_tests.rs",
- ],
- crate_root = "tests/integration_tests.rs",
- edition = "2024",
- rustc_flags = [
- "-Ctarget-cpu=native",
- "-Clink-arg=-fuse-ld=mold",
- ],
- visibility = ["//visibility:public"],
- deps = [
- ":lib",
- ] + all_crate_deps(),
-)
diff --git a/matchakey/Cargo.toml b/matchakey/Cargo.toml
@@ -1,17 +0,0 @@
-[package]
-name = "matchakey"
-version = "0.1.0"
-edition = "2024"
-
-[dependencies]
-anyhow = "1.0.101"
-clap = { version = "4.5.57", features = ["derive"] }
-colored = "3.1.1"
-lofty = "0.24.0"
-rand = "0.10.0"
-rayon = "1.10"
-regex = "1.12.3"
-rusqlite = "0.39.0"
-tracing = "0.1.44"
-tracing-subscriber = "0.3.18"
-walkdir = "2.5"
diff --git a/matchakey/README.md b/matchakey/README.md
@@ -1,76 +0,0 @@
-# match-a-key
-
-A tool that finds compatible tracks based on `Key` and `BPM` tags which makes it a great companion for digging, also compatible with Mixxx.
-
-## Features
-
-* Find tracks with compatible musical keys using Camelot/Harmonic mixing rules
-* Filter tracks by BPM tolerance (drift)
-* Support for local music library scanning
-* Integration with Mixxx database for direct track queries
-* Verbose output for debugging
-
-## Building
-
-To build the project, run:
-
-```sh
-cargo build --release
-```
-
-Or if using Bazel:
-
-```sh
-bazel build //:matchakey
-```
-
-## Usage
-
-There are two ways to use the tool:
-
-1. Search for compatible tracks in local directories:
-```sh
-./target/release/matchakey <REFERENCE_TRACK> [OPTIONS] <SOURCE_PATH>...
-```
-
-2. Query compatible tracks from Mixxx database:
-```sh
-# Using default Mixxx database path (~/.mixxx/mixxxdb.sqlite)
-./target/release/matchakey <REFERENCE_TRACK> --mixxx [OPTIONS]
-
-# Using custom Mixxx database path
-./target/release/matchakey <REFERENCE_TRACK> --mixxx-path <MIXXX_DATABASE_PATH> [OPTIONS]
-```
-
-### Arguments
-
-* `<REFERENCE_TRACK>`: The path to the reference audio file.
-* `<SOURCE_PATH>...`: One or more directories to search for compatible tracks. Required unless `--mixxx` is specified.
-
-### Options
-
-* `--drift <DRIFT>`: BPM drift tolerance. If specified, only tracks with a BPM within `DRIFT` of the reference track will be shown.
-* `--mixxx`: Use Mixxx database (defaults to ~/.mixxx/mixxxdb.sqlite).
-* `--mixxx-path <MIXXX_PATH>`: Custom path to Mixxx database (implies --mixxx if provided).
-* `--mixxx-db-size <MIXXX_DB_SIZE>`: Number of tracks to fetch from Mixxx database for shuffling (default: 1000).
-* `--limit <LIMIT>`: Limit number of tracks to display from Mixxx database (default: 10).
-* `-v`, `--verbose`: Enable verbose output, showing the number of files found.
-* `-h`, `--help`: Print help information.
-* `-V`, `--version`: Print version information.
-
-### Example
-
-```sh
-./target/release/matchakey /path/to/my/track.mp3 /path/to/my/music/library
-```
-
-```sh
-# Using default Mixxx database path
-./target/release/matchakey /path/to/my/track.mp3 --mixxx --drift 2.0
-
-# Using custom Mixxx database path
-./target/release/matchakey /path/to/my/track.mp3 --mixxx-path ~/.mixxx/mixxxdb.sqlite --drift 2.0
-
-# Using custom database fetch size and display limit
-./target/release/matchakey /path/to/my/track.mp3 --mixxx --mixxx-db-size 2000 --limit 20 --drift 2.0
-```
diff --git a/matchakey/src/db.rs b/matchakey/src/db.rs
@@ -1,106 +0,0 @@
-use anyhow::Result;
-use rusqlite::{Connection, Row};
-use std::fmt::Write;
-use std::path::PathBuf;
-
-use crate::track::TrackInfo;
-use crate::key;
-use crate::drift;
-
-/// Find and process tracks from a Mixxx `SQLite` database
-///
-/// # Arguments
-/// * `db_path` - Path to the Mixxx `SQLite` database file
-/// * `mixxx_limit` - Maximum number of tracks to fetch (uses default of 1000 if None)
-/// * `drift` - BPM drift tolerance for filtering tracks (uses default of 3.0 if None)
-/// * `ref_track` - Reference track to compare against
-///
-/// # Errors
-/// Returns an error if the database cannot be opened or queried
-pub fn find_tracks_from_mixxx(
- db_path: &PathBuf,
- db_fetch_limit: Option<u32>,
- drift: Option<f32>,
- ref_track: &TrackInfo,
-) -> Result<Vec<TrackInfo>> {
- let conn = Connection::open(db_path)?;
- let limit = db_fetch_limit.unwrap_or(1000);
-
- let mut query = String::from(
- "SELECT t1.artist, t1.title, t1.album, t1.year, t1.bpm, t1.[key], t1.datetime_added
- FROM library t1
- WHERE (t1.mixxx_deleted IS NULL OR t1.mixxx_deleted = 0)",
- );
-
- // Apply drift filtering with default value of 3.0
- query.push_str(" AND t1.bpm IS NOT NULL");
- if ref_track.bpm > 0.0 {
- let drift_val = drift.unwrap_or(3.0);
- let _ = write!(
- query,
- " AND ABS(t1.bpm - {}) <= {}",
- ref_track.bpm, drift_val
- );
- }
-
- let _ = write!(query, " ORDER BY t1.id LIMIT {limit}");
-
- let mut stmt = conn.prepare(&query)?;
-
- let track_iter = stmt.query_map([], |row: &Row| {
- Ok((
- row.get::<usize, Option<String>>(0)?, // artist
- row.get::<usize, Option<String>>(1)?, // title
- row.get::<usize, Option<String>>(2)?, // album
- row.get::<usize, Option<String>>(3)?, // year
- row.get::<usize, Option<f32>>(4)?, // bpm
- row.get::<usize, Option<String>>(5)?, // key
- row.get::<usize, Option<String>>(6)?, // datetime_added
- ))
- })?;
-
- let mut results: Vec<TrackInfo> = Vec::new();
-
- for track_result in track_iter {
- match track_result {
- Ok((artist, title, _, _, bpm_opt, key_opt, datetime_added_opt)) => {
- // Process the track data
- let bpm: f32 = bpm_opt.unwrap_or(0.0);
-
- let key_raw = key_opt.unwrap_or_default();
- let key = key::Key::from_string(&key_raw);
-
- // Check key compatibility using the drift module
- let temp_track_info = TrackInfo {
- bpm,
- key: key.clone(),
- key_raw: key_raw.clone(),
- title: title.clone(),
- artist: artist.clone(),
- datetime_added: datetime_added_opt.clone(),
- };
-
- if !drift::is_key_compatible(&temp_track_info, ref_track) {
- continue; // Skip this track if keys are not compatible
- }
-
- // Create TrackInfo object
- let track_info = TrackInfo {
- bpm,
- key,
- key_raw,
- title,
- artist,
- datetime_added: datetime_added_opt,
- };
-
- results.push(track_info);
- }
- Err(e) => {
- tracing::warn!("Error reading track from database: {}", e);
- }
- }
- }
-
- Ok(results)
-}
diff --git a/matchakey/src/drift.rs b/matchakey/src/drift.rs
@@ -1,65 +0,0 @@
-use crate::track::TrackInfo;
-
-/// Check if a track's BPM is within the drift tolerance of a reference track
-///
-/// # Arguments
-/// * `track` - The track to check
-/// * `ref_track` - The reference track to compare against
-/// * `drift` - The BPM drift tolerance
-///
-/// # Returns
-/// True if the track's BPM is within the drift tolerance, false otherwise
-pub fn is_within_drift_tolerance(track: &TrackInfo, ref_track: &TrackInfo, drift: f32) -> bool {
- // If reference has BPM but target doesn't, skip target only if drift is specified.
- if ref_track.bpm > 0.0 && track.bpm == 0.0 {
- return false;
- }
-
- // Filter by BPM if drift is specified
- if ref_track.bpm > 0.0 && track.bpm > 0.0 && (track.bpm - ref_track.bpm).abs() > drift {
- return false;
- }
-
- true
-}
-
-/// Check if a track's key is compatible with a reference track's key
-///
-/// # Arguments
-/// * `track` - The track to check
-/// * `ref_track` - The reference track to compare against
-///
-/// # Returns
-/// True if the track's key is compatible with the reference track's key, false otherwise
-pub fn is_key_compatible(track: &TrackInfo, ref_track: &TrackInfo) -> bool {
- if let Some(ref_key) = &ref_track.key {
- if let Some(track_key) = &track.key {
- return ref_key.is_compatible(track_key);
- }
- // If target has no key, return false
- return false;
- }
- // If reference has no key, consider it compatible
- true
-}
-
-/// Check if a track should be filtered based on BPM drift and key compatibility
-///
-/// # Arguments
-/// * `track` - The track to check
-/// * `ref_track` - The reference track to compare against
-/// * `drift_opt` - Optional BPM drift tolerance (if None, no drift filtering is applied)
-///
-/// # Returns
-/// True if the track passes all filters, false otherwise
-pub fn should_include_track(track: &TrackInfo, ref_track: &TrackInfo, drift_opt: Option<f32>) -> bool {
- // Apply drift filtering if specified
- if let Some(drift) = drift_opt
- && !is_within_drift_tolerance(track, ref_track, drift)
- {
- return false;
- }
-
- // Filter by Key compatibility
- is_key_compatible(track, ref_track)
-}
diff --git a/matchakey/src/key.rs b/matchakey/src/key.rs
@@ -1,151 +0,0 @@
-// This module handles music key parsing and determines musical compatibility between keys.
-
-use regex::Regex;
-use std::sync::OnceLock;
-
-#[derive(Debug, Clone, PartialEq)]
-pub enum Mode {
- Major,
- Minor,
-}
-
-/// Keys are represented internally using the Camelot Wheel system where:
-/// - Root values range from 1-12 corresponding to positions on the wheel
-/// - Mode indicates whether the key is major or minor
-#[derive(Debug, Clone, PartialEq)]
-pub struct Key {
- /// Root note position (1-12 on the Camelot wheel)
- pub root: u8,
- /// Key mode (major/minor)
- pub mode: Mode,
-}
-
-/// Cache for compiled regex to avoid recompilation
-static CAMELOT_REGEX: OnceLock<Regex> = OnceLock::new();
-
-impl Key {
- /// Parse a key from a string representation
- ///
- /// Supports both Camelot notation (e.g., "8A", "11B") and standard notation
- /// (e.g., "C Major", "Am", "F#m", "Eb")
- ///
- /// # Arguments
- /// * `s` - String representation of the key
- ///
- /// # Returns
- /// Some(Key) if parsing succeeds, None otherwise
- ///
- /// # Panics
- /// Panics if the `CAMELOT_REGEX` fails to build.
- pub fn from_string(s: &str) -> Option<Self> {
- let s = s.trim();
-
- // Get or initialize the cached regex
- let camelot_re =
- CAMELOT_REGEX.get_or_init(|| Regex::new(r"(?i)^(\d{1,2})([AB])$").unwrap());
-
- if let Some(caps) = camelot_re.captures(s) {
- let num: u8 = caps[1].parse().ok()?;
- let letter = &caps[2];
-
- if !(1..=12).contains(&num) {
- return None;
- }
-
- // Convert Camelot to Circle of Fifths position (which maps to root notes)
- // Map Standard to Camelot logic (1-12)
-
- let mode = if letter.to_uppercase() == "A" {
- Mode::Minor
- } else {
- Mode::Major
- };
- return Some(Key { root: num, mode });
- }
-
- // Try parsing Standard notation (e.g., C Major, Am, F#m, Eb)
- // Map Standard to Camelot
- // Major:
- // B (1B), F# (2B), Db (3B), Ab (4B), Eb (5B), Bb (6B), F (7B), C (8B), G (9B), D (10B), A (11B), E (12B)
- // Minor:
- // Abm (1A), Ebm (2A), Bbm (3A), Fm (4A), Cm (5A), Gm (6A), Dm (7A), Am (8A), Em (9A), Bm (10A), F#m(11A), Dbm(12A)
-
- // Normalize string
- let s_norm = s.replace(' ', "").to_lowercase();
-
- let (note, is_minor) = if s_norm.ends_with("minor")
- || s_norm.ends_with("min")
- || (s_norm.ends_with('m') && !s_norm.ends_with("major"))
- {
- let base = s_norm
- .trim_end_matches("minor")
- .trim_end_matches("min")
- .trim_end_matches('m');
- (base, true)
- } else if s_norm.ends_with("major") || s_norm.ends_with("maj") {
- let base = s_norm.trim_end_matches("major").trim_end_matches("maj");
- (base, false)
- } else {
- (s_norm.as_str(), false) // Default to Major if not specified
- };
-
- let camelot_num = match (note, is_minor) {
- ("b", false) | ("ab" | "g#", true) => 1,
- ("f#" | "gb", false) | ("eb" | "d#", true) => 2,
- ("db" | "c#", false) | ("bb" | "a#", true) => 3,
- ("ab" | "g#", false) | ("f", true) => 4,
- ("eb" | "d#", false) | ("c", true) => 5,
- ("bb" | "a#", false) | ("g", true) => 6,
- ("f", false) | ("d", true) => 7,
- ("c", false) | ("a", true) => 8,
- ("g", false) | ("e", true) => 9,
- ("d", false) | ("b", true) => 10,
- ("a", false) | ("f#" | "gb", true) => 11,
- ("e", false) | ("db" | "c#", true) => 12,
-
- _ => return None,
- };
-
- let mode = if is_minor { Mode::Minor } else { Mode::Major };
- Some(Key {
- root: camelot_num,
- mode,
- })
- }
-
- #[must_use]
- pub fn is_compatible(&self, other: &Key) -> bool {
- // Exact match
- if self == other {
- return true;
- }
-
- // Same mode, +/- 1 hour (wrap around 12)
- if self.mode == other.mode {
- let diff = (i16::from(self.root) - i16::from(other.root)).abs();
- if diff == 1 || diff == 11 {
- return true;
- }
- }
-
- // Different mode, same hour (Relative Major/Minor) e.g. 8A <-> 8B
- if self.root == other.root && self.mode != other.mode {
- return true;
- }
-
- false
- }
-
- /// Convert the key to Camelot notation string (e.g., "8A", "11B")
- ///
- /// # Returns
- /// A string in Camelot notation format
- #[must_use]
- pub fn to_camelot(&self) -> String {
- let letter = match self.mode {
- Mode::Minor => "A",
- Mode::Major => "B",
- };
- format!("{}{}", self.root, letter)
- }
-}
diff --git a/matchakey/src/lib.rs b/matchakey/src/lib.rs
@@ -1,5 +0,0 @@
-pub mod db;
-pub mod drift;
-pub mod key;
-pub mod track;
-pub mod utils;
diff --git a/matchakey/src/main.rs b/matchakey/src/main.rs
@@ -1,218 +0,0 @@
-use anyhow::{Context, Result};
-use clap::Parser;
-use colored::Colorize;
-use rand::seq::SliceRandom;
-use rayon::prelude::*;
-use std::path::PathBuf;
-use tracing::{debug, info, warn};
-use walkdir::WalkDir;
-
-
-
-use matchakey::track::{get_track_info, TrackInfo};
-use matchakey::utils::is_allowed_extension;
-use matchakey::db::find_tracks_from_mixxx;
-use matchakey::key;
-
-fn get_default_mixxx_db_path() -> Option<PathBuf> {
- std::env::var_os("HOME").map(|home| {
- let mut path = PathBuf::from(home);
- path.push(".mixxx");
- path.push("mixxxdb.sqlite");
- path
- })
-}
-
-#[derive(Parser, Debug)]
-#[command(author, version, about, long_about = None)]
-struct Args {
- /// Reference track file to compare against
- #[arg(required = true)]
- reference: PathBuf,
-
- /// Directories to search for compatible tracks
- #[arg(required_unless_present_any = &["mixxx", "mixxx_path"])]
- source_path: Vec<PathBuf>,
-
- /// Use Mixxx database (defaults to ~/.mixxx/mixxxdb.sqlite)
- #[arg(long)]
- mixxx: bool,
-
- /// Custom path to Mixxx database (implies --mixxx if provided)
- #[arg(long, conflicts_with = "mixxx")]
- mixxx_path: Option<PathBuf>,
-
- /// Number of tracks to fetch from Mixxx database for shuffling (default: 1000)
- #[arg(long)]
- mixxx_db_size: Option<u32>,
-
- /// Limit number of tracks to display from Mixxx database (default: 10)
- #[arg(long)]
- limit: Option<u32>,
-
- /// BPM drift tolerance (default: 3.0)
- #[arg(long)]
- drift: Option<f32>,
-
- /// Enable verbose output
- #[arg(short, long)]
- verbose: bool,
-}
-
-fn main() -> Result<()> {
- tracing_subscriber::fmt::init();
-
- let mut args = Args::parse();
-
- // Determine the mixxx database path
- // If mixxx_path is provided, use it
- // If mixxx flag is provided without path, use default
- // Otherwise, don't use mixxx functionality
- let mixxx_db_path = if let Some(custom_path) = args.mixxx_path {
- Some(custom_path)
- } else if args.mixxx {
- get_default_mixxx_db_path()
- } else {
- None
- };
-
- // Update args to have the resolved path
- args.mixxx_path = mixxx_db_path;
-
- info!("Starting matchakey with args: {:?}", args);
-
- println!(
- "{} {}",
- " Analyzing reference track:".bold().blue(),
- args.reference.display()
- );
- let ref_track = get_track_info(&args.reference).context("Failed to read reference track")?;
- debug!(
- "Reference track info: bpm={}, key={:?}",
- ref_track.bpm, ref_track.key
- );
-
- if ref_track.bpm == 0.0 {
- warn!("Reference track has no BPM metadata.");
- println!(
- "{}",
- "Warning: Reference track has no BPM metadata.".yellow()
- );
- }
-
- let ref_key_str = ref_track
- .key
- .as_ref()
- .map_or_else(|| "Unknown".to_string(), key::Key::to_camelot);
- println!(
- " Reference BPM: {}",
- format!("{:.1}", ref_track.bpm).green()
- );
- println!(
- " Reference Key: {} ({})",
- ref_key_str.green(),
- ref_track.key_raw
- );
- println!("---------------------------------------------------");
-
- if let Some(db_path) = &args.mixxx_path {
- let db_fetch_size = args.mixxx_db_size.unwrap_or(1000); // Default to 1000 if not specified
- let mut results =
- find_tracks_from_mixxx(db_path, Some(db_fetch_size), args.drift, &ref_track)?; // Fetch configurable amount
-
- // Shuffle all fetched results
- let mut rng = rand::rng();
- results.shuffle(&mut rng);
-
- let display_count = args.limit.unwrap_or(10) as usize;
- for track in results.into_iter().take(display_count) {
- let artist = track.artist.as_deref().unwrap_or("Unknown");
- let title = track.title.as_deref().unwrap_or("Unknown");
- let datetime_added = track.datetime_added.as_deref().unwrap_or("Unknown");
-
- let bpm_display = format!("{:.1}", track.bpm);
- let key_display = track
- .key
- .as_ref()
- .map_or_else(|| track.key_raw.clone(), key::Key::to_camelot);
-
- println!(
- "{:<6} {:<4} | {:<20} - {:<20} ({})",
- bpm_display.cyan(),
- key_display.yellow(),
- artist.bold(),
- title,
- datetime_added.dimmed()
- );
- }
- } else {
- find_and_process_tracks(&args, &ref_track);
- }
-
- info!("Completed processing successfully");
- Ok(())
-}
-
-fn find_and_process_tracks(args: &Args, ref_track: &TrackInfo) {
- let files: Vec<PathBuf> = args
- .source_path
- .iter()
- .flat_map(|dir| {
- WalkDir::new(dir).into_iter().filter_map(|entry| {
- entry
- .map_err(|e| {
- warn!("Failed to read directory entry: {}", e);
- e
- })
- .ok()
- })
- })
- .filter(|e| e.path().is_file())
- .filter(|e| is_allowed_extension(e.path()))
- .map(|e| e.path().to_path_buf())
- .collect();
-
- info!("Found {} files to scan.", files.len());
- if args.verbose {
- println!("Found {} files to scan.", files.len());
- }
-
- let mut results: Vec<TrackInfo> = files
- .par_iter()
- .filter_map(|path| get_track_info(path).ok())
- .filter(|info| {
- // Use the drift module to check if track should be included
- matchakey::drift::should_include_track(info, ref_track, args.drift)
- })
- .collect();
-
- // Sort results by BPM difference
- results.sort_by(|a, b| {
- let diff_a = (a.bpm - ref_track.bpm).abs();
- let diff_b = (b.bpm - ref_track.bpm).abs();
- diff_a
- .partial_cmp(&diff_b)
- .unwrap_or(std::cmp::Ordering::Equal)
- });
-
- for track in results {
- let artist = track.artist.as_deref().unwrap_or("Unknown");
- let title = track.title.as_deref().unwrap_or("Unknown");
- let datetime_added = track.datetime_added.as_deref().unwrap_or("Unknown");
-
- let bpm_display = format!("{:.1}", track.bpm);
- let key_display = track
- .key
- .as_ref()
- .map_or_else(|| track.key_raw.clone(), key::Key::to_camelot);
-
- println!(
- "{:<6} {:<4} | {:<20} - {:<20} ({})",
- bpm_display.cyan(),
- key_display.yellow(),
- artist.bold(),
- title,
- datetime_added.dimmed()
- );
- }
-}
diff --git a/matchakey/src/track.rs b/matchakey/src/track.rs
@@ -1,80 +0,0 @@
-//! Track metadata handling
-//!
-//! This module handles reading and parsing metadata from audio files.
-
-use anyhow::{Context, Result};
-use lofty::prelude::*;
-use lofty::probe::Probe;
-use lofty::tag::ItemKey;
-use lofty::config::ParseOptions;
-use std::path::Path;
-
-/// Information extracted from a track's metadata
-#[derive(Debug)]
-pub struct TrackInfo {
- /// Beats per minute (tempo)
- pub bpm: f32,
- /// Musical key (if available)
- pub key: Option<Key>,
- /// Raw key string from the file
- pub key_raw: String,
- /// Track title (if available)
- pub title: Option<String>,
- /// Track artist (if available)
- pub artist: Option<String>,
- /// Date and time when track was added to the database
- pub datetime_added: Option<String>,
-}
-
-use crate::key::Key;
-
-/// Extract metadata information from an audio file
-///
-/// # Arguments
-/// * `path` - Path to the audio file
-///
-/// # Returns
-/// `TrackInfo` containing the extracted metadata, or an error if the file could not be read
-///
-/// # Errors
-///
-/// This function will return an error if the file metadata cannot be read.
-pub fn get_track_info(path: &Path) -> Result<TrackInfo> {
- let tagged_file = Probe::open(path)
- .context("Failed to open file")?
- .options(ParseOptions::new().read_properties(false))
- .read()
- .context("Failed to read metadata")?;
-
- let tag = tagged_file
- .primary_tag()
- .or_else(|| tagged_file.first_tag());
-
- let bpm_val = tag
- .and_then(|t| t.get_string(ItemKey::Bpm))
- .and_then(|s| s.parse::<f32>().ok())
- .unwrap_or(0.0);
-
- let key_raw = tag
- .and_then(|t| t.get_string(ItemKey::InitialKey))
- .map(ToString::to_string)
- .unwrap_or_default();
-
- let key = Key::from_string(&key_raw);
-
- let title = tag
- .and_then(|t| t.get_string(ItemKey::TrackTitle))
- .map(String::from);
- let artist = tag
- .and_then(|t| t.get_string(ItemKey::TrackArtist))
- .map(String::from);
-
- Ok(TrackInfo {
- bpm: bpm_val,
- key,
- key_raw,
- title,
- artist,
- datetime_added: None, // Not available from file metadata, only from database
- })
-}
diff --git a/matchakey/src/utils.rs b/matchakey/src/utils.rs
@@ -1,12 +0,0 @@
-use std::collections::HashSet;
-use std::path::Path;
-
-#[must_use]
-pub fn is_allowed_extension(path: &Path) -> bool {
- let allowed_extensions: HashSet<&str> = ["mp3", "flac", "aiff"].iter().copied().collect();
- if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
- allowed_extensions.contains(ext.to_lowercase().as_str())
- } else {
- false
- }
-}
diff --git a/matchakey/tests/integration_tests.rs b/matchakey/tests/integration_tests.rs
@@ -1,75 +0,0 @@
-//! Integration tests for the matchakey application
-
-use matchakey::key::{Key, Mode};
-
-#[test]
-fn test_key_parsing_integration() {
- // Test Camelot notation parsing
- let key = Key::from_string("8A").unwrap();
- assert_eq!(key.root, 8);
- assert_eq!(key.mode, Mode::Minor);
-
- let key = Key::from_string("8B").unwrap();
- assert_eq!(key.root, 8);
- assert_eq!(key.mode, Mode::Major);
-
- // Test standard notation parsing
- let key = Key::from_string("C").unwrap();
- assert_eq!(key.root, 8); // C maps to 8B in Camelot
- assert_eq!(key.mode, Mode::Major);
-
- let key = Key::from_string("Am").unwrap();
- assert_eq!(key.root, 8); // Am maps to 8A in Camelot
- assert_eq!(key.mode, Mode::Minor);
-}
-
-#[test]
-fn test_key_compatibility_integration() {
- let key1 = Key {
- root: 8,
- mode: Mode::Major,
- }; // 8B
- let key2 = Key {
- root: 8,
- mode: Mode::Minor,
- }; // 8A - relative minor
-
- assert!(key1.is_compatible(&key2));
- assert!(key2.is_compatible(&key1));
-
- let key3 = Key {
- root: 9,
- mode: Mode::Major,
- }; // 9B - adjacent key
- assert!(key1.is_compatible(&key3));
- assert!(key3.is_compatible(&key1));
-
- let key4 = Key {
- root: 7,
- mode: Mode::Major,
- }; // 7B - adjacent key (wrapping)
- assert!(key1.is_compatible(&key4));
- assert!(key4.is_compatible(&key1));
-
- let key5 = Key {
- root: 6,
- mode: Mode::Major,
- }; // 6B - not adjacent
- assert!(!key1.is_compatible(&key5));
- assert!(!key5.is_compatible(&key1));
-}
-
-#[test]
-fn test_camelot_conversion_integration() {
- let key = Key {
- root: 8,
- mode: Mode::Major,
- };
- assert_eq!(key.to_camelot(), "8B");
-
- let key = Key {
- root: 8,
- mode: Mode::Minor,
- };
- assert_eq!(key.to_camelot(), "8A");
-}
diff --git a/matchakey/tests/utils_tests.rs b/matchakey/tests/utils_tests.rs
@@ -1,19 +0,0 @@
-//! Integration tests for utility functions
-
-use matchakey::utils::is_allowed_extension;
-use std::path::Path;
-
-#[test]
-fn test_is_allowed_extension_integration() {
- assert!(is_allowed_extension(Path::new("test.mp3")));
- assert!(is_allowed_extension(Path::new("test.flac")));
- assert!(is_allowed_extension(Path::new("test.aiff")));
- assert!(is_allowed_extension(Path::new("TEST.MP3")));
- assert!(is_allowed_extension(Path::new("path/to/file.FLAC")));
-
- assert!(!is_allowed_extension(Path::new("test.wav")));
- assert!(!is_allowed_extension(Path::new("test.ogg")));
- assert!(!is_allowed_extension(Path::new("test.m4a")));
- assert!(!is_allowed_extension(Path::new("test.txt")));
- assert!(!is_allowed_extension(Path::new("test")));
-}
diff --git a/virittaa/BUILD b/virittaa/BUILD
@@ -1,7 +0,0 @@
-load("@crates//:defs.bzl", "all_crate_deps")
-load("//bazel:rust.bzl", "rust_app")
-
-rust_app(
- name = "virittaa",
- deps = all_crate_deps(),
-)
diff --git a/virittaa/Cargo.toml b/virittaa/Cargo.toml
@@ -1,22 +0,0 @@
-[package]
-name = "virittaa"
-version = "0.1.0"
-edition = "2024"
-
-[dependencies]
-bliss-audio = { version = "0.11.2", features = ["bench"] }
-bliss-audio-aubio-rs = "0.2.4"
-ndarray = "0.17.2"
-ndarray-stats = "0.7.0"
-noisy_float = "0.2.0"
-anyhow = "1.0.45"
-clap = { version = "4.5.35", features = ["derive"] }
-walkdir = "2.5"
-lofty = "0.24.0"
-rayon = "1.10"
-jemallocator = "0.5"
-indicatif = "0.18.3"
-chrono = "0.4.43"
-csv = "1.3"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
diff --git a/virittaa/Makefile.toml b/virittaa/Makefile.toml
@@ -1,35 +0,0 @@
-[tasks.audit]
-command = "cargo"
-args = ["audit", "--color", "always"]
-
-[tasks.clippy]
-command = "cargo"
-args = ["clippy", "--", "-W", "clippy::pedantic"]
-
-[tasks.format]
-command = "cargo"
-args = ["fmt", "--", "--emit=files"]
-
-[tasks.test]
-command = "cargo"
-args = ["test"]
-
-[tasks.build]
-command = "cargo"
-args = ["build"]
-
-[tasks.build-release]
-command = "cargo"
-args = ["build", "--release"]
-
-[tasks.install]
-command = "cargo"
-args = ["install", "--path", "."]
-
-[tasks.checks]
-dependencies = [
- "clippy",
- "format",
- "audit",
- "test"
-]
diff --git a/virittaa/README.md b/virittaa/README.md
@@ -1,83 +0,0 @@
-# virittaa
-
-`virittaa` is a command-line tool for analyzing audio files to detect `BPM` and `Key`. It is designed to be fast and accurate, with a focus on providing useful information for DJs, musicians, and music enthusiasts.
-
-## Features
-
-* **BPM Detection**: Accurately detects the tempo of a track in beats per minute.
-* **Key Detection**: Determines the musical key of a track.
-* **Metadata Tagging**: Optionally writes the detected BPM and Key to the audio file's metadata.
-* **CSV Reporting**: Generates a CSV report of the analyzed tracks, including their BPM, Key, and other metadata.
-* **Waveform Generation**: Can generate waveform images of the audio files (requires `aaltomuoto`).
-* **Batch Processing**: Process multiple files at once, with support for running in parallel and cooldowns between batches.
-
-## Installation
-
-1. **Clone the repository:**
- ```bash
- git clone https://github.com/your-username/virittaa.git
- cd virittaa
- ```
-2. **Build the project:**
- ```bash
- cargo build --release
- ```
-3. **The executable will be located at `target/release/virittaa`.** You can copy it to a directory in your `PATH` to make it accessible from anywhere on your system.
-
-## Dependencies
-
-* **`aaltomuoto`**: Required for generating waveform images.
-
-## Usage
-
-### Basic Usage
-
-To analyze a single file or a directory of files, simply provide the path to the file or directory as an argument:
-
-```bash
-# Analyze a single file
-virittaa path/to/track.mp3
-
-# Analyze all files in a directory
-virittaa path/to/music/
-```
-
-### Writing Metadata
-
-To write the detected BPM and Key to the audio file's metadata, use the `--write-tags` or `-w` flag:
-
-```bash
-virittaa --write-tags path/to/music/
-```
-
-### Generating a CSV Report
-
-To generate a CSV report of the analyzed tracks, use the `--report` or `-r` flag. The report will be saved in the current directory with a timestamped filename (e.g., `virittaa-report-2023-10-27T12-00-00.csv`).
-
-```bash
-virittaa --report path/to/music/
-```
-
-### Generating Waveforms
-
-To generate waveform images of the audio files, use the `--waveform` or `-f` flag. This requires `aaltomuoto` to be installed and in your `PATH`.
-
-```bash
-virittaa --waveform path/to/music/
-```
-
-### Advanced Usage
-
-You can control the number of threads used for processing with the `--jobs` or `-j` flag:
-
-```bash
-# Use 4 threads for processing
-virittaa -j 4 path/to/music/
-```
-
-You can also process files in batches with a cooldown period between each batch. This is useful for preventing your system from being overloaded when processing a large number of files.
-
-```bash
-# Process files in batches of 10, with a 5-second cooldown between each batch
-virittaa --batch-size 10 --cooldown 5 path/to/music/
-```
diff --git a/virittaa/src/audio_processing.rs b/virittaa/src/audio_processing.rs
@@ -1,177 +0,0 @@
-use anyhow::Result;
-use bliss_audio::chroma::bench::{chroma_stft, estimate_tuning};
-use bliss_audio::utils::bench::stft;
-use bliss_audio_aubio_rs::{OnsetMode, Tempo};
-use ndarray::Array1;
-use ndarray_stats::Quantile1dExt;
-use noisy_float::types::n64;
-use crate::SAMPLE_RATE;
-use crate::constants::{
- BPM_HOP_SIZE, BPM_MAX, BPM_MIN, BPM_OFFSET, BPM_WINDOW_SIZE, CHROMA_BINS, KEY_HOP_SIZE,
- KEY_WINDOW_SIZE, TUNING_PRECISION,
-};
-
-/// Calculate the BPM of a track.
-///
-/// # Panics
-///
-/// This function will panic if the tempo object cannot be created.
-#[must_use]
-pub fn calculate_bpm(samples: &[f32]) -> f32 {
- let window_size = BPM_WINDOW_SIZE;
- let hop_size = BPM_HOP_SIZE;
- let mut tempo = Tempo::new(OnsetMode::SpecFlux, window_size, hop_size, SAMPLE_RATE)
- .expect("Failed to create tempo object");
- let mut bpms = Vec::new();
-
- for chunk in samples.chunks(hop_size) {
- let mut padded = chunk.to_vec();
- if padded.len() < hop_size {
- padded.resize(hop_size, 0.0);
- }
- if tempo.do_result(&padded).expect("Failed to calculate tempo") > 0.0 {
- bpms.push(tempo.get_bpm());
- }
- }
-
- if bpms.is_empty() {
- return 0.0;
- }
-
- let bpms_array = Array1::from(bpms);
- #[allow(clippy::cast_possible_truncation)]
- let median_bpm = *bpms_array
- .mapv(|x| n64(f64::from(x)))
- .quantile_mut(n64(0.5), &ndarray_stats::interpolate::Midpoint)
- .expect("Failed to calculate median BPM")
- .as_ref() as f32;
-
- normalize_bpm(median_bpm)
-}
-
-fn normalize_bpm(mut bpm: f32) -> f32 {
- if bpm > 0.0 {
- bpm += BPM_OFFSET;
- }
-
- // Constraints BPM to 80-160
- if bpm > 0.0 {
- while bpm > BPM_MAX {
- bpm /= 2.0;
- }
- while bpm < BPM_MIN {
- bpm *= 2.0;
- }
- }
-
- bpm
-}
-
-/// Calculate the key of a track.
-///
-/// # Errors
-///
-/// This function will return an error if the key cannot be calculated.
-pub fn calculate_key(samples: &[f32]) -> Result<String> {
- let global_chroma = calculate_global_chroma(samples)?;
- let (best_key_idx, best_mode) = find_best_key(&global_chroma);
- let key_names = [
- "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
- ];
- Ok(format!("{} {}", key_names[best_key_idx], best_mode))
-}
-
-fn calculate_global_chroma(samples: &[f32]) -> Result<Array1<f64>> {
- let window_size = KEY_WINDOW_SIZE;
- let hop_size = KEY_HOP_SIZE;
-
- let mut spectrum = stft(samples, window_size, hop_size);
-
- let tuning = estimate_tuning(
- SAMPLE_RATE,
- &spectrum,
- window_size,
- TUNING_PRECISION,
- CHROMA_BINS.try_into()?,
- )?;
-
- let n_chroma = u32::try_from(CHROMA_BINS)?;
- let chroma = chroma_stft(SAMPLE_RATE, &mut spectrum, window_size, n_chroma, tuning)?;
-
- // Manually sum along axis 1 to avoid ndarray version conflicts
- let shape = chroma.shape();
- let mut result = vec![0.0; shape[0]];
- for i in 0..shape[0] {
- let mut sum = 0.0;
- for j in 0..shape[1] {
- sum += chroma[[i, j]];
- }
- result[i] = sum;
- }
-
- Ok(Array1::from_vec(result))
-}
-
-fn find_best_key(global_chroma: &Array1<f64>) -> (usize, &'static str) {
- let major_profile = get_major_profile();
- let minor_profile = get_minor_profile();
-
- let mut max_corr = -1.0;
- let mut best_key_idx = 0;
- let mut best_mode = "Major";
-
- for i in 0..CHROMA_BINS {
- let rotated_major = rotate_array(&major_profile, i);
- let rotated_minor = rotate_array(&minor_profile, i);
-
- let corr_major = pearson_correlation(global_chroma, &rotated_major);
- if corr_major > max_corr {
- max_corr = corr_major;
- best_key_idx = i;
- best_mode = "Major";
- }
-
- let corr_minor = pearson_correlation(global_chroma, &rotated_minor);
- if corr_minor > max_corr {
- max_corr = corr_minor;
- best_key_idx = i;
- best_mode = "Minor";
- }
- }
-
- (best_key_idx, best_mode)
-}
-
-fn rotate_array(arr: &Array1<f64>, shift: usize) -> Array1<f64> {
- let n = arr.len();
- Array1::from_iter((0..n).map(|j| arr[(j + n - shift) % n]))
-}
-
-fn get_major_profile() -> Array1<f64> {
- Array1::from(vec![
- 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
- ])
-}
-
-fn get_minor_profile() -> Array1<f64> {
- Array1::from(vec![
- 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
- ])
-}
-
-fn pearson_correlation(v1: &Array1<f64>, v2: &Array1<f64>) -> f64 {
- let mean1 = v1.mean().expect("Failed to calculate mean");
- let mean2 = v2.mean().expect("Failed to calculate mean");
- let num: f64 = v1
- .iter()
- .zip(v2.iter())
- .map(|(x, y)| (x - mean1) * (y - mean2))
- .sum();
- let den1: f64 = v1.iter().map(|x| (x - mean1).powi(2)).sum();
- let den2: f64 = v2.iter().map(|y| (y - mean2).powi(2)).sum();
- if den1 == 0.0 || den2 == 0.0 {
- 0.0
- } else {
- num / (den1.sqrt() * den2.sqrt())
- }
-}
diff --git a/virittaa/src/constants.rs b/virittaa/src/constants.rs
@@ -1,24 +0,0 @@
-//! Audio processing constants
-
-// BPM
-pub const BPM_WINDOW_SIZE: usize = 1024;
-pub const BPM_HOP_SIZE: usize = 128;
-pub const BPM_OFFSET: f32 = -1.15;
-pub const BPM_MIN: f32 = 80.0;
-pub const BPM_MAX: f32 = 160.0;
-
-// Key
-pub const KEY_WINDOW_SIZE: usize = 8192;
-pub const KEY_HOP_SIZE: usize = 2205;
-pub const TUNING_PRECISION: f64 = 0.01;
-pub const CHROMA_BINS: usize = 12;
-
-// Waveform
-pub const WAVEFORM_WIDTH: usize = 2000;
-pub const WAVEFORM_HEIGHT: usize = 350;
-
-// Supported audio file extensions
-pub const SUPPORTED_EXTENSIONS: &[&str] = &[
- "aac", "ape", "aif", "aiff", "flac", "mp3", "mp4", "m4a", "mpc", "opus", "ogg", "spx", "wav",
- "wv",
-];
diff --git a/virittaa/src/file_io.rs b/virittaa/src/file_io.rs
@@ -1,233 +0,0 @@
-use lofty::prelude::TaggedFileExt;
-use crate::constants::{WAVEFORM_HEIGHT, WAVEFORM_WIDTH};
-use anyhow::Result;
-use chrono::Local;
-use lofty::config::WriteOptions;
-use lofty::prelude::*;
-use lofty::probe::Probe;
-use lofty::tag::{ItemKey, Tag};
-use std::borrow::Cow;
-use std::path::Path;
-use std::process::Command as ProcessCommand;
-
-use crate::audio_processing::{calculate_bpm, calculate_key};
-use crate::types::{TrackError, TrackInfo};
-use bliss_audio::decoder::ffmpeg::FFmpegDecoder as Decoder;
-use bliss_audio::decoder::Decoder as DecoderTrait;
-
-/// Metadata extracted from a file's tags.
-#[derive(Debug, Default)]
-pub struct FileMetadata {
- pub artist: Option<String>,
- pub album: Option<String>,
- pub track: Option<String>,
- pub label: Option<String>,
-}
-
-/// Process a single file.
-///
-/// # Errors
-///
-/// This function will return an error if the file cannot be processed.
-pub fn process_file(path: &Path, write_tags: bool) -> Result<TrackInfo, String> {
- let song = Decoder::decode(path).map_err(|e| format!("Error decoding: {e}"))?;
- let samples = &song.sample_array;
-
- let bpm = calculate_bpm(samples);
- let key = calculate_key(samples).map_err(|e| format!("Error calculating key: {e}"))?;
-
- let metadata = read_metadata(path);
-
- println!("File: {}, BPM: {bpm:.1}, Key: {key}", path.display());
-
- if write_tags && let Err(e) = write_metadata(path, bpm, &key) {
- eprintln!("Error writing tags for {}: {e}", path.display());
- }
-
- Ok(TrackInfo {
- path: path.to_path_buf(),
- artist: metadata.artist,
- album: metadata.album,
- track: metadata.track,
- label: metadata.label,
- bpm,
- key,
- })
-}
-
-/// Read metadata from an audio file.
-///
-/// Returns default values if the file cannot be read or has no tags.
-pub fn read_metadata(path: &Path) -> FileMetadata {
- match Probe::open(path).and_then(Probe::read) {
- Ok(tagged_file) => {
- let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
- if let Some(t) = tag {
- FileMetadata {
- artist: t
- .get_string(ItemKey::TrackArtist)
- .or_else(|| t.get_string(ItemKey::AlbumArtist))
- .map(String::from),
- album: t.get_string(ItemKey::AlbumTitle).map(String::from),
- track: t.get_string(ItemKey::TrackTitle).map(String::from),
- label: t.get_string(ItemKey::Label).map(String::from),
- }
- } else {
- FileMetadata::default()
- }
- }
- Err(_) => FileMetadata::default(),
- }
-}
-
-#[derive(serde::Serialize)]
-#[serde(rename_all = "PascalCase")]
-struct ReportRecord<'a> {
- path: Cow<'a, str>,
- artist: Option<&'a str>,
- album: Option<&'a str>,
- track: Option<&'a str>,
- label: Option<&'a str>,
- #[serde(rename = "BPM")]
- bpm: Option<String>,
- key: Option<&'a str>,
- status: &'a str,
- error: Option<&'a str>,
-}
-
-/// Write a report of the processed tracks.
-///
-/// # Errors
-///
-/// This function will return an error if the report cannot be written.
-pub fn write_report(results: &[TrackInfo], errors: &[TrackError]) -> Result<()> {
- let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
- let filename = format!("virittaa-report-{timestamp}.csv");
-
- let mut wtr = csv::Writer::from_path(&filename)?;
-
- for track in results {
- wtr.serialize(ReportRecord {
- path: track.path.to_string_lossy(),
- artist: track.artist.as_deref(),
- album: track.album.as_deref(),
- track: track.track.as_deref(),
- label: track.label.as_deref(),
- bpm: Some(format!("{:.1}", track.bpm)),
- key: Some(&track.key),
- status: "Success",
- error: None,
- })?;
- }
-
- for error in errors {
- wtr.serialize(ReportRecord {
- path: error.path.to_string_lossy(),
- artist: None,
- album: None,
- track: None,
- label: None,
- bpm: None,
- key: None,
- status: "Error",
- error: Some(&error.reason),
- })?;
- }
-
- wtr.flush()?;
- println!("Report written to: {filename}");
- Ok(())
-}
-
-pub fn generate_waveform_images(results: &[TrackInfo]) {
- if let Err(e) = std::fs::create_dir_all("meta") {
- eprintln!("Error creating meta directory: {e}");
- return;
- }
- for track in results {
- generate_waveform_image(track);
- }
-}
-
-fn generate_waveform_image(track: &TrackInfo) {
- let artist = track.artist.as_deref().unwrap_or("unknown");
- let album = track.album.as_deref().unwrap_or("unknown");
- let title = track.track.as_deref().unwrap_or("unknown");
-
- let output_name = format!(
- "meta/{}_{}_{}_{}.png",
- sanitize_filename(artist),
- sanitize_filename(album),
- sanitize_filename(title),
- "waveform"
- )
- .to_lowercase();
-
- let track_path = track.path.to_string_lossy();
-
- match ProcessCommand::new("aaltomuoto")
- .arg(&*track_path)
- .arg(&output_name)
- .arg(WAVEFORM_WIDTH.to_string())
- .arg(WAVEFORM_HEIGHT.to_string())
- .output()
- {
- Ok(output) => {
- if output.status.success() {
- println!("Waveform generated: {output_name}");
- } else {
- let stderr = String::from_utf8_lossy(&output.stderr);
- eprintln!("Error generating waveform for {track_path}: {stderr}");
- }
- }
- Err(e) => {
- eprintln!("Failed to run aaltomuoto for {track_path}: {e}");
- }
- }
-}
-
-/// Sanitize a string for use in a filename.
-///
-/// Replaces non-alphanumeric characters (except `-`) with underscores.
-fn sanitize_filename(s: &str) -> String {
- s.chars()
- .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
- .collect()
-}
-
-/// Write metadata to a file.
-///
-/// # Panics
-///
-/// This function will panic if the metadata cannot be written.
-///
-/// # Errors
-///
-/// This function will return an error if the metadata cannot be written.
-pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> {
- let mut tagged_file = Probe::open(path)?.read()?;
- let tag = match tagged_file.primary_tag_mut() {
- Some(primary_tag) => primary_tag,
- None => {
- if let Some(first_tag) = tagged_file.first_tag_mut() {
- first_tag
- } else {
- // If there are no tags, create a new one based on the file type
- let tag_type = tagged_file.primary_tag_type();
- tagged_file.insert_tag(Tag::new(tag_type));
- tagged_file
- .primary_tag_mut()
- .expect("Failed to create new tag")
- }
- }
- };
-
- tag.insert_text(ItemKey::Bpm, bpm.round().to_string());
- tag.insert_text(ItemKey::InitialKey, key.to_string());
- if let Some(tkey) = ItemKey::from_key(tag.tag_type(), "TKEY") {
- tag.insert_text(tkey, key.to_string());
- }
-
- tag.save_to_path(path, WriteOptions::default())?;
- Ok(())
-}
diff --git a/virittaa/src/main.rs b/virittaa/src/main.rs
@@ -1,231 +0,0 @@
-pub mod audio_processing;
-pub mod constants;
-pub mod file_io;
-pub mod types;
-
-use anyhow::Result;
-use clap::{Arg, Command};
-use rayon::prelude::*;
-use std::path::PathBuf;
-use walkdir::WalkDir;
-
-use crate::file_io::{generate_waveform_images, process_file, write_report};
-use crate::types::{TrackError, TrackInfo};
-use std::sync::Mutex;
-
-#[global_allocator]
-static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
-
-fn is_supported_audio_file(path: &std::path::Path) -> bool {
- path.is_file()
- && path
- .extension()
- .is_some_and(is_supported_extension)
-}
-
-fn is_supported_extension(ext: &std::ffi::OsStr) -> bool {
- let ext_str = ext.to_string_lossy().to_lowercase();
- crate::constants::SUPPORTED_EXTENSIONS.contains(&ext_str.as_ref())
-}
-
-pub const SAMPLE_RATE: u32 = 22050;
-
-fn main() -> Result<()> {
- let matches = Command::new("virittaa")
- .arg(Arg::new("path").required(true).num_args(1..))
- .arg(
- Arg::new("write-tags")
- .short('w')
- .long("write-tags")
- .action(clap::ArgAction::SetTrue)
- .help("Write BPM and Key to file metadata"),
- )
- .arg(
- Arg::new("jobs")
- .short('j')
- .long("jobs")
- .help("Number of threads to use")
- .default_value("0")
- .value_parser(clap::value_parser!(usize)),
- )
- .arg(
- Arg::new("batch-size")
- .short('b')
- .long("batch-size")
- .help("Number of files to process before cooldown (0 = off)")
- .default_value("0")
- .value_parser(clap::value_parser!(usize)),
- )
- .arg(
- Arg::new("cooldown")
- .short('c')
- .long("cooldown")
- .help("Cooldown in seconds between batches")
- .default_value("0")
- .value_parser(clap::value_parser!(u64)),
- )
- .arg(
- Arg::new("report")
- .short('r')
- .long("report")
- .action(clap::ArgAction::SetTrue)
- .help("Generate a CSV report file in the current directory"),
- )
- .arg(
- Arg::new("waveform")
- .short('f')
- .long("waveform")
- .action(clap::ArgAction::SetTrue)
- .help("Generate waveform images using aaltomuoto"),
- )
- .get_matches();
-
- let write_tags = matches.get_flag("write-tags");
- let generate_report = matches.get_flag("report");
- let generate_waveforms = matches.get_flag("waveform");
- let jobs = *matches
- .get_one::<usize>("jobs")
- .expect("Failed to get jobs");
- let batch_size = *matches
- .get_one::<usize>("batch-size")
- .expect("Failed to get batch size");
- let cooldown_secs = *matches
- .get_one::<u64>("cooldown")
- .expect("Failed to get cooldown");
-
- if jobs > 0 {
- rayon::ThreadPoolBuilder::new()
- .num_threads(jobs)
- .build_global()
- .expect("Failed to build thread pool");
- }
-
- let path: Vec<String> = matches
- .get_many::<String>("path")
- .expect("Failed to get path")
- .map(std::clone::Clone::clone)
- .collect();
-
- run_processing(
- path,
- batch_size,
- cooldown_secs,
- write_tags,
- generate_report,
- generate_waveforms,
- )
-}
-
-#[allow(clippy::fn_params_excessive_bools)]
-fn run_processing(
- paths: Vec<String>,
- batch_size: usize,
- cooldown_secs: u64,
- write_tags: bool,
- generate_report: bool,
- generate_waveforms: bool,
-) -> Result<()> {
- let mut files_to_process = Vec::new();
-
- for path_str in paths {
- let path = PathBuf::from(&path_str);
- if path.is_dir() {
- for entry in WalkDir::new(path).into_iter().filter_map(Result::ok) {
- let path = entry.path();
- if is_supported_audio_file(path) {
- files_to_process.push(path.to_path_buf());
- }
- }
- } else if path.is_file() {
- files_to_process.push(path);
- }
- }
-
- let (results, errors) = process_files(&files_to_process, batch_size, cooldown_secs, write_tags);
-
- if generate_report {
- write_report(&results, &errors)?;
- }
-
- if generate_waveforms {
- generate_waveform_images(&results);
- }
-
- Ok(())
-}
-
-fn process_files(
- files_to_process: &[PathBuf],
- batch_size: usize,
- cooldown_secs: u64,
- write_tags: bool,
-) -> (Vec<TrackInfo>, Vec<TrackError>) {
- let results: Mutex<Vec<TrackInfo>> = Mutex::new(Vec::new());
- let errors: Mutex<Vec<TrackError>> = Mutex::new(Vec::new());
-
- let process_file_with_lock = |path: &PathBuf| {
- match process_file(path, write_tags) {
- Ok(info) => results.lock().expect("Failed to lock results").push(info),
- Err(reason) => errors
- .lock()
- .expect("Failed to lock errors")
- .push(TrackError {
- path: path.clone(),
- reason,
- }),
- }
- };
-
- if batch_size == 0 {
- files_to_process.par_iter().for_each(|path| {
- process_file_with_lock(path);
- });
- } else {
- process_in_batches(
- files_to_process,
- batch_size,
- cooldown_secs,
- process_file_with_lock,
- );
- }
-
- (
- results.into_inner().expect("Failed to get results"),
- errors.into_inner().expect("Failed to get errors"),
- )
-}
-
-fn process_in_batches(
- files: &[PathBuf],
- batch_size: usize,
- cooldown_secs: u64,
- process_fn: impl Fn(&PathBuf) + Sync,
-) {
- let chunks: Vec<_> = files.chunks(batch_size).collect();
- let total_batches = chunks.len();
-
- let pb = indicatif::ProgressBar::new(files.len() as u64);
- pb.set_style(
- indicatif::ProgressStyle::default_bar()
- .template(
- "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
- )
- .expect("Failed to create progress bar style")
- .progress_chars("#>-"),
- );
-
- for (batch_idx, batch) in chunks.iter().enumerate() {
- batch.par_iter().for_each(|path| {
- process_fn(path);
- pb.inc(1);
- });
-
- if batch_idx < total_batches - 1 {
- pb.set_message(format!("Cooldown {cooldown_secs} seconds..."));
- std::thread::sleep(std::time::Duration::from_secs(cooldown_secs));
- pb.set_message("");
- }
- }
-
- pb.finish_with_message("Done!");
-}
diff --git a/virittaa/src/types.rs b/virittaa/src/types.rs
@@ -1,16 +0,0 @@
-use std::path::PathBuf;
-
-pub struct TrackInfo {
- pub path: PathBuf,
- pub artist: Option<String>,
- pub album: Option<String>,
- pub track: Option<String>,
- pub label: Option<String>,
- pub bpm: f32,
- pub key: String,
-}
-
-pub struct TrackError {
- pub path: PathBuf,
- pub reason: String,
-}