use crate::helpers::string_helper::{
collapse_whitespace, is_chinese_or_japanese_character, remove_front_back_brackets,
};
use crate::models::{LineInfo, SyllableInfo, SyllableItem};
use super::syllable_word_merger;
use super::utf16::{
is_letter_unit, is_lower_unit, is_upper_unit, to_upper_invariant_unit, utf16_slice,
};
pub fn prepare_lyrics(lines: &mut [LineInfo]) {
for line in lines.iter_mut() {
prepare_lyrics_line(line);
}
}
pub fn prepare_lyrics_line(line: &mut LineInfo) {
prepare_syllable_line(line);
if let Some(mut sub) = line.take_sub_line() {
prepare_syllable_line(&mut sub);
remove_redundant_translations(line, false);
remove_redundant_translations(&mut sub, true);
line.set_sub_line(Some(sub));
} else {
remove_redundant_translations(line, false);
}
}
fn prepare_syllable_line(line: &mut LineInfo) {
let Some(syllables) = line.syllables_mut() else {
return;
};
if syllables.is_empty() {
return;
}
trim_boundary_whitespaces(syllables);
let mut expanded: Vec<SyllableItem> = Vec::with_capacity(syllables.len() * 2);
for syllable in syllables.drain(..) {
let text = normalize_text(&syllable.text());
let start_time = syllable.start_time();
let end_time = syllable.end_time();
if text.is_empty() {
expanded.push(SyllableItem::Syllable(SyllableInfo::new(
String::new(),
start_time,
end_time,
)));
continue;
}
if should_split_token(&text) {
expanded.extend(
split_into_tokens(&text, start_time, end_time)
.into_iter()
.map(SyllableItem::Syllable),
);
} else {
expanded.push(SyllableItem::Syllable(SyllableInfo::new(
text, start_time, end_time,
)));
}
}
*syllables = expanded;
syllable_word_merger::merge(line);
}
fn remove_redundant_translations(line: &mut LineInfo, is_background: bool) {
let original = line.text_from_any();
let original_norm = normalize_for_compare(&original, is_background);
let Some(translations) = line.translations_mut() else {
return;
};
if translations.is_empty() {
return;
}
let mut to_remove: Vec<String> = Vec::new();
for (key, value) in translations.iter() {
let trans_norm = normalize_for_compare(value, is_background);
if original_norm == trans_norm {
to_remove.push(key.clone());
}
}
for key in to_remove {
translations.remove(&key);
}
}
fn normalize_for_compare(s: &str, is_background: bool) -> String {
let s = s.replace(['\r', '\n'], "");
let s = collapse_whitespace(&s);
if !is_background {
return s;
}
collapse_whitespace(&remove_front_back_brackets(&s))
}
fn trim_boundary_whitespaces(syllables: &mut [SyllableItem]) {
if let Some(first) = syllables.first_mut() {
trim_boundary_safe(first, Edge::Start);
}
if let Some(last) = syllables.last_mut() {
trim_boundary_safe(last, Edge::End);
}
}
#[derive(Clone, Copy)]
enum Edge {
Start,
End,
}
fn trim_boundary_safe(syllable: &mut SyllableItem, edge: Edge) {
fn trim(text: &str, edge: Edge) -> &str {
match edge {
Edge::Start => text.trim_start(),
Edge::End => text.trim_end(),
}
}
match syllable {
SyllableItem::Syllable(si) => {
si.text = trim(&si.text, edge).to_string();
}
SyllableItem::Full(fi) => {
let sub_items = fi.sub_items_mut();
let edge_item = match edge {
Edge::Start => sub_items.first_mut(),
Edge::End => sub_items.last_mut(),
};
if let Some(item) = edge_item {
item.text = trim(&item.text, edge).to_string();
}
}
}
}
fn should_split_token(text: &str) -> bool {
let mut zhja = 0;
let mut has_latin = false;
let mut has_space = false;
let mut has_hyphen = false;
for ch in text.chars() {
if ch == ' ' {
has_space = true;
}
if ch == '-' {
has_hyphen = true;
}
if is_chinese_or_japanese_character(ch) {
zhja += 1;
} else if is_latin_word_char(ch) {
has_latin = true;
}
}
zhja >= 2 || (zhja >= 1 && has_latin) || (has_latin && (has_space || has_hyphen))
}
fn split_into_tokens(text: &str, start_time: i32, end_time: i32) -> Vec<SyllableInfo> {
let tokens = tokenize_mixed(text);
if tokens.len() <= 1 {
return vec![SyllableInfo::new(text.to_string(), start_time, end_time)];
}
let weights: Vec<i32> = tokens.iter().map(|t| token_weight(t)).collect();
let w_sum: i32 = weights.iter().sum();
let total = end_time - start_time;
if total <= 0 {
return tokens
.into_iter()
.map(|t| SyllableInfo::new(t, start_time, end_time))
.collect();
}
let mut result = Vec::with_capacity(tokens.len());
let mut allocated = 0;
let mut cur_start = start_time;
for (i, token) in tokens.into_iter().enumerate() {
let mut dur;
if i == weights.len() - 1 {
dur = total - allocated;
} else {
dur = (total as f64 * (weights[i] as f64 / w_sum as f64)).round() as i32;
if dur < 1 {
dur = 1;
}
let min_remain = (weights.len() - 1 - i) as i32;
if allocated + dur > total - min_remain {
dur = total - allocated - min_remain;
}
}
let cur_end = cur_start + dur;
allocated += dur;
result.push(SyllableInfo::new(token, cur_start, cur_end));
cur_start = cur_end;
}
result
}
fn tokenize_mixed(text: &str) -> Vec<String> {
let mut tokens: Vec<String> = Vec::new();
let mut sb = String::new();
fn flush(sb: &mut String, tokens: &mut Vec<String>) {
if !sb.is_empty() {
tokens.push(std::mem::take(sb));
}
}
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == '\r' || ch == '\n' {
i += 1;
continue;
}
if is_chinese_or_japanese_character(ch) {
flush(&mut sb, &mut tokens);
tokens.push(ch.to_string());
i += 1;
continue;
}
if ch == ' ' || ch == '-' {
if !sb.is_empty() {
sb.push(ch);
flush(&mut sb, &mut tokens);
} else if let Some(last) = tokens.last_mut() {
last.push(ch);
} else {
sb.push(ch);
}
i += 1;
continue;
}
if is_latin_word_char(ch) {
sb.push(ch);
i += 1;
while i < chars.len() {
let c2 = chars[i];
if c2 == '\r' || c2 == '\n' {
i += 1;
continue;
}
if is_latin_word_char(c2) {
sb.push(c2);
i += 1;
continue;
}
break;
}
continue;
}
if !sb.is_empty() {
sb.push(ch);
} else if let Some(last) = tokens.last_mut() {
last.push(ch);
} else {
sb.push(ch);
}
i += 1;
}
flush(&mut sb, &mut tokens);
tokens.retain(|t| !t.is_empty());
tokens
}
fn token_weight(token: &str) -> i32 {
if token.chars().any(is_chinese_or_japanese_character) {
return 1;
}
let mut weight = 0;
for ch in token.chars() {
if is_letter_or_digit(ch) {
weight += 1;
}
}
weight.max(1)
}
fn is_letter_or_digit(ch: char) -> bool {
ch.len_utf16() == 1 && ch.is_alphanumeric()
}
fn is_latin_word_char(ch: char) -> bool {
if is_letter_or_digit(ch) {
return true;
}
ch == '\'' || ch == '’'
}
fn normalize_text(text: &str) -> String {
text.replace(['\r', '\n'], "")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineCaseKind {
NoLettersOrMixed,
AllLower,
AllUpper,
}
#[derive(Default)]
struct CaseCounts {
lower: usize,
upper: usize,
other: usize,
}
impl CaseCounts {
fn count_line(&mut self, text: &str) {
match get_line_case_kind(text) {
LineCaseKind::AllLower => self.lower += 1,
LineCaseKind::AllUpper => self.upper += 1,
LineCaseKind::NoLettersOrMixed => self.other += 1,
}
}
}
pub fn capitalization_normalization(lines: &mut [LineInfo]) {
let mut counts = CaseCounts::default();
for line in lines.iter() {
counts.count_line(&line.text_from_any());
if let Some(sub) = line.sub_line() {
counts.count_line(&sub.text_from_any());
}
}
let total_lines = counts.lower + counts.upper + counts.other;
if total_lines == 0 {
return;
}
let lower_ratio = counts.lower as f64 / total_lines as f64;
let upper_ratio = counts.upper as f64 / total_lines as f64;
const THRESHOLD: f64 = 0.9;
let do_lower_case_first = if upper_ratio >= THRESHOLD {
true
} else if lower_ratio >= THRESHOLD {
false
} else {
return;
};
for line in lines.iter_mut() {
apply_capitalization(line, do_lower_case_first);
}
}
fn get_line_case_kind(s: &str) -> LineCaseKind {
let mut has_upper = false;
let mut has_lower = false;
for unit in s.encode_utf16() {
if is_upper_unit(unit) {
has_upper = true;
} else if is_lower_unit(unit) {
has_lower = true;
} else {
continue;
}
if has_upper && has_lower {
return LineCaseKind::NoLettersOrMixed;
}
}
match (has_upper, has_lower) {
(true, false) => LineCaseKind::AllUpper,
(false, true) => LineCaseKind::AllLower,
_ => LineCaseKind::NoLettersOrMixed,
}
}
fn apply_capitalization(line: &mut LineInfo, lower_first: bool) {
apply_capitalization_main_only(line, lower_first);
if let Some(mut sub) = line.take_sub_line() {
apply_capitalization_main_only(&mut sub, lower_first);
line.set_sub_line(Some(sub));
}
}
fn apply_capitalization_main_only(line: &mut LineInfo, lower_first: bool) {
if line
.syllables()
.is_some_and(|syllables| !syllables.is_empty())
{
apply_capitalization_to_syllables(line, lower_first);
return;
}
if line.text().is_empty() {
return;
}
let normalized = normalize_sentence_case_preserve_length(line.text(), lower_first);
match line {
LineInfo::Line { text, .. } | LineInfo::FullLine { text, .. } => *text = normalized,
LineInfo::Syllable { .. } | LineInfo::FullSyllable { .. } => {}
}
}
fn apply_capitalization_to_syllables(line: &mut LineInfo, lower_first: bool) {
let Some(syllables) = line.syllables_mut() else {
return;
};
if syllables.is_empty() {
return;
}
let full: String = syllables.iter().map(|s| s.text()).collect();
if full.is_empty() {
return;
}
let normalized = normalize_sentence_case_preserve_length(&full, lower_first);
let normalized_units: Vec<u16> = normalized.encode_utf16().collect();
let mut pos = 0;
for syllable in syllables.iter_mut() {
let old_text = syllable.text();
let len = old_text.encode_utf16().count();
if len == 0 {
continue;
}
if pos + len > normalized_units.len() {
break;
}
let part = utf16_slice(&normalized_units, pos, len);
pos += len;
rewrite_syllable_text_preserve_structure(syllable, &part);
}
}
fn rewrite_syllable_text_preserve_structure(syllable: &mut SyllableItem, new_text: &str) {
match syllable {
SyllableItem::Syllable(si) => si.text = new_text.to_string(),
SyllableItem::Full(fi) => {
if fi.sub_items().is_empty() {
let (start_time, end_time) = (fi.start_time(), fi.end_time());
*syllable = SyllableItem::Syllable(SyllableInfo::new(
new_text.to_string(),
start_time,
end_time,
));
return;
}
let new_units: Vec<u16> = new_text.encode_utf16().collect();
let mut pos = 0;
for item in fi.sub_items_mut() {
let len = item.text.encode_utf16().count();
if len == 0 {
continue;
}
if pos + len > new_units.len() {
break;
}
item.text = utf16_slice(&new_units, pos, len);
pos += len;
}
}
}
}
fn normalize_sentence_case_preserve_length(s: &str, lower_first: bool) -> String {
let src = if lower_first {
s.to_lowercase()
} else {
s.to_string()
};
let units: Vec<u16> = src.encode_utf16().collect();
let mut out: Vec<u16> = Vec::with_capacity(units.len());
let mut cap_next = true;
for (i, unit) in units.iter().copied().enumerate() {
if unit == 'i' as u16 && is_standalone_i(&units, i) {
out.push('I' as u16);
cap_next = false;
continue;
}
if is_letter_unit(unit) {
if cap_next {
out.push(to_upper_invariant_unit(unit));
cap_next = false;
} else {
out.push(unit);
}
} else {
out.push(unit);
}
if unit == '.' as u16
|| unit == '?' as u16
|| unit == '!' as u16
|| unit == '。' as u16
|| unit == '?' as u16
|| unit == '!' as u16
{
cap_next = true;
}
}
String::from_utf16_lossy(&out)
}
fn is_standalone_i(units: &[u16], index: usize) -> bool {
let left_ok = index == 0 || !is_letter_unit(units[index - 1]);
let right_ok = index == units.len() - 1 || !is_letter_unit(units[index + 1]);
left_ok && right_ok
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::models::{FullSyllableInfo, LineInfo, SyllableInfo, SyllableItem};
use super::*;
fn syllable(text: &str, start_time: i32, end_time: i32) -> SyllableItem {
SyllableItem::Syllable(SyllableInfo::new(text.to_string(), start_time, end_time))
}
#[test]
fn merges_latin_syllables_but_not_cjk() {
let mut line =
LineInfo::new_syllable(vec![syllable("Hel", 0, 100), syllable("lo", 100, 200)]);
prepare_lyrics_line(&mut line);
let items = line.syllables().unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].is_full());
assert_eq!(items[0].text(), "Hello");
assert_eq!(items[0].parts().len(), 2);
assert_eq!(items[0].parts()[0].text, "Hel");
assert_eq!(items[0].parts()[0].start_time, 0);
assert_eq!(items[0].parts()[1].text, "lo");
assert_eq!(items[0].parts()[1].end_time, 200);
let mut cjk =
LineInfo::new_syllable(vec![syllable("你", 0, 100), syllable("好", 100, 200)]);
prepare_lyrics_line(&mut cjk);
assert_eq!(cjk.syllables().unwrap().len(), 2);
}
#[test]
fn prepare_lyrics_trims_boundaries_and_drops_redundant_translations() {
let sub = LineInfo::new_full_line(
"(Hello)".to_string(),
None,
None,
HashMap::from([("zh".to_string(), "Hello".to_string())]),
None,
);
let mut line = LineInfo::new_full_syllable(
vec![syllable(" Hel", 0, 100), syllable("lo ", 100, 200)],
HashMap::from([
("zh".to_string(), "Hello".to_string()),
("en".to_string(), "你好".to_string()),
]),
None,
);
line.set_sub_line(Some(Box::new(sub)));
let mut lines = vec![line];
prepare_lyrics(&mut lines);
let line = &lines[0];
assert_eq!(line.text_from_any(), "Hello");
assert_eq!(line.translations().unwrap().len(), 1);
assert_eq!(line.translations().unwrap().get("en").unwrap(), "你好");
let sub = line.sub_line().unwrap();
assert!(sub.translations().unwrap().is_empty());
assert_eq!(sub.text_from_any(), "(Hello)");
}
#[test]
fn capitalization_normalization_lowercases_all_caps_and_keeps_standalone_i() {
let mut lines = vec![
LineInfo::new_line_simple("HELLO WORLD".to_string()),
LineInfo::new_line_simple("I AM HERE".to_string()),
];
capitalization_normalization(&mut lines);
assert_eq!(lines[0].text(), "Hello world");
assert_eq!(lines[1].text(), "I am here");
}
#[test]
fn capitalization_normalization_rewrites_syllables_preserving_structure() {
let mut lines = vec![LineInfo::new_syllable(vec![
syllable("HE", 0, 100),
syllable("LLO", 100, 200),
])];
capitalization_normalization(&mut lines);
let items = lines[0].syllables().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].text(), "He");
assert_eq!(items[1].text(), "llo");
let mut lines = vec![LineInfo::new_syllable(vec![SyllableItem::Full(
FullSyllableInfo::new(vec![
SyllableInfo::new("HE".to_string(), 0, 100),
SyllableInfo::new("LLO".to_string(), 100, 200),
]),
)])];
assert_eq!(lines[0].text_from_any(), "HELLO");
capitalization_normalization(&mut lines);
let items = lines[0].syllables().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text(), "Hello");
let sub_items = items[0].as_full().unwrap().sub_items();
assert_eq!(sub_items[0].text, "He");
assert_eq!(sub_items[1].text, "llo");
}
}