use std::iter::Peekable;
use std::str::Chars;
use crate::models::{FullSyllableInfo, LineInfo, SyllableInfo, SyllableItem};
const ROW_TOLERANCE_MS: i32 = 1_000;
pub fn apply_word_timings(lines: &mut [LineInfo], word_lines: &[LineInfo]) {
let mut next_word_line = 0;
for line in lines.iter_mut() {
let Some(line_start) = line.start_time() else {
continue;
};
while word_lines.get(next_word_line).is_some_and(|word_line| {
word_timed_start(word_line).is_none_or(|start| start + ROW_TOLERANCE_MS < line_start)
}) {
next_word_line += 1;
}
if next_word_line == word_lines.len() {
return;
}
let Some((index, word_words)) = word_lines[next_word_line..]
.iter()
.enumerate()
.map_while(|(offset, word_line)| {
let start = word_timed_start(word_line);
(start.is_none_or(|start| start <= line_start + ROW_TOLERANCE_MS)).then_some((
next_word_line + offset,
word_line,
start,
))
})
.filter_map(|(index, word_line, start)| {
Some((index, word_line.syllables()?, start?.abs_diff(line_start)))
})
.min_by_key(|(_, _, distance)| *distance)
.map(|(index, words, _)| (index, words))
else {
continue;
};
let Some(syllables) = read_words_onto(&line.text_from_any(), word_words) else {
continue;
};
let owned = std::mem::replace(line, LineInfo::new_line_simple(String::new()));
*line = with_words(owned, syllables);
next_word_line = index + 1;
}
}
fn word_timed_start(word_line: &LineInfo) -> Option<i32> {
word_line
.syllables()
.filter(|words| !words.is_empty())
.and(word_line.start_time())
}
fn with_words(line: LineInfo, syllables: Vec<SyllableItem>) -> LineInfo {
match line {
LineInfo::FullLine {
start_time,
end_time,
alignment,
sub_line,
translations,
pronunciation,
..
}
| LineInfo::FullSyllable {
start_time,
end_time,
alignment,
sub_line,
translations,
pronunciation,
..
} => LineInfo::FullSyllable {
syllables,
start_time,
end_time,
alignment,
sub_line,
translations,
pronunciation,
},
LineInfo::Line {
start_time,
end_time,
alignment,
sub_line,
..
}
| LineInfo::Syllable {
start_time,
end_time,
alignment,
sub_line,
..
} => LineInfo::Syllable {
syllables,
start_time,
end_time,
alignment,
sub_line,
},
}
}
fn read_words_onto(text: &str, words: &[SyllableItem]) -> Option<Vec<SyllableItem>> {
let mut remaining = text.chars().peekable();
let mut aligned: Vec<SyllableItem> = Vec::with_capacity(words.len());
for word in words {
if word
.as_full()
.is_some_and(|full| full.sub_items().is_empty())
{
continue;
}
let mut taken = String::new();
while let Some(separator) = remaining.next_if(|character| !character.is_alphanumeric()) {
match aligned.last_mut() {
Some(previous) => push_character(previous, separator),
None => taken.push(separator),
}
}
take_word(characters_of(word), &mut remaining, &mut taken)?;
if taken.is_empty() && !word.is_full() {
continue;
}
aligned.push(match word {
SyllableItem::Full(full) => SyllableItem::Full(FullSyllableInfo::new(
split_onto_parts(&taken, full.sub_items())?,
)),
SyllableItem::Syllable(syllable) => SyllableItem::Syllable(SyllableInfo::new(
taken,
syllable.start_time,
syllable.end_time,
)),
});
}
for character in remaining {
if character.is_alphanumeric() {
return None;
}
push_character(aligned.last_mut()?, character);
}
(!aligned.is_empty()).then_some(aligned)
}
fn split_onto_parts(text: &str, parts: &[SyllableInfo]) -> Option<Vec<SyllableInfo>> {
let mut remaining = text.chars().peekable();
let mut aligned: Vec<SyllableInfo> = Vec::with_capacity(parts.len());
for part in parts {
let mut taken = String::new();
while let Some(separator) = remaining.next_if(|character| !character.is_alphanumeric()) {
match aligned.last_mut() {
Some(previous) => previous.text.push(separator),
None => taken.push(separator),
}
}
take_word(part.text.chars(), &mut remaining, &mut taken)?;
aligned.push(SyllableInfo::new(taken, part.start_time, part.end_time));
}
for character in remaining {
if character.is_alphanumeric() {
return None;
}
aligned.last_mut()?.text.push(character);
}
(!aligned.is_empty()).then_some(aligned)
}
fn characters_of(word: &SyllableItem) -> impl Iterator<Item = char> + Clone + '_ {
word.parts().iter().flat_map(|part| part.text.chars())
}
fn take_word(
characters: impl Iterator<Item = char> + Clone,
remaining: &mut Peekable<Chars>,
text: &mut String,
) -> Option<()> {
let mut expected = characters
.clone()
.filter(|character| character.is_alphanumeric());
let mut word_characters = characters
.filter(|character| character.is_alphanumeric())
.count();
while word_characters > 0 {
let character = remaining.next()?;
if character.is_alphanumeric() {
if !character.to_lowercase().eq(expected.next()?.to_lowercase()) {
return None;
}
word_characters -= 1;
}
text.push(character);
}
Some(())
}
fn push_character(word: &mut SyllableItem, character: char) {
match word {
SyllableItem::Syllable(syllable) => syllable.text.push(character),
SyllableItem::Full(full) => {
if let Some(sub_item) = full.sub_items_mut().last_mut() {
sub_item.text.push(character);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn row(start_time: i32, end_time: i32, text: &str) -> LineInfo {
LineInfo::new_line(text.to_string(), Some(start_time), Some(end_time))
}
fn word_row(start_time: i32, words: &[(i32, i32, &str)]) -> LineInfo {
let syllables = words
.iter()
.map(|(start, end, text)| SyllableInfo::new(text.to_string(), *start, *end).into())
.collect();
LineInfo::new_syllable_with_time(syllables, Some(start_time), None)
}
fn words_of(line: &LineInfo) -> Vec<(i32, String)> {
line.syllables()
.unwrap_or_default()
.iter()
.map(|syllable| (syllable.start_time(), syllable.text()))
.collect()
}
#[test]
fn reads_the_words_of_a_row_onto_the_text_of_the_transcription() {
let mut lines = vec![row(90, 2_160, "Ugh, you're a monster")];
let word_lines = vec![word_row(
90,
&[
(90, 420, "Ugh"),
(420, 690, "you're "),
(690, 960, "a "),
(960, 2_160, "monster"),
],
)];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(
words_of(&lines[0]),
vec![
(90, "Ugh, ".to_string()),
(420, "you're ".to_string()),
(690, "a ".to_string()),
(960, "monster".to_string()),
]
);
assert_eq!(
LineInfo::text_from_syllables(lines[0].syllables().unwrap()),
"Ugh, you're a monster"
);
assert_eq!(
(lines[0].start_time(), lines[0].end_time()),
(Some(90), Some(2_160))
);
}
#[test]
fn takes_the_characters_of_the_transcription() {
let mut lines = vec![row(0, 1_000, "Ugh! You're a monster")];
let word_lines = vec![word_row(0, &[(0, 1_000, "ughyou're a monster")])];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(
LineInfo::text_from_syllables(lines[0].syllables().unwrap()),
"Ugh! You're a monster"
);
}
#[test]
fn ignores_case_beyond_ascii() {
let mut lines = vec![row(0, 1_000, "Élan vital")];
let word_lines = vec![word_row(0, &[(0, 500, "élan "), (500, 1_000, "vital")])];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(
words_of(&lines[0]),
vec![(0, "Élan ".to_string()), (500, "vital".to_string())]
);
}
#[test]
fn keeps_a_row_whose_words_do_not_spell_it() {
let mut lines = vec![
row(90, 2_160, "Shady's in this bitch, I'm posse'd up"),
row(18_540, 20_900, "Consider it to cross me a costly mistake"),
];
let word_lines = vec![
word_row(
90,
&[
(90, 400, "Shady's "),
(400, 600, "in "),
(600, 800, "this "),
(800, 1_100, "*****"),
(1_100, 1_400, "I'm "),
(1_400, 2_160, "posse'd up"),
],
),
word_row(
18_540,
&[
(18_540, 18_700, "Consider "),
(18_700, 19_900, "it to cross me a costly mistake"),
],
),
];
apply_word_timings(&mut lines, &word_lines);
assert!(lines[0].syllables().is_none());
assert_eq!(lines[0].text(), "Shady's in this bitch, I'm posse'd up");
assert_eq!(
(lines[0].start_time(), lines[0].end_time()),
(Some(90), Some(2_160))
);
assert_eq!(
words_of(&lines[1]),
vec![
(18_540, "Consider ".to_string()),
(18_700, "it to cross me a costly mistake".to_string()),
]
);
}
#[test]
fn pairs_the_rows_in_order() {
let mut lines = vec![
row(396, 2_160, "Ugh, you're a monster"),
row(2_345, 3_000, "Second row"),
];
let word_lines = vec![
word_row(90, &[(90, 2_160, "Ughyou're a monster")]),
word_row(2_370, &[(2_370, 3_000, "Second row")]),
];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(words_of(&lines[0])[0].0, 90);
assert_eq!(words_of(&lines[1]), vec![(2_370, "Second row".to_string())]);
}
#[test]
fn a_row_that_does_not_align_leaves_the_close_rows_after_it_alone() {
let mut lines = vec![
row(0, 800, "f*** you"),
row(800, 1_600, "go away"),
row(1_600, 2_400, "right now"),
];
let word_lines = vec![
word_row(0, &[(0, 400, "fuck "), (400, 800, "you")]),
word_row(800, &[(800, 1_200, "go "), (1_200, 1_600, "away")]),
word_row(1_600, &[(1_600, 2_000, "right "), (2_000, 2_400, "now")]),
];
apply_word_timings(&mut lines, &word_lines);
assert!(lines[0].syllables().is_none());
assert_eq!(words_of(&lines[1])[0].0, 800);
assert_eq!(words_of(&lines[2])[0].0, 1_600);
}
#[test]
fn pairs_the_nearest_word_row_within_the_tolerance() {
let mut lines = vec![row(500, 1_000, "Second row")];
let word_lines = vec![
word_row(0, &[(0, 500, "First row")]),
word_row(520, &[(520, 1_000, "Second row")]),
];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(words_of(&lines[0])[0].0, 520);
}
#[test]
fn pairs_a_row_that_starts_within_the_tolerance() {
let mut lines = vec![row(ROW_TOLERANCE_MS, 2_000, "Ugh, you're a monster")];
let word_lines = vec![word_row(0, &[(0, 2_000, "Ughyou're a monster")])];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(words_of(&lines[0])[0].0, 0);
}
#[test]
fn leaves_a_row_that_starts_beyond_the_tolerance() {
let mut lines = vec![row(ROW_TOLERANCE_MS + 1, 2_000, "Ugh, you're a monster")];
let word_lines = vec![word_row(0, &[(0, 2_000, "Ughyou're a monster")])];
apply_word_timings(&mut lines, &word_lines);
assert!(lines[0].syllables().is_none());
assert_eq!(
(lines[0].start_time(), lines[0].end_time()),
(Some(ROW_TOLERANCE_MS + 1), Some(2_000))
);
}
#[test]
fn skips_word_rows_without_words() {
let mut lines = vec![row(1_050, 2_000, "Ugh, you're a monster")];
let info_line = LineInfo::new_line("作词: 某人".to_string(), Some(1_000), None);
let word_lines = vec![
info_line,
word_row(1_100, &[(1_100, 2_000, "Ughyou're a monster")]),
];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(words_of(&lines[0])[0].0, 1_100);
}
#[test]
fn keeps_merged_syllables_as_one_word_with_their_own_times() {
let mut lines = vec![row(0, 1_000, "Hello you're")];
let word_lines = vec![LineInfo::new_syllable_with_time(
vec![
SyllableInfo::new("Hello ".to_string(), 0, 400).into(),
FullSyllableInfo::new(vec![
SyllableInfo::new("you".to_string(), 400, 600),
SyllableInfo::new("'re".to_string(), 600, 900),
])
.into(),
],
Some(0),
None,
)];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(
words_of(&lines[0]),
vec![(0, "Hello ".to_string()), (400, "you're".to_string())]
);
let merged = &lines[0].syllables().unwrap()[1];
assert_eq!(
merged
.as_full()
.expect("合并音节不拆")
.sub_items()
.iter()
.map(|sub_item| (
sub_item.start_time,
sub_item.end_time,
sub_item.text.as_str()
))
.collect::<Vec<_>>(),
vec![(400, 600, "you'"), (600, 900, "re")]
);
assert_eq!(
LineInfo::text_from_syllables(lines[0].syllables().unwrap()),
"Hello you're"
);
}
#[test]
fn keeps_a_row_whose_merged_syllables_do_not_spell_it() {
let mut lines = vec![row(0, 1_000, "Hello world")];
let word_lines = vec![LineInfo::new_syllable_with_time(
vec![
SyllableInfo::new("Hello ".to_string(), 0, 400).into(),
FullSyllableInfo::new(vec![
SyllableInfo::new("wor".to_string(), 400, 700),
SyllableInfo::new("lds".to_string(), 700, 900),
])
.into(),
],
Some(0),
None,
)];
apply_word_timings(&mut lines, &word_lines);
assert!(lines[0].syllables().is_none());
assert_eq!(lines[0].text(), "Hello world");
}
#[test]
fn keeps_the_translation_of_a_full_row() {
let mut translations = HashMap::new();
translations.insert("zh".to_string(), "呕,你真是只怪兽".to_string());
let mut lines = vec![LineInfo::new_full_line(
"Ugh, you're a monster".to_string(),
Some(90),
Some(2_160),
translations,
None,
)];
let word_lines = vec![word_row(90, &[(90, 2_160, "Ughyou're a monster")])];
apply_word_timings(&mut lines, &word_lines);
assert_eq!(lines[0].chinese_translation(), Some("呕,你真是只怪兽"));
assert_eq!(
LineInfo::text_from_syllables(lines[0].syllables().unwrap()),
"Ugh, you're a monster"
);
}
#[test]
fn leaves_the_lines_alone_without_word_lines() {
let mut lines = vec![row(0, 1_000, "Ugh, you're a monster")];
apply_word_timings(&mut lines, &[]);
assert!(lines[0].syllables().is_none());
assert_eq!(lines[0].text(), "Ugh, you're a monster");
}
}