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")
}