use super::BeatPosition;
use crate::error::AnalysisError;
const EPSILON: f32 = 1e-10;
const NUM_STATES: usize = 5;
const TIMING_TOLERANCE_S: f32 = 0.05;
const EMISSION_SIGMA: f32 = TIMING_TOLERANCE_S / 2.0;
#[derive(Debug)]
pub struct HmmBeatTracker {
pub bpm_estimate: f32,
pub onsets: Vec<f32>,
pub sample_rate: u32,
}
impl HmmBeatTracker {
pub fn new(bpm_estimate: f32, onsets: Vec<f32>, sample_rate: u32) -> Self {
Self {
bpm_estimate,
onsets,
sample_rate,
}
}
pub fn track_beats(&self) -> Result<Vec<BeatPosition>, AnalysisError> {
if self.bpm_estimate <= EPSILON || self.bpm_estimate > 300.0 {
return Err(AnalysisError::InvalidInput(
format!("Invalid BPM estimate: {:.2} (must be > 0 and <= 300)", self.bpm_estimate)
));
}
if self.onsets.is_empty() {
return Err(AnalysisError::InvalidInput(
"Cannot track beats: no onsets provided".to_string()
));
}
log::debug!("Tracking beats with HMM: BPM={:.2}, {} onsets",
self.bpm_estimate, self.onsets.len());
let state_bpms = self.build_state_space();
let transition_matrix = self.build_transition_matrix();
let emission_matrix = self.compute_emission_probabilities(&state_bpms)?;
let best_path = self.viterbi_forward_pass(&transition_matrix, &emission_matrix)?;
let beats = self.extract_beats_from_path(&best_path, &state_bpms, &emission_matrix)?;
log::debug!("HMM beat tracking complete: {} beats detected", beats.len());
Ok(beats)
}
fn build_state_space(&self) -> Vec<f32> {
let mut states = Vec::with_capacity(NUM_STATES);
let multipliers = [0.90, 0.95, 1.00, 1.05, 1.10];
for &mult in &multipliers {
states.push(self.bpm_estimate * mult);
}
states
}
fn build_transition_matrix(&self) -> Vec<Vec<f32>> {
let mut matrix = vec![vec![0.0; NUM_STATES]; NUM_STATES];
for i in 0..NUM_STATES {
for j in 0..NUM_STATES {
let distance = (i as i32 - j as i32).abs() as usize;
if distance == 0 {
matrix[i][j] = 0.7;
} else if distance == 1 {
matrix[i][j] = 0.15;
} else {
matrix[i][j] = 0.0;
}
}
}
for i in 0..NUM_STATES {
let sum: f32 = matrix[i].iter().sum();
if sum > EPSILON {
for j in 0..NUM_STATES {
matrix[i][j] /= sum;
}
}
}
matrix
}
fn compute_emission_probabilities(&self, state_bpms: &[f32]) -> Result<Vec<Vec<f32>>, AnalysisError> {
if self.onsets.is_empty() {
return Err(AnalysisError::InvalidInput("No onsets for emission computation".to_string()));
}
let start_time = self.onsets[0];
let end_time = self.onsets[self.onsets.len() - 1];
let beat_interval = 60.0 / self.bpm_estimate;
let num_frames = ((end_time - start_time) / beat_interval).ceil() as usize + 1;
if num_frames == 0 {
return Err(AnalysisError::ProcessingError(
"Cannot compute emissions: invalid time range".to_string()
));
}
let mut emission_matrix = vec![vec![0.0; NUM_STATES]; num_frames];
for t in 0..num_frames {
let frame_time = start_time + (t as f32 * beat_interval);
for s in 0..NUM_STATES {
let state_bpm = state_bpms[s];
let _state_beat_interval = 60.0 / state_bpm;
let expected_beat_time = frame_time;
let mut min_distance = f32::INFINITY;
for &onset_time in &self.onsets {
let distance = (onset_time - expected_beat_time).abs();
if distance < min_distance {
min_distance = distance;
}
}
let distance_sq = min_distance * min_distance;
let sigma_sq = EMISSION_SIGMA * EMISSION_SIGMA;
if sigma_sq <= EPSILON {
return Err(AnalysisError::NumericalError(
"Emission sigma too small".to_string()
));
}
let emission = (-distance_sq / (2.0 * sigma_sq)).exp();
emission_matrix[t][s] = emission;
}
}
Ok(emission_matrix)
}
fn viterbi_forward_pass(
&self,
transition_matrix: &[Vec<f32>],
emission_matrix: &[Vec<f32>],
) -> Result<Vec<usize>, AnalysisError> {
let num_frames = emission_matrix.len();
if num_frames == 0 {
return Err(AnalysisError::ProcessingError(
"Cannot run Viterbi: no time frames".to_string()
));
}
let mut viterbi = vec![vec![0.0; NUM_STATES]; num_frames];
let mut backpointer = vec![vec![0; NUM_STATES]; num_frames];
let initial_prob = 1.0 / NUM_STATES as f32;
for s in 0..NUM_STATES {
viterbi[0][s] = initial_prob * emission_matrix[0][s];
}
for t in 1..num_frames {
for s in 0..NUM_STATES {
let mut best_prob = 0.0;
let mut best_prev_state = 0;
for prev_s in 0..NUM_STATES {
let prob = viterbi[t - 1][prev_s] * transition_matrix[prev_s][s];
if prob > best_prob {
best_prob = prob;
best_prev_state = prev_s;
}
}
viterbi[t][s] = best_prob * emission_matrix[t][s];
backpointer[t][s] = best_prev_state;
}
}
let mut best_path = vec![0; num_frames];
let mut best_final_prob = 0.0;
let mut best_final_state = 0;
for s in 0..NUM_STATES {
if viterbi[num_frames - 1][s] > best_final_prob {
best_final_prob = viterbi[num_frames - 1][s];
best_final_state = s;
}
}
best_path[num_frames - 1] = best_final_state;
for t in (0..num_frames - 1).rev() {
best_path[t] = backpointer[t + 1][best_path[t + 1]];
}
Ok(best_path)
}
fn extract_beats_from_path(
&self,
best_path: &[usize],
_state_bpms: &[f32],
emission_matrix: &[Vec<f32>],
) -> Result<Vec<BeatPosition>, AnalysisError> {
if best_path.is_empty() || emission_matrix.is_empty() {
return Err(AnalysisError::ProcessingError(
"Cannot extract beats: empty path or emission matrix".to_string()
));
}
let start_time = self.onsets[0];
let beat_interval = 60.0 / self.bpm_estimate;
let emission_threshold = 0.1;
let mut beats = Vec::new();
for (t, &state) in best_path.iter().enumerate() {
let emission_prob = emission_matrix[t][state];
if emission_prob > emission_threshold {
let beat_time = start_time + (t as f32 * beat_interval);
let mut min_distance = f32::INFINITY;
for &onset_time in &self.onsets {
let distance = (onset_time - beat_time).abs();
if distance < min_distance {
min_distance = distance;
}
}
let alignment_score = if min_distance < TIMING_TOLERANCE_S {
1.0 - (min_distance / TIMING_TOLERANCE_S)
} else {
0.0
};
let confidence = (emission_prob * 0.7 + alignment_score * 0.3).min(1.0);
beats.push(BeatPosition {
beat_index: 0, time_seconds: beat_time,
confidence,
});
}
}
beats.sort_by(|a, b| a.time_seconds.partial_cmp(&b.time_seconds).unwrap());
Ok(beats)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hmm_tracker_creation() {
let tracker = HmmBeatTracker::new(120.0, vec![0.0, 0.5, 1.0], 44100);
assert_eq!(tracker.bpm_estimate, 120.0);
assert_eq!(tracker.onsets.len(), 3);
assert_eq!(tracker.sample_rate, 44100);
}
#[test]
fn test_build_state_space() {
let tracker = HmmBeatTracker::new(120.0, vec![0.0], 44100);
let states = tracker.build_state_space();
assert_eq!(states.len(), 5);
assert!((states[0] - 108.0).abs() < 0.1); assert!((states[1] - 114.0).abs() < 0.1); assert!((states[2] - 120.0).abs() < 0.1); assert!((states[3] - 126.0).abs() < 0.1); assert!((states[4] - 132.0).abs() < 0.1); }
#[test]
fn test_build_transition_matrix() {
let tracker = HmmBeatTracker::new(120.0, vec![0.0], 44100);
let matrix = tracker.build_transition_matrix();
assert_eq!(matrix.len(), 5);
assert_eq!(matrix[0].len(), 5);
for i in 0..5 {
assert!(matrix[i][i] > 0.6, "Self-transition should be high");
}
for i in 0..4 {
assert!(matrix[i][i + 1] > 0.1, "Adjacent transition should be medium");
assert!(matrix[i + 1][i] > 0.1, "Adjacent transition should be medium");
}
assert_eq!(matrix[0][4], 0.0);
assert_eq!(matrix[4][0], 0.0);
}
#[test]
fn test_compute_emission_probabilities() {
let onsets = vec![0.0, 0.5, 1.0, 1.5, 2.0]; let tracker = HmmBeatTracker::new(120.0, onsets.clone(), 44100);
let state_bpms = tracker.build_state_space();
let emissions = tracker.compute_emission_probabilities(&state_bpms).unwrap();
assert!(!emissions.is_empty());
assert_eq!(emissions[0].len(), 5);
for row in &emissions {
for &prob in row {
assert!(prob >= 0.0 && prob <= 1.0, "Emission probability should be in [0, 1]");
}
}
}
#[test]
fn test_track_beats_basic() {
let onsets = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
let tracker = HmmBeatTracker::new(120.0, onsets, 44100);
let beats = tracker.track_beats().unwrap();
assert!(!beats.is_empty(), "Should detect beats");
assert!(beats.len() >= 3, "Should detect at least 3 beats");
for i in 1..beats.len() {
assert!(beats[i].time_seconds > beats[i - 1].time_seconds,
"Beats should be sorted by time");
}
for beat in &beats {
assert!(beat.confidence >= 0.0 && beat.confidence <= 1.0,
"Confidence should be in [0, 1]");
}
}
#[test]
fn test_track_beats_128bpm() {
let beat_interval = 60.0 / 128.0;
let onsets: Vec<f32> = (0..6).map(|i| i as f32 * beat_interval).collect();
let tracker = HmmBeatTracker::new(128.0, onsets, 44100);
let beats = tracker.track_beats().unwrap();
assert!(!beats.is_empty());
if beats.len() >= 2 {
let interval = beats[1].time_seconds - beats[0].time_seconds;
let expected_interval = 60.0 / 128.0;
assert!((interval - expected_interval).abs() < 0.1,
"Beat interval should be close to expected");
}
}
#[test]
fn test_track_beats_invalid_bpm() {
let tracker = HmmBeatTracker::new(0.0, vec![0.0, 0.5], 44100);
assert!(tracker.track_beats().is_err());
let tracker = HmmBeatTracker::new(350.0, vec![0.0, 0.5], 44100);
assert!(tracker.track_beats().is_err());
}
#[test]
fn test_track_beats_empty_onsets() {
let tracker = HmmBeatTracker::new(120.0, vec![], 44100);
assert!(tracker.track_beats().is_err());
}
#[test]
fn test_track_beats_single_onset() {
let tracker = HmmBeatTracker::new(120.0, vec![0.5], 44100);
let result = tracker.track_beats();
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_viterbi_forward_pass() {
let onsets = vec![0.0, 0.5, 1.0, 1.5];
let tracker = HmmBeatTracker::new(120.0, onsets, 44100);
let state_bpms = tracker.build_state_space();
let transition_matrix = tracker.build_transition_matrix();
let emission_matrix = tracker.compute_emission_probabilities(&state_bpms).unwrap();
let best_path = tracker.viterbi_forward_pass(&transition_matrix, &emission_matrix).unwrap();
assert!(!best_path.is_empty());
assert_eq!(best_path.len(), emission_matrix.len());
for &state in &best_path {
assert!(state < 5, "State should be in range [0, 4]");
}
}
#[test]
fn test_extract_beats_from_path() {
let onsets = vec![0.0, 0.5, 1.0, 1.5, 2.0];
let tracker = HmmBeatTracker::new(120.0, onsets, 44100);
let state_bpms = tracker.build_state_space();
let transition_matrix = tracker.build_transition_matrix();
let emission_matrix = tracker.compute_emission_probabilities(&state_bpms).unwrap();
let best_path = tracker.viterbi_forward_pass(&transition_matrix, &emission_matrix).unwrap();
let beats = tracker.extract_beats_from_path(&best_path, &state_bpms, &emission_matrix).unwrap();
assert!(!beats.is_empty());
for i in 1..beats.len() {
assert!(beats[i].time_seconds > beats[i - 1].time_seconds);
}
}
}