tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

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:
MCargo.toml | 2--
MMODULE.bazel | 2--
Dmatchakey/BUILD | 33---------------------------------
Dmatchakey/Cargo.toml | 17-----------------
Dmatchakey/README.md | 76----------------------------------------------------------------------------
Dmatchakey/src/db.rs | 106-------------------------------------------------------------------------------
Dmatchakey/src/drift.rs | 65-----------------------------------------------------------------
Dmatchakey/src/key.rs | 151------------------------------------------------------------------------------
Dmatchakey/src/lib.rs | 5-----
Dmatchakey/src/main.rs | 218-------------------------------------------------------------------------------
Dmatchakey/src/track.rs | 80-------------------------------------------------------------------------------
Dmatchakey/src/utils.rs | 12------------
Dmatchakey/tests/integration_tests.rs | 75---------------------------------------------------------------------------
Dmatchakey/tests/utils_tests.rs | 19-------------------
Dvirittaa/BUILD | 7-------
Dvirittaa/Cargo.toml | 22----------------------
Dvirittaa/Makefile.toml | 35-----------------------------------
Dvirittaa/README.md | 83-------------------------------------------------------------------------------
Dvirittaa/src/audio_processing.rs | 177-------------------------------------------------------------------------------
Dvirittaa/src/constants.rs | 24------------------------
Dvirittaa/src/file_io.rs | 233-------------------------------------------------------------------------------
Dvirittaa/src/main.rs | 231-------------------------------------------------------------------------------
Dvirittaa/src/types.rs | 16----------------
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, -}