bpm-finder-tools 0.1.0

Lightweight Rust utilities and CLI for audio-file BPM analysis, tap tempo, and tempo conversion.
Documentation
use assert_cmd::Command;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

fn bin() -> Command {
    Command::new(assert_cmd::cargo::cargo_bin!("bpm-finder-tools"))
}

fn run(args: &[&str]) -> (bool, String, String) {
    let output = bin().args(args).output().unwrap();
    (
        output.status.success(),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

fn temp_wav_path(name: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!("{name}-{nanos}.wav"))
}

fn write_click_track_wav(path: &Path, bpm: f64, sample_rate: u32, duration_seconds: f64) {
    let total_samples = (sample_rate as f64 * duration_seconds) as usize;
    let mut pcm_samples = vec![0i16; total_samples];
    let interval = (sample_rate as f64 * 60.0 / bpm) as usize;
    let pulse_len = 512usize;

    let mut beat_start = 0usize;
    while beat_start < total_samples {
        for offset in 0..pulse_len {
            let index = beat_start + offset;
            if index >= total_samples {
                break;
            }

            let decay = 1.0 - offset as f32 / pulse_len as f32;
            pcm_samples[index] = (decay * i16::MAX as f32 * 0.8) as i16;
        }

        beat_start += interval;
    }

    let bytes_per_sample = 2u16;
    let channels = 1u16;
    let block_align = channels * bytes_per_sample;
    let byte_rate = sample_rate * block_align as u32;
    let data_size = (pcm_samples.len() * bytes_per_sample as usize) as u32;
    let riff_size = 36 + data_size;

    let mut wav = Vec::with_capacity((44 + data_size) as usize);
    wav.extend_from_slice(b"RIFF");
    wav.extend_from_slice(&riff_size.to_le_bytes());
    wav.extend_from_slice(b"WAVE");
    wav.extend_from_slice(b"fmt ");
    wav.extend_from_slice(&16u32.to_le_bytes());
    wav.extend_from_slice(&1u16.to_le_bytes());
    wav.extend_from_slice(&channels.to_le_bytes());
    wav.extend_from_slice(&sample_rate.to_le_bytes());
    wav.extend_from_slice(&byte_rate.to_le_bytes());
    wav.extend_from_slice(&block_align.to_le_bytes());
    wav.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
    wav.extend_from_slice(b"data");
    wav.extend_from_slice(&data_size.to_le_bytes());

    for sample in pcm_samples {
        wav.extend_from_slice(&sample.to_le_bytes());
    }

    fs::write(path, wav).unwrap();
}

#[test]
fn file_command_detects_bpm_from_wav() {
    let path = temp_wav_path("bpm-finder-tools");
    write_click_track_wav(&path, 120.0, 44_100, 8.0);

    let path_string = path.to_string_lossy().into_owned();
    let (success, stdout, stderr) = run(&["file", &path_string]);

    let _ = fs::remove_file(&path);

    assert!(success, "{stderr}");
    assert!(stdout.contains("Detected BPM: 120"));
    assert!(stdout.contains("Normalized BPM: 120"));
}

#[test]
fn tap_command_outputs_expected_values() {
    let (success, stdout, _) = run(&["tap", "500", "480", "495", "505"]);
    assert!(success);
    assert!(stdout.contains("Average interval: 495 ms"));
    assert!(stdout.contains("Exact BPM: 121.212"));
    assert!(stdout.contains("Rounded BPM: 121"));
}

#[test]
fn ms_command_outputs_delay_table() {
    let (success, stdout, _) = run(&["ms", "128"]);
    assert!(success);
    assert!(stdout.contains("quarter"));
    assert!(stdout.contains("468.75"));
    assert!(stdout.contains("dotted quarter"));
}

#[test]
fn bpm_command_outputs_exact_and_rounded_bpm() {
    let (success, stdout, _) = run(&["bpm", "500"]);
    assert!(success);
    assert!(stdout.contains("Exact BPM: 120"));
    assert!(stdout.contains("Rounded BPM: 120"));
}

#[test]
fn normalize_command_outputs_normalized_bpm() {
    let (success, stdout, _) = run(&["normalize", "72", "--min", "90", "--max", "180"]);
    assert!(success);
    assert!(stdout.contains("Normalized BPM: 144"));
    assert!(stdout.contains("Already in range: no"));
}

#[test]
fn tap_command_fails_with_too_few_values() {
    let (success, _, stderr) = run(&["tap", "500"]);
    assert!(!success);
    assert!(stderr.contains("tap requires at least two interval values in milliseconds"));
}

#[test]
fn normalize_command_fails_for_invalid_range() {
    let (success, _, stderr) = run(&["normalize", "120", "--min", "180", "--max", "90"]);
    assert!(!success);
    assert!(stderr.contains("min BPM must be lower than max BPM"));
}

#[test]
fn ms_command_fails_for_invalid_number() {
    let (success, _, stderr) = run(&["ms", "fast"]);
    assert!(!success);
    assert!(stderr.contains("invalid BPM value: fast"));
}