use std::fmt::Write as _;
#[derive(Debug, Clone, PartialEq)]
pub struct AlignedWord {
pub word: String,
pub success: bool,
pub start_s: f64,
pub end_s: f64,
pub p_align: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AlignedLineWord {
pub text: String,
pub start_s: f64,
pub end_s: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AlignedLine {
pub text: String,
pub start_s: f64,
pub end_s: f64,
pub section: String,
pub words: Vec<AlignedLineWord>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AlignedLyrics {
pub words: Vec<AlignedWord>,
pub lines: Vec<AlignedLine>,
pub waveform_data: Vec<f64>,
pub hoot_cer: Option<f64>,
pub is_streamed: Option<bool>,
}
impl AlignedLyrics {
pub fn is_empty(&self) -> bool {
self.lines.is_empty() && self.words.is_empty()
}
pub fn plain_text(&self) -> String {
if !self.lines.is_empty() {
return self
.lines
.iter()
.map(|line| line.text.trim_end())
.collect::<Vec<_>>()
.join("\n");
}
self.words
.iter()
.map(|word| word.word.as_str())
.collect::<Vec<_>>()
.join(" ")
}
pub fn lrc_body(&self) -> String {
let mut out = String::new();
for line in &self.lines {
if is_section_label(line) {
continue;
}
let text = if line.text.trim().is_empty() {
line.words
.iter()
.map(|w| w.text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ")
} else {
line.text.trim().to_owned()
};
let _ = writeln!(out, "[{}]{text}", lrc_stamp(line.start_s));
}
out
}
pub fn sylt_entries(&self) -> Vec<(u32, String)> {
let mut entries = Vec::new();
let mut first_emitted = true;
for line in &self.lines {
if is_section_label(line) {
continue;
}
let words: Vec<&AlignedLineWord> = line
.words
.iter()
.filter(|w| !w.text.trim().is_empty())
.collect();
let prefix = if first_emitted { "" } else { "\n" };
if words.is_empty() {
let text = line.text.trim();
if !text.is_empty() {
entries.push((to_ms(line.start_s), format!("{prefix}{text}")));
first_emitted = false;
}
continue;
}
for (word_index, word) in words.iter().enumerate() {
let text = word.text.trim();
let segment = if word_index == 0 {
format!("{prefix}{text}")
} else {
format!(" {text}")
};
entries.push((to_ms(word.start_s), segment));
}
first_emitted = false;
}
entries
}
}
fn is_section_label(line: &AlignedLine) -> bool {
let text = line.text.trim();
text.len() >= 2 && text.starts_with('[') && text.ends_with(']') && line.words.is_empty()
}
fn to_ms(secs: f64) -> u32 {
if !secs.is_finite() || secs <= 0.0 {
return 0;
}
(secs * 1000.0).round() as u32
}
fn lrc_stamp(secs: f64) -> String {
let cs = centiseconds(secs);
format!("{:02}:{:02}.{:02}", cs / 6000, (cs / 100) % 60, cs % 100)
}
fn centiseconds(secs: f64) -> u64 {
if !secs.is_finite() || secs <= 0.0 {
return 0;
}
(secs * 100.0).round() as u64
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_aligned() -> AlignedLyrics {
AlignedLyrics {
words: vec![
AlignedWord {
word: "Hello".to_owned(),
success: true,
start_s: 0.5,
end_s: 0.9,
p_align: 0.99,
},
AlignedWord {
word: "world".to_owned(),
success: true,
start_s: 1.0,
end_s: 1.4,
p_align: 0.98,
},
AlignedWord {
word: "again".to_owned(),
success: true,
start_s: 61.2,
end_s: 61.8,
p_align: 0.97,
},
],
lines: vec![
AlignedLine {
text: "Hello world".to_owned(),
start_s: 0.5,
end_s: 1.4,
section: "Verse 1".to_owned(),
words: vec![
AlignedLineWord {
text: "Hello".to_owned(),
start_s: 0.5,
end_s: 0.9,
},
AlignedLineWord {
text: "world".to_owned(),
start_s: 1.0,
end_s: 1.4,
},
],
},
AlignedLine {
text: "[Chorus]".to_owned(),
start_s: 60.0,
end_s: 60.0,
section: "Chorus".to_owned(),
words: vec![],
},
AlignedLine {
text: "again".to_owned(),
start_s: 61.2,
end_s: 61.8,
section: "Chorus".to_owned(),
words: vec![AlignedLineWord {
text: "again".to_owned(),
start_s: 61.2,
end_s: 61.8,
}],
},
],
..Default::default()
}
}
#[test]
fn lrc_body_omits_section_labels() {
let aligned = sample_aligned();
let body = aligned.lrc_body();
let expected = "[00:00.50]Hello world\n\
[01:01.20]again\n";
assert_eq!(body, expected);
assert!(!body.contains("[Chorus]"), "no timed section label");
}
#[test]
fn plain_text_retains_section_labels() {
let aligned = sample_aligned();
assert_eq!(aligned.plain_text(), "Hello world\n[Chorus]\nagain");
}
#[test]
fn sylt_entries_are_word_level_with_line_breaks() {
let aligned = sample_aligned();
let entries = aligned.sylt_entries();
assert_eq!(
entries,
vec![
(500, "Hello".to_owned()),
(1000, " world".to_owned()),
(61200, "\nagain".to_owned()),
]
);
}
#[test]
fn stamps_round_and_do_not_wrap_minutes() {
assert_eq!(lrc_stamp(61.2), "01:01.20");
assert_eq!(lrc_stamp(3661.0), "61:01.00");
assert_eq!(to_ms(1.2346), 1235);
assert_eq!(to_ms(-1.0), 0);
}
fn word(text: &str, start_s: f64, end_s: f64) -> AlignedLineWord {
AlignedLineWord {
text: text.to_owned(),
start_s,
end_s,
}
}
fn line(text: &str, start_s: f64, words: Vec<AlignedLineWord>) -> AlignedLine {
let end_s = words.last().map(|w| w.end_s).unwrap_or(start_s);
AlignedLine {
text: text.to_owned(),
start_s,
end_s,
section: String::new(),
words,
}
}
fn lyrics(lines: Vec<AlignedLine>) -> AlignedLyrics {
AlignedLyrics {
lines,
..Default::default()
}
}
#[test]
fn is_section_label_needs_both_brackets_and_no_words() {
assert!(is_section_label(&line("[Chorus]", 1.0, vec![])));
assert!(is_section_label(&line("[Verse 1]", 1.0, vec![])));
assert!(is_section_label(&line(" [Bridge] ", 1.0, vec![])));
assert!(!is_section_label(&line(
"[laughs]",
1.0,
vec![word("[laughs]", 1.0, 1.5)]
)));
assert!(!is_section_label(&line("I said [stop]", 1.0, vec![])));
assert!(!is_section_label(&line("[Chorus] Oh baby", 1.0, vec![])));
assert!(!is_section_label(&line("Hello", 1.0, vec![])));
assert!(!is_section_label(&line("[", 1.0, vec![])));
assert!(!is_section_label(&line("", 1.0, vec![])));
}
#[test]
fn sylt_label_at_index_zero_has_no_leading_newline() {
let aligned = lyrics(vec![
line("[Intro]", 0.0, vec![]),
line(
"first line",
1.0,
vec![word("first", 1.0, 1.4), word("line", 1.5, 1.9)],
),
]);
assert_eq!(
aligned.sylt_entries(),
vec![(1000, "first".to_owned()), (1500, " line".to_owned())]
);
}
#[test]
fn label_as_last_line_adds_no_trailing_entry() {
let aligned = lyrics(vec![
line(
"only line",
1.0,
vec![word("only", 1.0, 1.4), word("line", 1.5, 1.9)],
),
line("[Outro]", 5.0, vec![]),
]);
assert_eq!(aligned.lrc_body(), "[00:01.00]only line\n");
assert_eq!(
aligned.sylt_entries(),
vec![(1000, "only".to_owned()), (1500, " line".to_owned())]
);
}
#[test]
fn consecutive_labels_yield_exactly_one_newline() {
let aligned = lyrics(vec![
line(
"line one",
1.0,
vec![word("line", 1.0, 1.3), word("one", 1.4, 1.7)],
),
line("[Chorus]", 2.0, vec![]),
line("[Verse 2]", 2.5, vec![]),
line(
"line two",
3.0,
vec![word("line", 3.0, 3.3), word("two", 3.4, 3.7)],
),
]);
assert_eq!(
aligned.sylt_entries(),
vec![
(1000, "line".to_owned()),
(1400, " one".to_owned()),
(3000, "\nline".to_owned()),
(3400, " two".to_owned()),
]
);
assert_eq!(
aligned.lrc_body(),
"[00:01.00]line one\n[00:03.00]line two\n"
);
}
#[test]
fn bracketed_ad_lib_with_words_is_kept() {
let aligned = lyrics(vec![line(
"[laughs]",
2.0,
vec![word("[laughs]", 2.0, 2.6)],
)]);
assert_eq!(aligned.lrc_body(), "[00:02.00][laughs]\n");
assert_eq!(aligned.sylt_entries(), vec![(2000, "[laughs]".to_owned())]);
}
}