#![allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LyricsWord {
pub text: String,
pub start_ms: u32,
pub end_ms: u32,
}
impl LyricsWord {
#[must_use]
pub fn duration_ms(&self) -> u32 {
self.end_ms.saturating_sub(self.start_ms)
}
}
impl std::fmt::Display for LyricsWord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}[{}ms–{}ms]", self.text, self.start_ms, self.end_ms)
}
}
#[must_use]
pub fn align_lyrics(lyrics: &str, onsets_ms: &[u32]) -> Vec<LyricsWord> {
let words: Vec<&str> = lyrics.split_whitespace().collect();
if words.is_empty() {
return Vec::new();
}
let n_words = words.len();
let n_onsets = onsets_ms.len();
let start_times: Vec<u32> = if n_onsets == 0 {
vec![0u32; n_words]
} else {
assign_onsets_greedy(&words, onsets_ms)
};
let mut result: Vec<LyricsWord> = Vec::with_capacity(n_words);
for (idx, &word) in words.iter().enumerate() {
let start = start_times[idx];
let end = if idx + 1 < n_words {
let next_start = start_times[idx + 1];
if next_start > start {
next_start
} else {
start + 500
}
} else {
start + 500
};
result.push(LyricsWord {
text: word.to_string(),
start_ms: start,
end_ms: end,
});
}
result
}
fn assign_onsets_greedy(words: &[&str], onsets_ms: &[u32]) -> Vec<u32> {
let n_words = words.len();
let n_onsets = onsets_ms.len();
(0..n_words)
.map(|word_idx| {
let onset_idx = word_idx.min(n_onsets - 1);
onsets_ms[onset_idx]
})
.collect()
}
#[must_use]
pub fn split_lines(lyrics: &str) -> Vec<&str> {
lyrics.lines().collect()
}
#[must_use]
pub fn total_duration_ms(words: &[LyricsWord]) -> u32 {
words
.iter()
.map(|w| w.end_ms)
.fold(0u32, |acc, end| acc.max(end))
}
#[must_use]
pub fn words_in_range(words: &[LyricsWord], from_ms: u32, to_ms: u32) -> Vec<&LyricsWord> {
words
.iter()
.filter(|w| w.start_ms < to_ms && w.end_ms > from_ms)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_align_empty_lyrics() {
let result = align_lyrics("", &[0, 500, 1000]);
assert!(result.is_empty());
}
#[test]
fn test_align_empty_onsets() {
let result = align_lyrics("hello world", &[]);
assert_eq!(result.len(), 2);
assert_eq!(result[0].start_ms, 0);
assert_eq!(result[1].start_ms, 0);
assert_eq!(result[0].end_ms, 500);
assert_eq!(result[1].end_ms, 500);
}
#[test]
fn test_align_three_words_three_onsets() {
let lyrics = "Hello world goodbye";
let onsets = [0u32, 500, 1000];
let words = align_lyrics(lyrics, &onsets);
assert_eq!(words.len(), 3);
assert_eq!(words[0].text, "Hello");
assert_eq!(words[0].start_ms, 0);
assert_eq!(words[0].end_ms, 500);
assert_eq!(words[1].text, "world");
assert_eq!(words[1].start_ms, 500);
assert_eq!(words[1].end_ms, 1000);
assert_eq!(words[2].text, "goodbye");
assert_eq!(words[2].start_ms, 1000);
assert_eq!(words[2].end_ms, 1500); }
#[test]
fn test_align_more_words_than_onsets() {
let lyrics = "one two three four five";
let onsets = [100u32, 200, 300]; let words = align_lyrics(lyrics, &onsets);
assert_eq!(words.len(), 5);
assert_eq!(words[0].start_ms, 100);
assert_eq!(words[1].start_ms, 200);
assert_eq!(words[2].start_ms, 300);
assert_eq!(words[3].start_ms, 300);
assert_eq!(words[4].start_ms, 300);
}
#[test]
fn test_align_more_onsets_than_words() {
let lyrics = "quick brown";
let onsets = [0u32, 100, 200, 300, 400]; let words = align_lyrics(lyrics, &onsets);
assert_eq!(words.len(), 2);
assert_eq!(words[0].start_ms, 0);
assert_eq!(words[1].start_ms, 100);
}
#[test]
fn test_align_single_word_single_onset() {
let words = align_lyrics("only", &[750]);
assert_eq!(words.len(), 1);
assert_eq!(words[0].text, "only");
assert_eq!(words[0].start_ms, 750);
assert_eq!(words[0].end_ms, 1250); }
#[test]
fn test_align_word_duration_ms() {
let words = align_lyrics("a b", &[0, 300]);
assert_eq!(words[0].duration_ms(), 300); assert_eq!(words[1].duration_ms(), 500); }
#[test]
fn test_align_whitespace_lyrics() {
let result = align_lyrics(" \t \n ", &[0, 100]);
assert!(result.is_empty());
}
#[test]
fn test_lyrics_word_display() {
let w = LyricsWord {
text: "hello".to_string(),
start_ms: 100,
end_ms: 400,
};
let s = format!("{w}");
assert!(s.contains("hello"));
assert!(s.contains("100ms"));
assert!(s.contains("400ms"));
}
#[test]
fn test_lyrics_word_duration_saturating() {
let w = LyricsWord {
text: "x".to_string(),
start_ms: 500,
end_ms: 300,
};
assert_eq!(w.duration_ms(), 0);
}
#[test]
fn test_total_duration_ms() {
let words = align_lyrics("a b c", &[0, 200, 400]);
let total = total_duration_ms(&words);
assert_eq!(total, 900);
}
#[test]
fn test_words_in_range() {
let words = align_lyrics("a b c d", &[0, 100, 200, 300]);
let in_range = words_in_range(&words, 50, 250);
let texts: Vec<&str> = in_range.iter().map(|w| w.text.as_str()).collect();
assert!(texts.contains(&"b"));
assert!(texts.contains(&"c"));
}
#[test]
fn test_split_lines_preserves_empty() {
let lyrics = "line one\n\nline three";
let lines = split_lines(lyrics);
assert_eq!(lines.len(), 3);
assert_eq!(lines[1], "");
}
#[test]
fn test_align_multiline_lyrics() {
let lyrics = "line one\nline two";
let onsets = [0u32, 200, 400, 600];
let words = align_lyrics(lyrics, &onsets);
assert_eq!(words.len(), 4);
assert_eq!(words[0].text, "line");
assert_eq!(words[1].text, "one");
}
}