bpm-finder-tools 0.1.0

Lightweight Rust utilities and CLI for audio-file BPM analysis, tap tempo, and tempo conversion.
Documentation
use std::env;
use std::process;

use bpm_finder_tools::{TapTempoError, convert, file, range, tap};

struct NoteDivision<'a> {
    label: &'a str,
    multiplier: f64,
}

const NOTE_DIVISIONS: [NoteDivision<'static>; 10] = [
    NoteDivision {
        label: "whole",
        multiplier: 4.0,
    },
    NoteDivision {
        label: "half",
        multiplier: 2.0,
    },
    NoteDivision {
        label: "quarter",
        multiplier: 1.0,
    },
    NoteDivision {
        label: "eighth",
        multiplier: 0.5,
    },
    NoteDivision {
        label: "sixteenth",
        multiplier: 0.25,
    },
    NoteDivision {
        label: "thirty-second",
        multiplier: 0.125,
    },
    NoteDivision {
        label: "dotted quarter",
        multiplier: 1.5,
    },
    NoteDivision {
        label: "dotted eighth",
        multiplier: 0.75,
    },
    NoteDivision {
        label: "quarter triplet",
        multiplier: 2.0 / 3.0,
    },
    NoteDivision {
        label: "eighth triplet",
        multiplier: 1.0 / 3.0,
    },
];

fn main() {
    if let Err(message) = run() {
        eprintln!("Error: {message}");
        process::exit(1);
    }
}

fn run() -> Result<(), String> {
    let mut args = env::args().skip(1);
    let command = args.next().ok_or_else(usage)?;
    let remaining: Vec<String> = args.collect();

    match command.as_str() {
        "file" => run_file(&remaining),
        "tap" => run_tap(&remaining),
        "ms" => run_ms(&remaining),
        "bpm" => run_bpm(&remaining),
        "normalize" => run_normalize(&remaining),
        "--help" | "-h" | "help" => {
            println!("{}", usage());
            Ok(())
        }
        _ => Err(format!("unknown command: {command}\n\n{}", usage())),
    }
}

fn run_file(args: &[String]) -> Result<(), String> {
    if args.is_empty() {
        return Err("file requires a path to an audio file".to_string());
    }

    let path = &args[0];
    let (min, max) = parse_range_options(&args[1..])?;
    let analysis = file::analyze_path(path, min, max).map_err(format_error)?;

    println!("File: {path}");
    println!("Detected BPM: {}", format_number(analysis.bpm));
    println!("Rounded BPM: {}", analysis.rounded_bpm);
    println!("Normalized BPM: {}", format_number(analysis.normalized_bpm));
    println!("Confidence: {}", format_number(analysis.confidence));
    println!("Duration: {} s", format_number(analysis.duration_seconds));
    println!("Sample rate: {} Hz", analysis.sample_rate);

    Ok(())
}

fn run_tap(args: &[String]) -> Result<(), String> {
    if args.len() < 2 {
        return Err("tap requires at least two interval values in milliseconds".to_string());
    }

    let intervals = parse_numbers(args, "interval")?;
    let analysis = tap::analyze_intervals(&intervals).map_err(format_error)?;

    println!("Tap intervals: {}", format_list(&intervals));
    println!(
        "Average interval: {} ms",
        format_number(analysis.average_interval_ms)
    );
    println!("Exact BPM: {}", format_number(analysis.bpm));
    println!("Rounded BPM: {}", analysis.rounded_bpm);

    Ok(())
}

fn run_ms(args: &[String]) -> Result<(), String> {
    if args.len() != 1 {
        return Err("ms requires exactly one BPM value".to_string());
    }

    let bpm = parse_number(&args[0], "BPM")?;
    let beat_ms = convert::bpm_to_ms_per_beat(bpm).map_err(format_error)?;

    println!("BPM: {}", format_number(bpm));
    println!("Delay times (ms):");

    for division in NOTE_DIVISIONS {
        let value = beat_ms * division.multiplier;
        println!("{:<20}{}", division.label, format_number(value));
    }

    Ok(())
}

fn run_bpm(args: &[String]) -> Result<(), String> {
    if args.len() != 1 {
        return Err("bpm requires exactly one milliseconds value".to_string());
    }

    let milliseconds = parse_number(&args[0], "milliseconds")?;
    let exact = convert::ms_per_beat_to_bpm(milliseconds).map_err(format_error)?;

    println!("Milliseconds: {}", format_number(milliseconds));
    println!("Exact BPM: {}", format_number(exact));
    println!("Rounded BPM: {}", exact.round() as u32);

    Ok(())
}

fn run_normalize(args: &[String]) -> Result<(), String> {
    if args.is_empty() {
        return Err("normalize requires a BPM value".to_string());
    }

    let bpm = parse_number(&args[0], "BPM")?;
    let (min, max) = parse_range_options(&args[1..])?;

    let normalized = range::normalize(bpm, min, max).map_err(format_error)?;
    let in_range = range::is_within(bpm, min, max).map_err(format_error)?;

    println!("Input BPM: {}", format_number(bpm));
    println!(
        "Target range: {} - {}",
        format_number(min),
        format_number(max)
    );
    println!("Normalized BPM: {}", format_number(normalized));
    println!("Already in range: {}", if in_range { "yes" } else { "no" });

    Ok(())
}

fn parse_range_options(args: &[String]) -> Result<(f64, f64), String> {
    let mut min = 70.0;
    let mut max = 180.0;
    let mut index = 0;

    while index < args.len() {
        match args[index].as_str() {
            "--min" => {
                let value = args
                    .get(index + 1)
                    .ok_or_else(|| "missing value for --min".to_string())?;
                min = parse_number(value, "--min")?;
                index += 2;
            }
            "--max" => {
                let value = args
                    .get(index + 1)
                    .ok_or_else(|| "missing value for --max".to_string())?;
                max = parse_number(value, "--max")?;
                index += 2;
            }
            flag => return Err(format!("unknown option: {flag}")),
        }
    }

    Ok((min, max))
}

fn parse_numbers(values: &[String], label: &str) -> Result<Vec<f64>, String> {
    values
        .iter()
        .map(|value| parse_number(value, label))
        .collect::<Result<Vec<_>, _>>()
}

fn parse_number(value: &str, label: &str) -> Result<f64, String> {
    value
        .parse::<f64>()
        .map_err(|_| format!("invalid {label} value: {value}"))
}

fn format_error(error: TapTempoError) -> String {
    error.to_string()
}

fn format_list(values: &[f64]) -> String {
    values
        .iter()
        .map(|value| format_number(*value))
        .collect::<Vec<_>>()
        .join(", ")
}

fn format_number(value: f64) -> String {
    let rounded = (value * 1000.0).round() / 1000.0;
    if (rounded.fract()).abs() < f64::EPSILON {
        format!("{rounded:.0}")
    } else if ((rounded * 10.0).fract()).abs() < f64::EPSILON {
        format!("{rounded:.1}")
    } else if ((rounded * 100.0).fract()).abs() < f64::EPSILON {
        format!("{rounded:.2}")
    } else {
        format!("{rounded:.3}")
    }
}

fn usage() -> String {
    [
        "bpm-finder-tools",
        "",
        "Usage:",
        "  bpm-finder-tools file <path> [--min 70] [--max 180]",
        "  bpm-finder-tools tap <interval_ms...>",
        "  bpm-finder-tools ms <bpm>",
        "  bpm-finder-tools bpm <milliseconds>",
        "  bpm-finder-tools normalize <bpm> [--min 70] [--max 180]",
    ]
    .join("\n")
}